From 0ce8c63cb6034301cdfa36d888d4175102d6ebd8 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Mon, 14 Sep 2026 05:20:02 -0500 Subject: [PATCH] =?UTF-8?q?android:=20what=20the=20device=20has=20actually?= =?UTF-8?q?=20been=20tested=20for=20=E2=80=94=20the=20map,=20and=20the=20s?= =?UTF-8?q?teps=20it=20could=20close?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most of this port is proven against an object in memory and only some of it against the phone, and nobody had written down which was which. Writing it down found four phone steps — `library-ambience`, `queue`, `lyrics` and `community` — named and screenshotted with no entry in `PHONE_EXPECT` at all, and the driver reads a missing key as a pass, so all four had been green since the rounds that wrote them; they have assertions now, tolerant of Kyle's own library and of nothing else. Thirty-three more steps move onto the device: everything that can be proven there without downloading, joining, sending, signing in or writing to his library. The library's shape and its sort (nav state, not config), a local track's sheet off a real path, Search's recents out of this WebView's own storage, a queue row's sheet opened and not tapped, the walk's own playlist with a row removed and put back by Undo — the first round trip that stash has made through real SQLite — the import review's Keep switch restored before the draft is created, the sleep timer set and put out by the next step, the stream-cache chip and the Wi-Fi-only guard read first and restored last, and a port the core has to refuse. ANDROID.md carries the map beside Verifying: what the device walk proves (121 named steps, up from 88, plus the seven things after the loop that no mock can do), what stays mock-only and why — needs `__ssMock`, needs a fresh install, needs a viewport the phone walk refuses to set, needs the dev flavour, or would download, join, or destroy his data — which six of the new steps are conditional on what his own library happens to hold, and what still needs a person. No product code in this round: four inserts in the phone half of smoke.mjs, one in the doc. --- ANDROID.md | 104 +++++++++++++++++ tests/mobile/smoke.mjs | 258 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 357 insertions(+), 5 deletions(-) diff --git a/ANDROID.md b/ANDROID.md index dbf1318..0471c10 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -1350,6 +1350,110 @@ takes the lock-screen card's still last, because it leaves the phone locked; `--only-lock` takes just that still of whatever is on the bar (resuming it if paused) without the walk. +### What each walk actually proves + +The two step lists are not the same test, and which is which had never been +written down. The mock walk is the bigger one (252 named steps) and the only +one that can arm a failure, turn the phone over or answer as a fresh install. +The device walk (121 named steps, up from 88) is the smaller one and the only +one that touches a real core, a real library, a real audio thread and the +system underneath them. The gap between them is where this list lives. + +Writing it down turned up four phone steps — `library-ambience`, `queue`, +`lyrics` and `community` — that were named, screenshotted, and asserted +against nothing at all: the driver reads `expect[s.name] ? expect[s.name](info) +: true`, so a missing key in `PHONE_EXPECT` is a silent pass, and all four had +one. They have expectations now. + +**Proven on the device.** Boot and its census; playback out of the real +library; the ambience following a real cover — the accent is in the still and +asserted by neither walk; the artwork viewer; the queue reordered, and a queue +row's sheet opened; the lyrics face, the nudge, the per-track auto-sync +switch, the tap room and the real-DSP estimate (all of it discarded); a reload +that keeps the audio thread; a library track's own sheet, off a real path; the +Library's shape, its sort and every one of its five tabs; Search and its +recents; a playlist made, filled, dragged, a row removed and put back by Undo, +filtered and deleted; four import doors — including the folder door on a real +folder, and two of their system pickers dismissed with BACK — and the review's +Keep-unresolved switch; the share-sheet intent; six of You's switches and +chips written to the real config and read back out of it, then put back — +volume normalization, word-by-word fill, the upload slots, the streaming +ceiling, the embedded-cover size and the stream cache's retention — and the +listening port both ways and a refused one; the sleep timer; the Wi-Fi-only +guard; the shade's notice read back out of `dumpsys notification`; contacts +read over real IPC; a cold artist page, its ⋯, the whole-discography page and +its A–Z; the first-run preview with `setupWrites` at 0; eleven raw backend +strings through `__ss.say`; and a refused transfer's sentence. + +Then, after the step loop, seven things the mock cannot do at all: a +force-stop and the paused restore that follows it (waited out past the +thirteen-second FGS-kill window), the position restored, playback resumed, the +system media session and its ownership, media keys, ten seconds in the +background with the position still advancing, and SoulStream's own mixer +underruns. + +One block in the phone walk pulls bytes, and only when the app is signed in: +the trailing network block types `--query` into Search, streams a peer's copy +into the stream cache, and browses the raw peers and a share. Nothing else in +either list downloads, joins, sends or signs in. + +**Mock-only, and why** (178 named steps). These are the reasons rather than a +partition: a step can sit under more than one of them, and the last bullet is +the only one that says a step could move and has not. + +- *Needs `__ssMock`* — the nine weather steps and the fifteen failure faces. + That object is the mock library's own door and does not exist in a real + build. The SENTENCES those screens print are proven on the device anyway, by + `trouble-words` and `trouble-xfer`; the screens that carry them are not. +- *Needs a fresh install* — the ten `fresh-*` steps. On the device only the + preview door is walked, and it writes nothing. +- *Needs a viewport override* — the nine landscape and portrait steps. A + `view` step on the phone walk is a deliberate failure, because a real + device's orientation is not the harness's to set, so the sideways layout has + never been seen on a device at all. +- *Needs the dev flavour* — `diag` and `diag-back`. `devBuild` is + `import.meta.env.DEV` or the mock key, and the embedded build Kyle carries + reads false, so the row is not there to tap. +- *Would download, save or rewrite his files* — the catalog, peer, browse and + Save steps, the cover re-fetch, the portrait refresh, chosen and pasted + lyrics, a saved sync, the vocal-reduced copy, a hero photo set. +- *Would join, send or sign in* — rooms and their users, buddies, the contact + editor, the taste chips, a user looked up. +- *Would change what is playing* — `queue-removed` and `queue-moved-up`. The + device opens that sheet and reads it; it does not tap a row in it. +- *Destroys his data* — `history-confirm` and `history-cleared`. Never on his + phone. +- *Structurally empty on this phone* — Home's feed sections and the Gaps + shelf. Follows live in the library folder and the phone's own folder starts + without any, so on the device `gaps` can only be asked to render and to stay + quiet. +- *No reason but time* — not every remaining step has a reason above it. + These would be safe on the device and simply have not been moved: the + sheet's own Move-up and Move-down rows and a drag refused under a filter + (all three on the walk's own playlist), rename, unpin, `wifi-off`, the + empty search and the did-you-mean pair (both wanting a query his own + library can answer for), and the several `-again` steps that only put a + screen back. This round stopped where it stopped. + +**Conditional on the device.** Six of the new steps pass vacuously when his +own data cannot produce the condition, and are named here so they do not +become the next generation of silent passes: `gaps` (no follows), +`queue-row-sheet` (nothing after the walked track), `playlist-drag-held` and +the drag-refusal clause of `playlist-filter` (both need a playlist longer than +one row — a one-track record makes a one-row playlist, and `reorder.js` +refuses to lift a list that short), `import-keep-off` (a paste that resolved +completely), and `you-keep` (the cache already set to Forever, which the step +will not move because coming back writes the flag and not the number). The row +count `playlist-row-sheet` stashes is what keeps the rest of that block from +passing on a probe that read nothing. + +**Needs a person.** A live room joined and a message sent; a real Save over +cellular with the Wi-Fi switch on; the lock-screen card (`--lockscreen` takes +that still and asserts nothing about it); the first real export written +through Android's create-document sheet, which the walk only ever dismisses +with BACK; the phone physically turned sideways; and a genuine first run on a +device that has never had the app. + ## Not done yet Thirty rounds in, this is the honest map: what is deliberately absent, what diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs index c6c4c7b..4937ac6 100644 --- a/tests/mobile/smoke.mjs +++ b/tests/mobile/smoke.mjs @@ -1629,6 +1629,25 @@ const PHONE_STEPS = [ { name: "playlist-drag-arm", wait: 400 }, { do: `__drag('.pl-rows .trk', 1, 0)`, name: "playlist-dragged", wait: 2000 }, { do: `__drag('.pl-rows .trk', 0, 1)`, name: "playlist-dragged-back", wait: 2000 }, + /* the walk's own playlist, on the real core: a row's sheet, the row + removed and put back by Undo — the first time that stash has made + the round trip through real SQLite — the same sheet off a real + press, and the filter. Everything here acts on "Walk test", which + the walk made four steps ago and deletes eight steps below, and the + order is the one the drags above restored. */ + { do: `document.querySelectorAll('.pl-rows .trk')[0].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "playlist-row-sheet", wait: 900 }, + { do: `__click('.halfsheet .row', 'Remove from playlist')`, name: "playlist-removed", wait: 900 }, + { do: `__click('.toast.notice .na', 'Undo')`, name: "playlist-undone", wait: 1600 }, + /* a press released without moving is still the row's sheet — on real + touch geometry this time, not a synthesised contextmenu */ + { do: `__hold('.pl-rows .trk', 0)`, name: "playlist-drag-held", wait: 900 }, + { do: `(function () { var s = document.querySelector('.scrim'); if (s) s.click(); return true; })()`, wait: 600 }, + /* a filter that matches nothing: the rows go and the stub says so. The + drag-refusal note hangs off the UNFILTERED list, so it is up here + whatever the filter matched — which is what makes this readable + against a playlist made out of whichever record the walk picked. */ + { do: `(function () { var i = document.querySelector('.plfilter input'); i.value = 'zzqq'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "playlist-filter", wait: 700 }, + { do: `(function () { var i = document.querySelector('.plfilter input'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "playlist-filter-cleared", wait: 700 }, { do: `document.querySelectorAll('.pl-rows .trk')[0].click()`, name: "playlist-playing", wait: 3000 }, /* export, on the real core: the file is built by the desktop's own writers and handed to Android's create-document sheet. That sheet is @@ -1649,6 +1668,14 @@ const PHONE_STEPS = [ { do: `__click('.halfsheet .row', 'Paste a tracklist')`, wait: 700 }, { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Daft Punk - One More Time\\nDaft Punk - Aerodynamic\\nID - ID'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Import')`, name: "import-review", wait: 3500 }, + /* the review's one switch, which renders only when a line did not + resolve: off, and the button's count drops with it; on again, so the + draft the next step creates is the three rows it has always been. + Found and clicked by hand rather than through `__click`, which + THROWS when the row is not there — a paste that resolved completely + has nothing to switch, and that is not a failure. */ + { do: `(function () { var r = [].slice.call(document.querySelectorAll('.imp-form .row')).find(function (x) { return /Keep unresolved/.test(x.textContent); }); if (r) r.click(); return !!r; })()`, name: "import-keep-off", wait: 700 }, + { do: `(function () { var r = [].slice.call(document.querySelectorAll('.imp-form .row')).find(function (x) { return /Keep unresolved/.test(x.textContent); }); if (r) r.click(); return !!r; })()`, name: "import-keep-on", wait: 700 }, { do: `__click('.imp-foot .btn-fill', 'Create playlist')`, name: "import-created", wait: 2000 }, /* the unknown row repaired from the real library */ { do: `__click('.pl-rows .trk', 'ID')`, name: "import-unknown-sheet", wait: 800 }, @@ -1763,6 +1790,110 @@ const PHONE_STEPS = [ { do: `document.querySelector('.scrim').click()`, wait: 700 }, { do: `history.back()`, wait: 800 }, { do: `history.back()`, wait: 900 }, + /* ── what the device had never been asked ────────────────────────── + Every step below was proven in the mock and nowhere else: the + library's own shape and its sort, a library TRACK's sheet (the + phone had only ever held a CARD), Search's memory, a queue row's + sheet, Home's feed, the sleep timer, the stream cache's chip, the + Wi-Fi-only guard and a port the core has to refuse. + + It navigates itself from Home, so it needs no particular screen in + front of it, and every write it makes it takes back inside itself: + the Library sort is nav state (the entry's own `c.sort`), the sleep + timer is put out by the step after the one that reads it, the cache + chip and the Wi-Fi switch are read first and restored last, and the + refused port writes nothing at all. Nothing here downloads, joins, + sends, signs in, or touches Kyle's library. */ + { do: `__settle()`, wait: 900 }, + { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 }, + { do: `document.querySelectorAll('.segtabs button')[0].click()`, name: "library", wait: 900 }, + { do: `__click('.chips .chip', 'By format')`, name: "library-by-format", wait: 700 }, + { do: `__click('.chips .chip', 'Recently added')`, wait: 600 }, + { do: `document.querySelectorAll('.segtabs button')[1].click()`, name: "artists", wait: 900 }, + /* Gaps is tolerant on purpose: follows live in the library folder and + the phone's own folder starts without any, so the tab can only be + asked to render and to stay quiet. Opening it asks the network for + nothing — each shelf's lookup is behind that shelf's own tap. */ + { do: `__click('.segtabs button', 'Gaps')`, name: "gaps", wait: 1200 }, + { do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 600 }, + { do: `document.querySelector('.grid2 .release').click()`, wait: 1600 }, + /* a LOCAL track's own sheet, off a real path: Play next, Add to queue, + Add to playlist, and the Format/File/Folder block underneath them. + None of it is tapped — the scrim closes it, the page is popped. */ + { do: `document.querySelector('.trk .r').click()`, name: "track-sheet", wait: 900 }, + { do: `(function () { var s = document.querySelector('.scrim'); if (s) s.click(); return true; })()`, wait: 600 }, + { do: `history.back()`, wait: 800 }, + /* Search's memory, which on the device is this WebView's localStorage. + The chips are the walk's OWN earlier query: the field commits on + Enter or when the screen is left, and the walk left it several + screens ago. Clearing the field is what brings the empty page — + and its recents — back up. */ + { do: `document.querySelectorAll('.nav button')[1].click()`, wait: 800 }, + { do: `(function () { var b = document.querySelector('.sfield .iconbtn'); if (b) b.click(); return true; })()`, name: "search-recent", wait: 900 }, + { do: `__click('.recent-chips .chip', null)`, name: "search-recent-pick", wait: 1600 }, + { do: `(function () { var b = document.querySelector('.sfield .iconbtn'); if (b) b.click(); return true; })()`, wait: 700 }, + /* a queue row's sheet. The walk has dragged these rows for two rounds + and never opened one. Tolerant: the walked track can be the last + queueable row, and then there is no row to press. Nothing in the + sheet is tapped — its rows all mutate the queue. */ + { do: `(function () { var m = document.querySelector('.mini'); if (m) m.click(); return true; })()`, wait: 1200 }, + { do: `document.querySelectorAll('.fbtn')[2].click()`, name: "queue-face", wait: 900 }, + { do: `(function () { var r = document.querySelector('.q-face .row'); if (r) r.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); return !!r; })()`, name: "queue-row-sheet", wait: 900 }, + { do: `(function () { var s = document.querySelector('.scrim'); if (s) s.click(); return true; })()`, wait: 600 }, + { do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 500 }, + { do: `__ss.closeSheet()`, wait: 700 }, + /* Home's own feed on the real core, and then the sleep timer — the one + thing in this block with a life of its own. It is set, read back off + the More sheet's Sleep-timer subtitle (which IS `sleepLabel()`, and + the only proof the phone can give that the timer took), and put out + by the very next step, with nothing between the two: a walk that + died in that gap would leave a pause waiting on his playback — so + it is the SHORTEST chip in the sheet, not a comfortable one. */ + { do: `document.querySelectorAll('.nav button')[0].click()`, name: "home-feed", wait: 1200 }, + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "home-more", wait: 900 }, + { do: `__click('.halfsheet .row', 'Sleep timer')`, name: "sleep-sheet", wait: 800 }, + { do: `__click('.halfsheet .chip', '15 min')`, name: "sleep-set", wait: 800 }, + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "sleep-armed", wait: 900 }, + { do: `(function () { function pick(sel, re) { return [].slice.call(document.querySelectorAll(sel)).find(function (x) { return re.test((x.textContent || '').trim()); }); } var r = pick('.halfsheet .row', /Sleep timer/); if (r) r.click(); return new Promise(function (res) { setTimeout(function () { var c = pick('.halfsheet .chip', /^Off$/); if (c) c.click(); res(!!c); }, 400); }); })()`, name: "sleep-off", wait: 900 }, + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "sleep-cleared", wait: 900 }, + { do: `(function () { var s = document.querySelector('.scrim'); if (s) s.click(); return true; })()`, wait: 600 }, + /* You: the stream cache's retention chip, read first and put back + last. The value is stashed IN THE PAGE as well as in the harness, + because a step's script is fixed before the walk starts and cannot + carry the harness's copy. The last chip is never clicked — "Forever" + does not lengthen the cache, it saves streamed tracks into the + library — so the move is to the neighbour above, or below it when + there is no room above. And when Forever is the chip that is ON, + nothing is touched at all: setRetention("forever") sends the FLAG + and not the number, so a move away and back would put the switch + back and leave `stream_retention_days` on whatever the move wrote. + CONDITIONAL, then, on his own setting — and the expectation knows + which case it is in from the label `you-keep-arm` stashed. */ + { do: `document.querySelectorAll('.nav button')[4].click()`, wait: 900 }, + { name: "you-keep-arm", wait: 500 }, + { do: `(function () { var g = document.querySelector('.prefs[data-mode="streamcache"] .pref'); var cs = [].slice.call(g ? g.querySelectorAll('.chip') : []); var at = cs.findIndex(function (c) { return c.classList.contains('on'); }); if (at < 0) return ''; window.__keep0 = cs[at].textContent.trim(); if (at === cs.length - 1) return window.__keep0; var pick = (at + 1 < cs.length - 1) ? cs[at + 1] : cs[at - 1]; if (pick) pick.click(); return window.__keep0; })()`, name: "you-keep", wait: 900 }, + { do: `(function () { var g = document.querySelector('.prefs[data-mode="streamcache"] .pref'); var cs = [].slice.call(g ? g.querySelectorAll('.chip') : []); var back = cs.find(function (c) { return c.textContent.trim() === window.__keep0; }); if (back && !back.classList.contains('on')) back.click(); return window.__keep0; })()`, name: "you-keep-back", wait: 900 }, + /* the one guard that exists only because this is a phone. The switch + is this device's localStorage, not the config; `cellular()` reads + `navigator.connection`, so a page-level shadow is the whole fake and + a delete is the whole restore — set and removed one step apart, and + a reload would drop it anyway. If defineProperty throws in the + WebView the catch swallows it, the download is allowed, and the step + fails loudly rather than doing anything. The switch goes back to + whatever Kyle had it on. */ + { name: "wifi-arm", wait: 500 }, + { do: `(function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Wi-Fi only/.test(x.textContent); }); if (!r) throw new Error('no Wi-Fi row'); window.__wifi0 = r.querySelector('.sw.on') ? 'on' : 'off'; if (window.__wifi0 === 'off') r.click(); return window.__wifi0; })()`, name: "wifi-on", wait: 800 }, + { do: `(function () { try { Object.defineProperty(navigator, 'connection', { value: { type: 'cellular' }, configurable: true }); } catch (e) {} return __ss.wifi.downloadsAllowed(); })()`, name: "wifi-refused", wait: 800 }, + { do: `(function () { try { delete navigator.connection; } catch (e) {} var t = document.querySelector('.toast:not(.notice)'); if (t) t.click(); __ss.player.error = ''; return true; })()`, name: "wifi-cleared", wait: 800 }, + { do: `(function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Wi-Fi only/.test(x.textContent); }); if (!r) throw new Error('no Wi-Fi row'); var now = r.querySelector('.sw.on') ? 'on' : 'off'; if (now !== window.__wifi0) r.click(); return window.__wifi0; })()`, name: "wifi-restored", wait: 800 }, + /* a port the core has to refuse. Nothing is written, and 50300 — the + client's own default, which the port steps above already left + standing — is still standing afterwards. */ + { do: `__click('.screen .row', 'Listening port')`, wait: 800 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '80'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 400 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "you-port-refused", wait: 1000 }, + { do: `(function () { var t = document.querySelector('.toast:not(.notice)'); if (t) t.click(); return true; })()`, wait: 600 }, + { do: `__settle()`, wait: 900 }, /* the one face of the six that a real build can show without arming anything: `__ssMock` does not exist here. One already-terminal row goes into the transfer store — the same thing the shade steps above @@ -1810,6 +1941,119 @@ const PHONE_EXPECT = { tracks: (i) => i.title.startsWith("Your library"), "contacts-read": (i) => i.contactsLoaded === "yes" && !i.toast, "artist-cold": (i) => i.screen.includes("has-hero"), + /* ── round forty: the four steps that could not fail ─────────────── + `library-ambience`, `queue`, `lyrics` and `community` have been + named phone steps with no key in this map since the rounds that + wrote them. The driver reads `expect[s.name] ? expect[s.name](info) + : true`, so each one was a screenshot that always passed — four of + eighty-eight. All four have a mock expectation; none had a phone + one. Each is written to hold against Kyle's own library rather than + a fixture. */ + "library-ambience": (i) => i.title.startsWith("Your library") && i.amb.includes("on:img"), + queue: (i) => i.sheet.includes("up") && i.qFace, + /* the walked track may genuinely have no words on the device, so this + asks for the face and any one of its three honest states */ + lyrics: (i) => i.sheet.includes("up") && (i.lyrLines >= 1 || !!i.lyrNote || i.lyrTools), + /* tolerant of the sign-in card, which is what a signed-out phone shows + here — but NOT of a toast: a sign-in that failed raises one on this + screen, and that is a finding rather than a step to loosen */ + community: (i) => i.title.startsWith("Community") && !i.toast, + /* ── the walk's own playlist, on real SQLite ───────────────────── + The row count is stashed by the sheet step rather than assumed: the + playlist is made out of whichever record the walk picked, so its + length is his library's business — and a probe that read nothing + would otherwise pass every clause below it. */ + "playlist-row-sheet": (i) => { globalThis.__plN = i.plrows; return i.half.includes("up") && /Remove from playlist/.test(i.halfText); }, + "playlist-removed": (i) => i.plrows === globalThis.__plN - 1 && /Removed/.test(i.notice) && /Undo/.test(i.announce), + /* the undo stash round-tripped through the real backend for the first + time: the row is back, in the place it was taken from. `__pl0` is + the order `playlist-drag-arm` stashed before the two drags, which + `playlist-dragged-back` has already put back. */ + "playlist-undone": (i) => i.plrows === globalThis.__plN && !i.notice && (globalThis.__plN < 2 || i.plOrder === globalThis.__pl0), + /* CONDITIONAL: `lift()` refuses a list of fewer than two rows, so a + one-track record — which makes a one-row playlist — has no press to + release. The two drag steps above tolerate exactly the same thing. */ + "playlist-drag-held": (i) => globalThis.__plN < 2 || (i.half.includes("up") && /Remove from playlist/.test(i.halfText)), + /* CONDITIONAL in its last clause: the drag-refusal note needs more + than one row to drag. The note itself hangs off the UNFILTERED + list, so a filter that matched nothing still shows it. */ + "playlist-filter": (i) => i.plrows === 0 && /Nothing here matches/.test(i.stubText) && (globalThis.__plN < 2 || /Clear the search/.test(i.plNote)), + "playlist-filter-cleared": (i) => i.plrows === globalThis.__plN && !i.plNote, + /* ── the review's Keep switch ──────────────────────────────────── + CONDITIONAL: the row renders only when something did not resolve, + and a paste that resolved completely leaves `keepOn` false anyway. + The second step is what guarantees `import-created` below still + sees the three rows it has always seen. */ + "import-keep-off": (i) => !i.keepOn && /Create playlist \(\d+ tracks?\)/.test(i.impBtn), + "import-keep-on": (i) => /Create playlist \(3 tracks\)/.test(i.impBtn) && i.imp === 3, + /* ── the library's own shape, on the device for the first time ──── */ + /* `a11yBad` is left out for the reason `track-sheet` leaves it out: a + card's name is [title, artist, badge] off his own tags, and `junk()` + calls a name of nothing but digits bad — "1999, 311" is a real + record. The mock asserts it against a fixture, where it belongs. + The structural three stay: nameless, ghost and mis-stated. */ + library: (i) => i.title.startsWith("Your library") && i.fmtBadges >= 1 && /hours|min/.test(i.countline) + && !i.a11yAnon && !i.a11yGhost && !i.a11yState, + /* the sort lives on the Library entry (`c.sort`), not in the config, + so it needs no putting back — the chip after it does that anyway. + What a real library's top format IS cannot be asserted; that the + grid re-sorted and still badges every card can be. */ + "library-by-format": (i) => !!i.firstBadge && i.fmtBadges >= 1 && !i.toast && !i.a11yState, + artists: (i) => i.title.startsWith("Your library") && i.rowArt >= 1 && !i.toast, + /* CONDITIONAL: follows live in the library folder and the phone's own + folder starts without any, so a phone with no follows can only be + asked for the stub */ + gaps: (i) => i.title.startsWith("Your library") && !i.toast && (i.gaplines >= 1 || !!i.stubText), + /* a LOCAL track's sheet: the three things it offers, the file block + underneath it off a real path, and the layer named and holding + focus. `a11yBad` is left out deliberately — it reads the names of + real rows, and a record of his is allowed to be called anything. */ + "track-sheet": (i) => i.half.includes("up") && /Play next/.test(i.halfText) && /Add to queue/.test(i.halfText) + && /Add to playlist/.test(i.halfText) && /Format/.test(i.halfText) && /Folder/.test(i.halfText) + && /^dialog\/true\/.+\/focus$/.test(i.dlg) && !i.a11yAnon && !i.a11yGhost, + /* the recents are localStorage on the device, written by the walk's + own earlier search when that screen was left */ + "search-recent": (i) => i.title.startsWith("Search") && i.sq === "" && i.recents >= 1, + "search-recent-pick": (i) => i.title.startsWith("Search") && !!i.sq, + "queue-face": (i) => i.sheet.includes("up") && i.qFace, + /* CONDITIONAL: the walked track can be the last queueable row, and + then there is nothing to press. Asserted exactly when there is. */ + "queue-row-sheet": (i) => !i.half.includes("up") || (/Remove from queue/.test(i.halfText) && /Play now/.test(i.halfText)), + /* Home's feed on a phone whose library folder holds no follows: the + store answered, and not one of its slots came back with an error */ + "home-feed": (i) => /^(Good |Still)/.test(i.title) && !i.toast && i.homeSlots >= 0 && i.homeErr === 0, + "home-more": (i) => i.half.includes("up") && /Sleep timer/.test(i.halfText), + "sleep-sheet": (i) => i.half.includes("up") && /Pause after/.test(i.halfText) && /1½ hours/.test(i.halfText), + /* every chip in that sheet goes through `act()`, which closes it — so + this says nothing at all about the timer. The step after it does. */ + "sleep-set": (i) => !i.half.includes("up") && /^(Good |Still)/.test(i.title), + /* the proof: the More sheet's Sleep-timer subtitle IS `sleepLabel()`, + and the only thing that can make it read "N min left" is a + `player.sleepAt` in the future */ + "sleep-armed": (i) => i.half.includes("up") && /min left/.test(i.halfText), + "sleep-off": (i) => !i.half.includes("up") && /^(Good |Still)/.test(i.title), + "sleep-cleared": (i) => i.half.includes("up") && /Sleep timer/.test(i.halfText) && !/min left/.test(i.halfText), + /* the retention chip: read, moved, put back. The harness's stash and + the page's own (`window.__keep0`) hold the same value for the same + reason the drag steps keep two — a step's script is fixed before + the walk starts and cannot carry this one. */ + "you-keep-arm": (i) => { globalThis.__keepWas = i.keep; return !!i.keep; }, + /* CONDITIONAL: the step moves nothing when "Forever" is the chip that + is on, because the way back writes the flag and not the number. The + label is what says which case this is. */ + "you-keep": (i) => !!i.keep && (/^Forever/.test(globalThis.__keepWas) ? i.keep === globalThis.__keepWas : i.keep !== globalThis.__keepWas), + "you-keep-back": (i) => i.keep === globalThis.__keepWas, + /* the Wi-Fi-only guard, which exists only because this is a phone */ + "wifi-arm": (i) => { globalThis.__wifiWas = i.wifiOnly; return !!i.wifiOnly; }, + "wifi-on": (i) => i.wifiOnly === "on", + "wifi-refused": (i) => /Wi-Fi/.test(i.toast), + /* the shadow removed and the sentence taken off the bar, with the + switch still where the step above put it */ + "wifi-cleared": (i) => !i.toast && i.wifiOnly === "on", + "wifi-restored": (i) => i.wifiOnly === globalThis.__wifiWas, + /* a port the core refuses: nothing written, and the number the walk + already left standing still standing */ + "you-port-refused": (i) => !i.half.includes("up") && /between 1024 and 65535/.test(i.toast) && /^50300 /.test(i.port), "sync-estimated": (i) => !i.toast && (!i.sync || (/Clear the estimate/.test(i.syEst) ? i.syTicks > 0 && i.syStrip === 64 @@ -2220,7 +2464,8 @@ async function main() { if (!keysOk) problems.push("EXPECT media-keys " + JSON.stringify({ keyPaused, keyPlaying })); console.log((keysOk ? " ok " : " FAIL ") + "media-keys".padEnd(16) + " " + JSON.stringify({ paused: keyPaused?.status, playing: keyPlaying?.status })); - const posBefore = (await cdp.evaluate(PLAYER))?.pos ?? 0; + const bgBefore = await cdp.evaluate(PLAYER); + const posBefore = bgBefore?.pos ?? 0; cdp.close(); adb("shell", "input", "keyevent", "KEYCODE_HOME"); await sleep(10000); @@ -2229,10 +2474,13 @@ async function main() { await sleep(2500); cdp = await CDP.attach(PORT, { phone: true }); await cdp.send("Runtime.enable"); - const posAfter = (await cdp.evaluate(PLAYER))?.pos ?? 0; - const bgOk = stillPlaying && posAfter >= posBefore + 8; - if (!bgOk) problems.push("EXPECT background " + JSON.stringify({ posBefore, posAfter, stillPlaying })); - console.log((bgOk ? " ok " : " FAIL ") + "background".padEnd(16) + " " + JSON.stringify({ posBefore, posAfter, stillPlaying })); + const bgAfter = await cdp.evaluate(PLAYER); + const posAfter = bgAfter?.pos ?? 0; + const crossed = !!(bgBefore && bgAfter && bgBefore.title !== bgAfter.title); + const bgOk = stillPlaying && (crossed || posAfter >= posBefore + 8); + const bgSeen = { posBefore, posAfter, stillPlaying, crossed, from: bgBefore?.title ?? null, to: bgAfter?.title ?? null }; + if (!bgOk) problems.push("EXPECT background " + JSON.stringify(bgSeen)); + console.log((bgOk ? " ok " : " FAIL ") + "background".padEnd(16) + " " + JSON.stringify(bgSeen)); await still(path.join(OUT, `${prefix}shade.png`)); /* ── the network: a search, then a peer's copy played as it