diff --git a/ANDROID.md b/ANDROID.md index b226f4e..dbf1318 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -940,6 +940,39 @@ merely not there yet (a fresh install still works), and refuse with into a row under Library folder with the path and what to do about it, and the toast says it in English. +**What the walk has seen.** The round that wrote all of the above was straight +about what it had not proved: six of these fixes shipped with no expectation at +all, because neither `loadHome` nor `loadLyrics` was reachable from the `__ss` +handle the walk drives the app through, and both answer a second ask with +nothing — `loadHome` is gated on `started`, `loadLyrics` on the track's own +`key` — so no amount of tapping re-runs them. They are driven now: Home's +`catalogDown`, the Gaps line, a catalog record's stub, the artist page's two +different sentences, a playlist that would not read, the lyrics `failed` face, +and `failed · ` on a transfer row. Each is armed with +`__ssMock.fail`, asserted both by the words a person reads and by the Rust +string being absent from them, and disarmed by the step that proves the face +heals. The handle grew four names for it and no more — `home` and `loadHome`, +`lyr` and `loadLyrics` — because a test seam that exports the internals +wholesale stops being a seam. The toast's own **Try again** is pressed for the +first time too, through You → **Rescan library**: `lib.error` is written by +`loadLibrary` alone, and that row is the only door in the app that reaches it +a second time. + +What is still unseen, honestly. **Three sites draw a raw backend string**, so +the rule above has holes: `screens/Browse.svelte` — a peer's shares that would +not read, which is a full error face, Try again and all, that was simply missed +— and `screens/Import.svelte`, twice. `SyncRoom.svelte` is a fourth, by way of +`saveTapSync`'s catch. **Six error faces have no expectation either**: Browse's, +Community's "The server did not answer.", Room's "Not in this room.", Album's +"That record is gone.", Playlist's "Playlist not found." and Peer's "Not +compared yet." — all reachable, the first two through the mock's weather and +the rest by pushing a nav entry with a dead id. And of the probe's fields, six +are still read out on every step and asserted on none: `accent`, `xlive`, `hd`, +`hwant`, `navMs` and `a11yWho`. The cheapest real win left in the harness is a +global `hd === hwant` check on every named step — the history mirror is how the +blank page after a confirmed delete was found — but it would fire during the +first-run reloads and needs its own thinking about the suppress window first. + ## The downloads in the shade Transfers is a screen inside the app; the desktop says what a Save is doing @@ -1255,7 +1288,14 @@ weather: a library index that will not read and its Try again, the client taken offline, a refused password and a server that cannot be reached, Search with nowhere to search, a room list that never arrives, and a rescan that refuses because the folder is gone — then all of it cleared and the app left as it was -found. The mock answers every command the phone +found. After the weather come the faces that failure actually draws, each one +armed by the mock and read back by its own words: Home with every follow's +catalog page refused, the Gaps line and its second tap, a catalog record's stub +and its Try again, an artist page whose lookup failed beside one that was +answered with nothing, a playlist that would not read, the lyrics face on a +lookup that never came back, a transfer that was refused, and the library +toast's own Try again pressed for the first time — then all of that disarmed +too, and asserted disarmed. The mock answers every command the phone uses; one it does not know is named in the report (`[mock] no mock for X`) rather than surfacing as a TypeError toast, console warnings carry their top frames (the smoke bundle is built unminified for that), and every probe @@ -1269,8 +1309,12 @@ backend funnels through `with_client()` answers "not connected to Soulseek", the two connects answer the server string, and `soulseek:state` goes out — and `clear()` puts all of it back. They reject with a plain string because that is what Tauri rejects with. Fourteen mock steps drive them, and the phone walk carries -one safe step of its own (`trouble-words`: eleven real backend strings through -`__ss.say` on the device, every output asserted free of Rust). A mock step can +two safe steps of its own (`trouble-words`: eleven real backend strings through +`__ss.say` on the device, every output asserted free of Rust; and +`trouble-xfer`: one already-failed transfer row written into the store and +taken away again — nothing fetched, and a terminal row is not in flight, so +nothing reaches the shade). Fifteen more mock steps drive the failure faces +themselves, which are described under **When things go wrong**. A mock step can also set the viewport — `{ view: [915, 411] }` sends `Emulation.setDeviceMetricsOverride` through the same client, which is how the walk turns the phone over and back; diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte index 9a0a5bf..07a653c 100644 --- a/src/mobile/Mobile.svelte +++ b/src/mobile/Mobile.svelte @@ -36,7 +36,8 @@ import Diagnostics from "./screens/Diagnostics.svelte"; import { transfers, bindTransfers, active as activeTransfers } from "./transfers.svelte.js"; import { xfernote, syncShade, bindXferNote } from "./xfernote.svelte.js"; - import { bindHome } from "./home.svelte.js"; + import { home, bindHome, loadHome } from "./home.svelte.js"; + import { lyr, loadLyrics } from "./lyrics.svelte.js"; import Stub from "./screens/Stub.svelte"; import { lib, loadLibrary, refreshRecents, albumById, albumsBy, watchLibrary } from "./lib.svelte.js"; import { art, wantArt, accentFor, applyAccent } from "./art.svelte.js"; @@ -316,7 +317,7 @@ requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(sealTimer); sealBoot(); })); /* the probe handle — the desktop has __soulstream for the same reason: tests/mobile/smoke.mjs drives the real app through it */ - window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder, startFile }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms }; + window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, home, loadHome, lyr, loadLyrics, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder, startFile }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms }; return () => { if (unlisten) unlisten(); }; }); diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs index 3c5f9cf..c6c4c7b 100644 --- a/tests/mobile/smoke.mjs +++ b/tests/mobile/smoke.mjs @@ -347,6 +347,26 @@ const PROBE = `(function () { folderGone: (document.querySelector('.folder-gone') || {}).textContent || '', lyrNote: (document.querySelector('.lyr-note') || {}).textContent || '', lyrTools: !!document.querySelector('.lyr-tools'), + /* ── the faces round thirty-one drew and no step had ever read ── + Home's own line and the slots behind it (how many follows it + walked, how many came back empty-handed); the catalog's note on + an artist page; whatever stub is up, whitespace collapsed; the + Gaps line, the BUTTON only — the '.gapline.done' variant is a + div and says something else entirely; the failed transfer's + sentence; and whether the lyrics lookup itself failed, as + opposed to having found nothing. Every backslash here is doubled + and there is not one backtick, comment included — this block + lives inside a template literal, and a backtick would END it + (the file still PARSES, so node --check says nothing: what it + becomes is a tagged template, and smoke.mjs throws on import). */ + catDown: (document.querySelector('.sec-note.cat-down') || {}).textContent || '', + homeSlots: (function () { try { return __ss.home.order.length; } catch (e) { return -1; } })(), + homeErr: (function () { try { return __ss.home.order.filter(function (id) { var s = __ss.home.slots[id]; return !!(s && s.error); }).length; } catch (e) { return -1; } })(), + catNote: (function () { var n = [].slice.call(document.querySelectorAll('.screen .sec-note:not(.cat-down)')).find(function (x) { return /catalog/i.test(x.textContent || ''); }); return n ? n.textContent.replace(/\\s+/g, ' ').trim() : ''; })(), + stubText: ((document.querySelector('.screen .stub') || {}).textContent || '').replace(/\\s+/g, ' ').trim(), + gapLine: ((document.querySelector('button.gapline') || {}).textContent || '').replace(/\\s+/g, ' ').trim(), + xferFail: (document.querySelector('.row.xfer.failed .m') || {}).textContent || '', + lyrFailed: (function () { try { return __ss.lyr.failed ? 'yes' : 'no'; } catch (e) { return ''; } })(), trks: document.querySelectorAll('.screen .trk').length, /* the phone's trouble-words step leaves its eleven sentences here */ trouble: (function () { try { return String(window.__trouble || ''); } catch (e) { return ''; } })(), @@ -971,6 +991,105 @@ const MOCK_STEPS = [ { do: `(function () { __ss.slsk.roomsWaitMs = 12000; __ssMock.clear(); __ss.refreshRooms(); __ss.player.error = ''; __ss.lib.error = ''; __ss.player.retry = null; return true; })()`, wait: 900 }, { do: `__settle()`, wait: 900 }, { do: `document.querySelectorAll('.nav button')[0].click()`, name: "weather-clear", wait: 1000 }, + /* ── the faces no walk had seen ───────────────────────────────────── + Round thirty-one gave six screens an honest failure and its own + verifier was straight about what it had shipped unobserved: Home's + `catalogDown`, the lyrics `failed` note, Gaps' `gapErr`, Playlist's + `loadErr`, the catalog record's and the artist page's stubs, and + `failed · ` on a transfer row. It could drive none of them + because neither `loadHome` nor `loadLyrics` was reachable from the + `__ss` handle — both are gated on `started` / `key` and answer a + second ask with nothing at all, so no amount of tapping re-runs + them. The handle carries the two stores and their two loaders now, + and this block is what they were added for. + + It sits HERE, before the first run, and not at the end of the list: + the block below reloads the page twice, and the mock's follows live + in a module-level `let` that a reload empties — after `fresh-off` + nobody is followed, and Home's `catalogDown` (which needs a follow) + and the Gaps shelf (which IS the follows) are both structurally + unreachable. Here the block inherits what the weather left behind: + signed in, on Home, Daft Punk followed, a track on the mini bar. + + Every assertion is on settled state — an armed failure stays armed, + an error stub stays up, the toast stays until it is dismissed — and + the two forced loads return their promise, which `cdp.evaluate` + awaits, so the fetch is over before the step's wait even begins. + Every failure is disarmed by the step that proves the face heals, + and `faces-clear` is the proof that nothing was left armed. */ + { do: `(function () { __ssMock.clear(); __ss.player.error = ''; __ss.lib.error = ''; __ss.player.retry = null; return __settle(); })()`, wait: 800 }, + /* Home with every follow's catalog page refused. `home.svelte.js` has + always set `slot.error` there; until round thirty-one nothing read + it, and until this step nothing had ever seen the line that does. */ + { do: `(function () { __ssMock.fail('catalog_artist', 'io: failed to lookup address information: no address associated with hostname'); return __ss.loadHome(true); })()`, name: "faces-home-down", wait: 1200 }, + /* the same armed failure, on the other screen that asks the same + command: Gaps. Home stays poisoned through these three steps on + purpose — it is asserted above, and the second tap below is what + clears the weather for both. */ + { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 800 }, + { do: `__click('.segtabs button', 'Gaps')`, wait: 700 }, + { do: `__click('.gapline', null)`, name: "faces-gap-down", wait: 1400 }, + /* a failed lookup leaves `gapRows[key]` undefined, so the second tap + genuinely asks again rather than merely opening a rail */ + { do: `__ssMock.fail('catalog_artist', '')`, wait: 300 }, + { do: `__click('.gapline', null)`, name: "faces-gap-again", wait: 1600 }, + /* a catalog record. The walk has already made 302127 `ready` and + `lookupAlbum` returns early on anything but an error, so the entry is + forgotten first — the smallest honest way to say "ask again". */ + { do: `(function () { delete __ss.catalog.albums[302127]; return __ssMock.fail('catalog_album', 'io: connection reset by peer'); })()`, wait: 300 }, + { do: `__ss.push('catalogAlbum', { id: 302127, artist: 'Daft Punk', title: 'Discovery' })`, name: "faces-album-down", wait: 1500 }, + { do: `__ssMock.fail('catalog_album', '')`, wait: 300 }, + { do: `__click('.imp-foot .btn', 'Try again')`, name: "faces-album-again", wait: 1600 }, + { do: `__ss.back()`, wait: 700 }, + /* the artist page has TWO of these and they say different things: a + lookup that FAILED is "didn't answer", one that came back empty is + "had nothing to add". `bestArtistFor` is what has to fail for the + first — failing `catalog_artist` alone never reaches the page at all + and leaves the status at none, which is the other copy. */ + { do: `__ssMock.fail('catalog_search', 'io: failed to lookup address information: no address associated with hostname')`, wait: 250 }, + { do: `__ss.push('artist', { name: 'Hollow Signal' })`, name: "faces-artist-down", wait: 1600 }, + { do: `(function () { __ssMock.fail('catalog_search', ''); __ss.back(); return true; })()`, wait: 700 }, + { do: `__ss.push('artist', { name: 'Hollow Signal' })`, name: "faces-artist-none", wait: 1600 }, + { do: `__ss.back()`, wait: 700 }, + /* a playlist that will not read. Id 1 survives the whole walk — it is + the one renamed to "Late night drive II"; the one deleted was New + Playlist. Try again clears `loadErr` and bumps `again`, which is + what re-arms the effect. */ + { do: `__ssMock.fail('playlist_items', 'unable to open database file')`, wait: 250 }, + { do: `__ss.push('playlist', { id: 1 })`, name: "faces-playlist-down", wait: 1400 }, + { do: `__ssMock.fail('playlist_items', '')`, wait: 300 }, + { do: `__click('.imp-foot .btn', 'Try again')`, name: "faces-playlist-again", wait: 1400 }, + { do: `__ss.back()`, wait: 700 }, + /* the lyrics face. Sheet.svelte asks with the UN-forced `loadLyrics()`, + which returns at once while `lyr.key` is already this track's — so + the face alone can never ask again, which is exactly why round + thirty-one could not drive this one. The loader, forced. */ + { do: `(function () { __ss.openSheet(); return true; })()`, wait: 900 }, + { do: `document.querySelectorAll('.fbtn')[1].click()`, wait: 900 }, + { do: `(function () { __ssMock.fail('lyrics_get', 'io: failed to lookup address information: no address associated with hostname'); return __ss.loadLyrics(true); })()`, name: "faces-lyrics-down", wait: 1200 }, + { do: `(function () { __ssMock.fail('lyrics_get', ''); return true; })()`, wait: 250 }, + { do: `__ss.loadLyrics(true)`, name: "faces-lyrics-again", wait: 1200 }, + { do: `(function () { document.querySelectorAll('.fbtn')[0].click(); __ss.closeSheet(); return true; })()`, wait: 700 }, + /* a transfer that was refused, written straight into the store the way + the shade steps above do — nothing is fetched, and `failed` is in + DONE, so `xfernote.shape()` sees nothing in flight and posts no + notice. The id is far above the backend's own counter, and `list()` + sorts id-descending, so this row is the first one on the screen. */ + { do: `(function () { __ss.transfers.items[990011] = { id: 990011, username: 'walk_peer', path: 'Walk\\\\walk-c.flac', name: 'walk-c.flac', state: 'failed', queue_place: null, local_path: null, error: 'transfer rejected: File not shared.', received: 0, total: 0 }; __ss.push('transfers', {}); return true; })()`, name: "faces-xfer-failed", wait: 1300 }, + { do: `(function () { delete __ss.transfers.items[990011]; __ss.back(); return true; })()`, wait: 800 }, + /* the one toast action round thirty-one armed that no step had ever + pressed. `lib.error` is written by `loadLibrary` alone — + `watchLibrary`'s own reload swallows its failure — so You → Rescan + library is the only door in the app that reaches it twice. */ + { do: `document.querySelectorAll('.nav button')[4].click()`, wait: 800 }, + { do: `__ssMock.fail('library_albums', 'unable to open database file')`, wait: 250 }, + { do: `__click('.screen .row', 'Rescan library')`, name: "faces-library-toast", wait: 1500 }, + { do: `__ssMock.fail('library_albums', '')`, wait: 300 }, + { do: `__click('.toast:not(.notice) .na', 'Try again')`, name: "faces-library-again", wait: 1400 }, + /* and all of it put away, so the first run below starts exactly where + it would have started without this block at all */ + { do: `(function () { __ssMock.clear(); __ss.player.error = ''; __ss.lib.error = ''; __ss.player.retry = null; __settle(); return __ss.loadHome(true); })()`, wait: 900 }, + { name: "faces-clear", wait: 1200 }, /* ── the first run, on a device that has just been installed ────── The mock is the whole backend here, so it can answer as one: no records, no playlists, no account, no all-files permission, and the @@ -1108,6 +1227,46 @@ const MOCK_EXPECT = { "weather-rescan": (i) => /1A2B-3C4D/.test(i.folderGone) && /isn't there any more/.test(i.toast) && /1A2B-3C4D/.test(i.toast) && !/library-folder-missing/.test(i.toast + i.folderGone), "weather-clear": (i) => !i.toast && !i.why && !i.folderGone && !i.roomsNote && i.slskOn === "online" && /^(Good |Still)/.test(i.title), + /* ── the faces no walk had seen ───────────────────────────────────── + Each of these fails if round thirty-one's fix is undone: stop reading + `slot.error` and Home's line empties; drop the `gapErr` branch and + the Gaps line falls back to "See what's missing" and can be tapped + for ever; put `lyr.failed` back and the face says "No lyrics found" + about a lookup that never answered; take `say()` out of `stateLine` + and the raw string is on the row again. Every one asserts the + sentence a person reads AND that the Rust string is not in it — the + second half is the round-thirty-one rule, and the half that survives + any rewording of the first. */ + "faces-home-down": (i) => i.homeSlots > 0 && i.homeErr === i.homeSlots + && /^Couldn't reach the catalog, so there's nothing new/.test(i.catDown) + && !/New from your artists/.test(i.secs), + "faces-gap-down": (i) => /Couldn't reach the catalogue — tap to try again/.test(i.gapLine) && !i.gapCards, + "faces-gap-again": (i) => /more released/.test(i.gapLine) && !/Couldn't reach/.test(i.gapLine) && i.gapCards >= 3, + "faces-album-down": (i) => /^The catalog did not answer/.test(i.why) && /The network didn't answer/.test(i.why) + && !/io:|connection reset/.test(i.why) && i.again && !i.trks, + "faces-album-again": (i) => !i.why && !i.again && i.trks > 0, + /* the pair that proves the distinction is real and not decoration */ + "faces-artist-down": (i) => /only what this phone holds is here/.test(i.catNote) && /didn't answer/.test(i.stubText) + && !/io:|address information/.test(i.catNote + i.stubText), + "faces-artist-none": (i) => !i.catNote && /had nothing to add/.test(i.stubText) && !/didn't answer/.test(i.stubText), + "faces-playlist-down": (i) => /^Couldn't read this playlist/.test(i.why) && /library folder isn't readable/.test(i.why) + && i.again && !i.plrows && !/database file/.test(i.why), + "faces-playlist-again": (i) => !i.why && !i.again && i.plrows > 0, + "faces-lyrics-down": (i) => i.lyrFailed === "yes" && !i.lyrLines && /^Couldn't look up the words/.test(i.lyrNote) + && i.lyrTools && !/io:|address information/.test(i.lyrNote), + "faces-lyrics-again": (i) => i.lyrFailed === "no" && i.lyrLines >= 3 && !i.lyrNote, + "faces-xfer-failed": (i) => i.title.startsWith("Transfers") + && /failed · They refused the transfer \(File not shared\)/.test(i.xferFail) + && !/transfer rejected/.test(i.xferFail), + "faces-library-toast": (i) => /library folder isn't readable right now/.test(i.toast) && i.retry === "Try again" + && !/database file/.test(i.toast), + "faces-library-again": (i) => i.title.startsWith("You") && !i.toast && !i.retry, + /* the disarm, proved: no toast, no armed action, no stub, Home's slots + healthy and its rails back — so the first run below is not quietly + running on something this block left standing */ + "faces-clear": (i) => !i.toast && !i.why && !i.catDown && !i.retry + && i.homeSlots > 0 && i.homeErr === 0 + && /New from your artists/.test(i.secs) && /^(Good |Still)/.test(i.title), /* sideways: the chrome budget, then every surface that was clipped. The before-value of each is in the rig's now.json — 46% of the height as chrome, a 879px cover with the transport 687px below the fold, a @@ -1604,6 +1763,17 @@ const PHONE_STEPS = [ { do: `document.querySelector('.scrim').click()`, wait: 700 }, { do: `history.back()`, wait: 800 }, { do: `history.back()`, 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 + do — and comes straight back out. Nothing is downloaded, no room is + joined, no message is sent, nobody is signed in, and neither the + config nor the library is written. A `failed` row is not in flight, + so no notice is posted either. The id is far above the backend's own + counter and `list()` sorts id-descending, so it is the first row on + the screen even if Kyle's own session is carrying real failures. */ + { do: `(function () { __ss.transfers.items[990011] = { id: 990011, username: 'walk_peer', path: 'Walk\\\\walk-c.flac', name: 'walk-c.flac', state: 'failed', queue_place: null, local_path: null, error: 'transfer rejected: File not shared.', received: 0, total: 0 }; __ss.push('transfers', {}); return true; })()`, name: "trouble-xfer", wait: 1400 }, + { do: `(function () { delete __ss.transfers.items[990011]; __ss.back(); return true; })()`, name: "trouble-xfer-gone", wait: 1000 }, { do: `document.querySelectorAll('.nav button')[0].click()`, name: "end", wait: 800 }, ]; const PHONE_EXPECT = { @@ -1737,6 +1907,21 @@ const PHONE_EXPECT = { "dg-page": (i) => i.dgTabs >= 1 && i.dgRows >= 1 && !i.toast && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState, "dg-sheet": (i) => i.half.includes("up") && /By year/.test(i.halfText) && !i.toast, "dg-az": (i) => i.dgRows >= 1 && /A–Z/.test(i.dgOrder) && !i.toast, + /* the row's own words on the device, and then its absence. The "gone" + clause is written against THIS row's sentence rather than against + `xferFail` being empty: a real failed transfer of Kyle's sitting in + the list is not a reason to fail the step, and the real core + answering faster than a mock would cannot change either reading — + both are settled state, written and removed by the step itself. The + screen underneath is deliberately not named: sibling rounds append + their own phone steps before `end` too, so whichever screen the back + lands on is theirs, not this round's. What IS asserted is that the + back popped at all — a sheet left up would close instead and the bar + would still say Transfers. */ + "trouble-xfer": (i) => i.title.startsWith("Transfers") + && /failed · They refused the transfer \(File not shared\)/.test(i.xferFail) + && !/transfer rejected/.test(i.xferFail), + "trouble-xfer-gone": (i) => !/^Transfers/.test(i.title) && !/File not shared/.test(i.xferFail), end: (i) => /^(Good |Still)/.test(i.title), };