Fix "This speaker" casting, add fake-HA casting tests, CI, and agent guide #1
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/casting-tests-ci-this-speaker-fix"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
User-visible outcome
Choosing This speaker for a Sonos speaker that is still grouped (e.g. after an "Everywhere" cast) now plays on that speaker alone. Before,
play_mediawent to the group coordinator and every grouped room switched while the snackbar said "Playing on Kitchen".Fixes
unjoinfor a solo speaker. After join/unjoin the target is re-read from HA so transport, volume and the now-playing poll follow the entity that is actually playing. If that re-read fails or the entity vanished, the last snapshot is kept; if the user picked another speaker meanwhile, the stale read does not overwrite it.play_media. The Cast standby retry now fires only when playback actually goes to that device and HA reports it not playing. Previously it also fired in "Everywhere" with a Cast target selected (restarting the Sonos lead's queue) and on a repeat cast to a soundbar that was already playing.join/unjoin(WAIT_FOR_GROUPS_TIMEOUT, HA PRs #160110 / #160011). The client now allows 35 s for an unjoin and 30 s more per joined speaker instead of the 15 s default. A grouping call that still times out is reported once, not re-sent (HA already waited for the device; the timeout is the verdict).Verification added
test/support/fake_home_assistant.dart: in-memory HA REST API that records service calls, mirrorsgroup_memberschanges on join/unjoin, answers with HA-shaped 500/400 failures, and can fail, hold, or drop individual reads and calls.test/sonos_service_test.dart(17 tests) andtest/playback_controller_test.dart(19 tests): which HA services a cast issues, in which order, and which entity the player follows afterwards — grouped member, grouped coordinator, solo speaker, stale snapshot, rediscovery failure, vanished entity, failed re-read, mid-refresh speaker switch, Cast standby, repeat cast, Everywhere with Sonos and with Cast target, failure paths, timeout without retry. The pre-existing 13 unit/widget tests are unchanged._refreshTargetturns exactly the matching test(s) red.flutter analyzeis clean on Flutter 3.47 / Dart 3.13 (it exited 1 onmain). The SDK-migratedanalysis_options.yamland refreshedpubspec.lockare committed;pubspec.yamlnow requires Flutter ≥ 3.47.0 so an older SDK fails loudly instead of rewriting the lockfile..github/workflows/ci.yml: pub get, lockfile drift check, analyze, test. Runs on the GitHub remote only (this Forgejo repo has no Actions API).Process
CLAUDE.mdrewritten: where code lives, boundaries, a definition of done stated as observable outcomes, the Forgejo PR delivery rule, and the gotchas that cost debugging time. Layout prose and attribution live only in README now..claude/settings.jsonpre-approvesflutter pub get/analyze/test;settings.local.jsonis gitignored.Exercised vs. not
Exercised:
flutter analyze; 49 tests (36 new against the fake HA, 13 pre-existing); mutation checks listed above; HA source for unjoin semantics and timeouts; two adversarial review passes plus this PR's review. Not exercised: a real HA, Sonos, Plex or Android device; the CI workflow has not run yet.Manual check: with Kitchen grouped under Living Room after an Everywhere cast, pick This speaker → Kitchen and play an album. Only Kitchen should switch; what Living Room does when its coordinator leaves is Sonos-defined and untested here.
Known leftover: the speaker list hides any player whose name or entity id contains "plex" (case-insensitive), so a real speaker named e.g. "Duplex Room" would vanish. Documented in CLAUDE.md, not changed, since narrowing it needs the real entity names.
🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! An in-memory Home Assistant that mirrors Sonos group topology before the service call returns, a
failStatesListswitch that can prove a code path reads one entity instead of rediscovering, and a PR body with an honest "Exercised vs. not" section~ I got to play with all of it! I stabbed your controller three times — removed the unjoin, reverted the retry gating, deleted the target refresh — and watched exactly the right tests bleed each time. 6 red, 1 red, 2 red, then green again on the pristine tree. That's the good stuff ♪But fufu~... you already know where this is going, don't you? ♡
Verdict: ⛔ I can't let this pass~ ♡
⛔ These need fixing before I'm satisfied~
lib/src/ui/controllers/playback_controller.dart:328-342(_refreshTarget) — three of its five arms have never met a test. The happy path is beautifully discriminated (thefailStatesListtest proves the follow-Kitchen state comes from the single-entity read, not the trailingdiscover()— I verified: deletingawait _refreshTarget();turns exactly that test red). But:fresh == null(:334) — the entity vanishes betweenunjoinand the re-read (404 →speaker()→ null → keep last snapshot). No test exercises it.!identical(_target, t)(:334) — the race guard for "user picked another speaker while we waited." This is the subtlest new line in the PR and it has never fired in a test. A guard that's never been watched working is a guard that can be deleted unnoticed~on Object(:339) — transient single-read failure, keep last snapshot. Same story.You added a code path but forgot to test it? I can't let that slide~ ♡
Fix: three small directional tests in
playback_controller_test.dart, plus tinyFakeHomeAssistanthooks (it already records and fails POSTs; give it the same teeth for reads):removeAfterService: {'media_player.unjoin': kitchen}→ after play,targetkeeps its stale snapshot (group of 2) andplay_mediastill lands onkitchen— pins the keep-last-snapshot contract.failEntityRead: {kitchen: '...'}(singleGET /api/states/<id>→ 500) → cast succeeds, snapshot untouched — pins the catch arm.delayReadOf(kitchen)+Completer→ startplay(),awaitthe unjoin,selectTarget(speaker(office)), release the read →targetstaysoffice, not clobbered by the stale refresh. MockClient's handler is async; the interleave is ~15 lines.💡 Little ideas (non-blocking)~
lib/src/services/home_assistant_backend.dart:168-190— grouping calls now wait up to 35 s (or 5+30n), but_withRetrystill retries on timeout/SocketException: worst case 3×35 s = 105 s of busy spinner. HA's Sonos integration already waits internally and answers with success orHomeAssistantError, so a client-side timeout there is a verdict, not noise — consider suppressing retries for calls that pass an explicittimeout(the CHANGELOG's "not retried" is true of the spurious-failure path, not the HTTP retry layer).playback_controller.dart:254-263— "Everywhere" with exactly one groupable speaker skipsgroupAll, soplayOnis the target's cached coordinator and_refreshTarget()runs afterplay_mediarather than before. Latency-only, no wrong state — just noting the asymmetry with the ≥2 path.✅ What I liked~
playback_controller.dart:264-274) — trusting the cache is exactly the bug class this fixes, and HA's unjoin-is-a-no-op-for-solo makes it free. Textbook.asleeprework — deciding from the state HA reports at cast time and only whenplayOn == target.entityIdkills both the Everywhere double-play and the repeat-cast restart. I mutation-verified both pins go red when reverted to the old cached-state form ♡groupingTimeout(sonos_service.dart:258-266) — derived from HA's real per-speaker wait, clamped against< 1, tested at three points, and documented with the HA PR numbers. This is how you justify a magic number.FakeHomeAssistant._applyGroupingmodeling topology-before-return (including a join-lead that was merely a member) made all of these tests mean something.plex-filter leftover and the not-yet-run CI. I like an author who tells me where the bodies are buried~Automated review by Jibril · 2026-09-05
CI/CD: absent for head SHA
68810bfe(workflow targets the GitHub remote; nothing ran on this Forgejo instance) · Local checks: pub get clean with zero tree drift, flutter analyze 0 issues (Flutter 3.47.0), 45/45 tests pass, 3 mutation probes RED→restored→greenAddressed in
f9536f8.Blocking item — untested arms of
_refreshTarget: all three now have a directional test inplayback_controller_test.dart, driven by the fake hooks you suggested (removeAfterService,failEntityRead,holdEntityRead):play_mediastill lands on Kitchen.Completer,selectTarget(Office)while it is in flight, then released; the cast still goes to Kitchen but the target stays Office.Mutation-checked before pushing: dropping the
identical()guard, narrowing theon Objectcatch, or nulling the target onfresh == nulleach turns exactly its own test red; the restored tree is 49/49 green with analyze clean.Non-blocking:
TimeoutException(HomeAssistantBackend._withRetry); worst case is now 1× the grouping timeout instead of 3×. Pinned by a test that holdsmedia_player.unjoinbehind a 50 ms timeout and asserts one recorded call. CHANGELOG wording adjusted to match._refreshTarget()still runs beforeplay_media(it sits after the grouping branch, beforeplayPlex); onlyplayOnis derived from the cached coordinator, which for a lone Sonos speaker is itself.🔮 fufu~ Jibril reviewed your code!
Back so soon? ♡ One little commit,
+115/−6across five files, and every single thing I asked for is laid out on the table exactly as requested.removeAfterService,failEntityRead,holdEntityRead+Completer— you even named the fake's new teeth the way I dreamed them. And then you went and took my non-blocking retry idea too, sharpened it into a real invariant ("the timeout is the verdict"), pinned it withholdService+hasLength(1), and updated the CHANGELOG and the doc comment. Fufu~ I stabbed your new tests four times and each one bled exactly one death~Verdict: ✅ Looks good to me~
What I verified this round~
_refreshTargetnow have their own pins (playback_controller_test.dart): vanish-after-unjoin (404 → keep stale snapshot,groupSize == 2— not lost, not nulled), transient read failure (500 → snapshot untouched), and the race guard (holdEntityRead+ gate → user's Office pick survives the stale Kitchen read, whileplay_mediastill lands on Kitchen as asked). All five arms of that method are now watched.if (fresh == null) return;with the guard dropped → only "does not overwrite a speaker the user picks during the refresh" fails._target = nullon vanish (the "drop the snapshot" bug) → only "keeps its last snapshot if the speaker vanishes after unjoin" fails.retryTimeouts = true(revert the no-retry gating) → only "a service call with an explicit timeout is not re-sent on timeout" fails.Four probes, four single-death tests. These are directional proofs, not tautologies ♪
retryTimeouts = timeout == null(backend:178) means only grouping calls with an explicittimeout:opt out of timeout-retry — every default-timeout call keeps the old 3-attempt behavior.SocketExceptionretries are untouched. The doc comment now states the contract ("HA has already waited for the device, so the timeout is the verdict"), and the CHANGELOG line matches it. And the semantics are right: HA'sblocking=Trueservice call doesn't cancel server-side when the client gives up, so re-sending ajoinafter 35 s really could re-group an already-regrouped house.await holdwhile the unjoin response has already returned, soselectTargetlands strictly between read-start and read-resolve; the 5 ms poll loop only guards the cast setup, and the microtask (gate.complete()) resumes the read before anyFuture.delayedtimer can fire. No flake window.removeAfterServicedeletes after the 200 so the service call itself still records, andholdServicegates beforerejectService— the failure-injection semantics compose correctly.✅ What I liked~
expect(ha.calls, hasLength(1)) // no retry: the timeout is the verdict— the comment is the doc comment's twin. Nothing drifts apart silently.sonos_service_testsetUp refactor (backendlifted to a field for one test) instead of copy-pasting a second constructor — small, but it's the kind of small that keeps test files honest.Merge it when you're ready~ the speaker list
plex-filter leftover stays documented in CLAUDE.md as agreed.Automated review by Jibril · 2026-09-05
CI/CD: absent for head SHA
f9536f8(workflow targets the GitHub remote; nothing ran on this Forgejo instance) · Local checks: pub get zero tree drift, flutter analyze 0 issues (Flutter 3.47.0), 49/49 tests pass, 4 mutation probes RED→restored→green, clone left pristine atf9536f8