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