fix(media_player,config_flow): close the batch review's O2 and O3 (t17)

Both observations from the t8-t12 batch review, settled by execution rather than
by argument.

O2 — WHICH STATES MAY CARRY A NOW-PLAYING ROW. The review read
`extra_state_attributes` and argued the row publishes the entity state verbatim,
so `now_playing_state: on` is reachable, while the README documents only
playing/paused/buffering/idle; t10's author had reported that `on` and `off`
publish no row at all. The probe settles both halves:

* the t10 account is true on the NATURAL ladder — `on` means no source reported a
  power or playback fact, and every tier that can produce a reading sets
  `reported = True` in the ladder, so the two cannot meet;
* the review is right about the RULE. It was "publish unless the state is off", an
  assumption nothing enforced: forcing the ladder to `on` (and to `standby`, which
  the README does not even mention) published `now_playing_state: on`/`standby`
  beside a real title.

So the rule is now explicit — `NOW_PLAYING_STATE_VALUES = ("playing", "paused",
"buffering", "idle")` — and a reading is dropped whole when the entity state is
anything else, exactly as `off` always dropped it. Nothing legitimate is lost: a
tier only ever answers in one of those four (`idle` for "reported, nothing
playing", which is where the foreground tier's remembered-playback bridge lands).
The README says the same thing in the table row and in prose, and
tests/test_now_playing_publication.py walks EVERY `MediaPlayerState` member with a
reading present (the four publish themselves; on/off/standby publish nothing),
then reads the four values back OUT OF THE README and compares them to the
constant, so code and docs cannot drift again.

O3 — A REJECTED APP PASTE NAMES ITS BAD LINES. The bulk paste was all-or-nothing
and reported a generic `invalid_app_map`, so a 30-row paste with one typo had to
be bisected by hand. `_app_map_parse` now returns `(map | None, problems)` where
each problem carries the 1-based line NUMBER, the line as the user typed it, and
why (empty/space-bearing package, empty name, space in the icon), and the
rejection puts them in the form's `description_placeholders["invalid_lines"]`,
which the error copy interpolates — capped at 4 named lines with the remaining
count, so the message stays readable. The map is still None when anything is
wrong (never half-stored) and the detail is cleared as soon as a paste parses.
The single-row editor is untouched and keeps its own `invalid_app_row`; a test
asserts the new per-line report and `_valid_app_row` accept and refuse the same
rows, so a paste and a typed row cannot disagree.

Verify: 794 passed / 4 skipped, 795 passed / 3 skipped (with androidtvremote2),
ruff clean.
This commit is contained in:
James Mackay committed 2026-10-12 12:36:15 +01:00
1 parent 91d9fb3883
commit 2819ee5a49
7 files changed
+489 -33

No files matched your search

+154
View File
@@ -706,6 +706,160 @@ class TestAppIconColumn:
assert "app_name_map" not in flow._options # nothing half-stored
class TestAppPasteRejectionNamesTheLine:
"""t17/O3: a rejected paste says WHICH line is wrong, not just that one is.
The all-or-nothing rule stays (nothing is half-stored), but the message now
quotes each offending line with its number and the reason, so a 30-row paste
with one typo is fixed instead of bisected by hand. The row editor's own
single-row behaviour is pinned alongside, because neither half may regress.
"""
def _flow(self, tmp_path, options=None):
module = _form_module()
entry = SimpleNamespace(options=dict(options or {}), title="Shield")
flow = module.NvidiaShieldOptionsFlow()
flow.config_entry = entry # t29 G3b: HA assigns config_entry
flow.hass = FakeHass(str(tmp_path))
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
return module, flow
async def test_the_message_names_the_numbered_bad_line(self, tmp_path):
module, flow = self._flow(tmp_path)
result = await _options_submit(
flow,
{
"app_name_map": (
"ar.tvplayer.tv=TiviMate\n"
"com.no.equals.sign\n"
"com.netflix.ninja=Netflix"
)
},
)
assert result["errors"] == {"base": "invalid_app_map"}
detail = result["description_placeholders"]["invalid_lines"]
# the SECOND line is the bad one, and it is quoted as the user wrote it
assert "line 2" in detail
assert "com.no.equals.sign" in detail
assert "no '='" in detail
assert "line 1" not in detail and "line 3" not in detail
assert "app_name_map" not in flow._options # still all-or-nothing
async def test_every_bad_line_is_named_up_to_the_cap(self, tmp_path):
module, flow = self._flow(tmp_path)
result = await _options_submit(
flow,
{
"app_name_map": (
"com.good.one=Name\n"
"com.bad.space in id=Name\n"
"com.bad.blank=\n"
"com.bad.icon=Name|http://x y/z.png"
)
},
)
detail = result["description_placeholders"]["invalid_lines"]
assert "line 2" in detail and "package id" in detail
assert "line 3" in detail and "name after the '='" in detail
assert "line 4" in detail and "icon after the '|'" in detail
assert "line 1" not in detail # no false accusation
async def test_a_long_bad_paste_is_capped_and_counts_the_rest(self, tmp_path):
module, flow = self._flow(tmp_path)
result = await _options_submit(
flow,
{"app_name_map": "\n".join(f"no-equals-{n}" for n in range(1, 8))},
)
detail = result["description_placeholders"]["invalid_lines"]
assert module.APP_MAP_PROBLEM_LIMIT == 4
assert "line 4" in detail
assert "line 5" not in detail
assert "3 more line(s)" in detail, detail
async def test_a_blank_line_is_not_a_row_and_never_a_problem(self, tmp_path):
module, flow = self._flow(tmp_path)
await _options_submit(
flow, {"app_name_map": "\n\nar.tvplayer.tv=TiviMate\n\n"}
)
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
async def test_a_good_paste_clears_the_previous_rejection(self, tmp_path):
module, flow = self._flow(tmp_path)
await _options_submit(flow, {"app_name_map": "com.bad row=Name"})
assert flow._app_map_problems
result = await _options_submit(flow, {"app_name_map": "com.good.one=Name"})
assert result["type"] == "menu" # back at the Apps screen, nothing left over
assert flow._app_map_problems == ""
assert flow._options_dict["app_name_map"] == {"com.good.one": "Name"}
async def test_the_parser_reports_problems_in_a_pure_call(self):
module = _form_module()
app_map, problems = module._app_map_parse("com.a=One\nbroken\ncom.b=", {})
assert app_map is None
assert [number for number, _line, _why in problems] == [2, 3]
assert problems[0][1] == "broken"
assert "no '='" in problems[0][2][0]
assert "name after the '='" in problems[1][2][0]
# and a valid paste reports nothing at all
assert module._app_map_parse("com.a=One", {}) == ({"com.a": "One"}, [])
async def test_the_report_and_the_wrapper_cannot_disagree(self):
module = _form_module()
icons: dict[str, str] = {}
assert module._app_map_from_text("com.a=One|http://x/i.png", icons) == {
"com.a": "One"
}
assert icons == {"com.a": "http://x/i.png"}
assert module._app_map_from_text("com.bad row=One", {}) is None
def test_the_paste_report_and_the_row_editor_apply_the_same_rule(self):
"""t17/O3 rider: the one-row path is unchanged, and both paths agree.
The row editor is driven in tests/test_config_flow_ux.py
(``test_an_invalid_row_is_refused_and_stores_nothing``); what matters HERE
is that the new per-line report cannot reject a row the editor accepts (or
wave one through that the editor refuses) — otherwise a paste and a typed
row would disagree about the same text.
"""
module = _form_module()
rows = [
("com.a", "Alpha", "", True),
("com.a", "Alpha", "http://x/i.png", True),
("com.bad row", "Alpha", "", False),
("", "Alpha", "", False),
("com.a", "", "", False),
("com.a", "Alpha", "http://x y/i.png", False),
]
for package, name, icon, valid in rows:
assert module._valid_app_row(package, name, icon) is valid, (package, name)
assert bool(module._app_row_problems(package, name, icon)) is not valid, (
package,
name,
icon,
)
# the paste path STRIPS before checking, which is why a whitespace-only
# name cannot get in through it either (t49: nothing blank is stored)
app_map, problems = module._app_map_parse("com.a= ", {})
assert app_map is None
assert problems[0][2] == ["the name after the '=' is empty"]
class TestChannelEntryOptions:
"""P5-2: digit-delay + optional send-enter on the options page."""