Files
qbz/scripts/test_qt_kiosk_feedback.mjs
T
vicrodh 31da9c57bb fix(qt): kiosk feedback round — chrome, search, album, artists, lyrics
Address the owner's kiosk feedback on top of the release hardening:

- Chrome: the back bar is a drag surface and shrank to 44px with one shared
  button form for Back/Forward/Connect/Cast; Qobuz Connect and Cast are a
  permanent cluster in the bar; the dead Share-logs button left the kiosk
  Settings header.
- Transport bar: the hidden overflow menu is gone; Volume is always visible
  with a vertical sheet, and Desktop mode has its own button.
- Now Playing: one centred transport row (New-bar order); lyrics are centred
  (shared LyricsLinesView gains an opt-in centered, desktop default false).
- Local Library: the media funnel and an Open (ephemeral) entry point reach
  the kiosk; Artists is its own multi-column round-card grid (kiosk-only, the
  desktop LocalArtistsTab is untouched); the Artists drill-down query no
  longer hangs off a stale derived guard.
- Search: the All-tab dashboard shelves render again — they were mounted in a
  Loader Component that read the delegate id under reuse and bound an empty
  model; they are direct children now.
- Album detail: icon actions the height of the quality badge plus play next /
  later / queue, an inline floating quality badge, and zebra rows; both album
  headers show a quality badge.
- Immersive: a quiet auto-hiding kiosk exit moved to the bottom, clear of the
  window controls.

Regression coverage in scripts/test_qt_kiosk_feedback.mjs (wired into the
runtime gate). Verified on a private authenticated Xvfb; QML audits 6/6,
Node gate 5/5, release build 0 warnings.

cc
2026-09-07 12:33:28 -06:00

200 lines
16 KiB
JavaScript

#!/usr/bin/env node
// Regressions for the 2026-09-07 kiosk feedback round:
// - the kiosk's client-side funnel predicates are the DESKTOP's (the two
// ports are run side by side over the same fixtures);
// - the chrome/transport decisions hold statically: the back bar drags,
// Connect + Cast live in the back bar, the transport bar has no hidden
// menu, Volume is always visible, Desktop mode has a button, the Now
// Playing transport is one centred row, and Local Library exposes the
// Open entry point and the funnel.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const qml = (rel) => fs.readFileSync(new URL(`../crates/qbz-qt/qml/${rel}`, import.meta.url), 'utf8');
const desktop = qml('views/LocalLibraryView.qml');
const kioskHost = qml('kiosk/KioskLocalLibrary.qml');
const shell = qml('shell/KioskShell.qml');
const bar = qml('kiosk/KioskNowPlayingBar.qml');
const nowPlaying = qml('kiosk/KioskNowPlaying.qml');
const albumsTab = qml('kiosk/KioskLocalAlbumsTab.qml');
const flyout = qml('shell/QconnectFlyout.qml');
/// The full `function name(...) {...}` text, brace-matched.
function fn(source, name) {
const at = source.indexOf(`function ${name}(`);
assert.ok(at >= 0, `function ${name} in source`);
const start = source.indexOf('{', at);
for (let depth = 1, i = start + 1; i < source.length; i++) {
if (source[i] === '{') depth++;
if (source[i] === '}' && --depth === 0) return source.slice(at, i + 1);
}
throw Error(`unclosed ${name}`);
}
// ---- funnel parity ----------------------------------------------------------
// Cross-context arrays carry foreign prototypes, so results are compared
// through JSON rather than as live objects.
const run = (ctx, code) => JSON.parse(vm.runInContext(`JSON.stringify(${code})`, ctx));
const desktopCtx = vm.createContext({ root: { albumFavorite: (r) => r.isFavorite === true } });
vm.runInContext([fn(desktop, 'sourceBucket'), fn(desktop, 'applyFilter'), fn(desktop, 'artistMatchesFilter')].join('\n'), desktopCtx);
const kioskCtx = vm.createContext({ root: {} });
vm.runInContext([fn(kioskHost, 'sourceBucket'), fn(kioskHost, 'applyFilter'), fn(kioskHost, 'artistMatchesFilter'),
'root.sourceBucket = sourceBucket; root.applyFilter = applyFilter; root.artistMatchesFilter = artistMatchesFilter;'].join('\n'), kioskCtx);
const albums = [
{ id: 'a', isFavorite: true, mediaVariants: [{ qualityTier: 'hires', format: 'flac', source: 'user' }] },
{ id: 'b', isFavorite: false, mediaVariants: [{ qualityTier: 'dsd', format: 'dsf', source: 'qobuz_purchase' }, { qualityTier: 'cd', format: 'flac', source: 'user' }] },
{ id: 'c', isFavorite: false, qualityTier: 'mp3', format: 'mp3', sources: ['navidrome'] },
{ id: 'd', isFavorite: true, qualityTier: 'cd', format: 'alac', source: 'plex' },
{ id: 'e', isFavorite: false, mediaVariants: [{ qualityTier: 'max', format: 'wav', source: '' }] },
];
const artists = [
{ name: 'A', qualityTiers: ['hires', 'cd'], formats: ['flac'], sources: ['user'] },
{ name: 'B', qualityTiers: ['dsd'], formats: ['dsf'], sources: ['qobuz_download'] },
{ name: 'C', qualityTiers: ['mp3'], formats: ['mp3'], sources: ['gonic'] },
{ name: 'D', qualityTiers: [], formats: [], sources: [] },
];
const funnels = [
{}, { hires: true }, { dsd: true }, { flac: true }, { flac: true, dsd: true }, { other: true },
{ plex: true }, { subsonic: true }, { offline: true }, { local: true }, { favorite: true },
{ favorite: true, cd: true }, { lossy: true, mp3: true }, { wav: true, hires: true }, { cd: true, local: true },
];
for (const f of funnels) {
const want = run(desktopCtx, `applyFilter(${JSON.stringify(albums)}, ${JSON.stringify(f)}).map(r => r.id)`);
const got = run(kioskCtx, `root.applyFilter(${JSON.stringify(albums)}, ${JSON.stringify(f)}).map(r => r.id)`);
assert.deepEqual(got, want, `applyFilter parity for ${JSON.stringify(f)}`);
const wantArtists = run(desktopCtx, `${JSON.stringify(artists)}.filter(a => artistMatchesFilter(a, ${JSON.stringify(f)})).map(a => a.name)`);
const gotArtists = run(kioskCtx, `${JSON.stringify(artists)}.filter(a => root.artistMatchesFilter(a, ${JSON.stringify(f)})).map(a => a.name)`);
assert.deepEqual(gotArtists, wantArtists, `artistMatchesFilter parity for ${JSON.stringify(f)}`);
}
// The fixtures actually discriminate: not every funnel keeps every row.
assert.deepEqual(run(kioskCtx, `root.applyFilter(${JSON.stringify(albums)}, {"hires":true}).map(r => r.id)`), ['a', 'e']);
assert.deepEqual(run(kioskCtx, `root.applyFilter(${JSON.stringify(albums)}, {"favorite":true,"cd":true}).map(r => r.id)`), ['d']);
assert.deepEqual(run(kioskCtx, `root.applyFilter(${JSON.stringify(albums)}, {"subsonic":true}).map(r => r.id)`), ['c']);
// ---- the funnel is wired, not just computed --------------------------------
assert.match(kioskHost, /property var albumsFilter: root\.parseFilter\(QbzLocal\.albumsFilter\)/, 'seeded from the bridge');
assert.match(kioskHost, /QbzLocal\.setAlbumsFilterJson\(/, 'persists through the desktop writer');
assert.match(kioskHost, /QbzLocal\.tracksSetFilterJson\(JSON\.stringify\(root\.commonFilter\)\)/, 'Tracks installs the shared funnel');
assert.match(kioskHost, /function onAlbumsFilterChanged\(\)/, 'a later republish wins');
assert.match(albumsTab, /albumsNativeReset\("", "artist-asc", "off",\s*root\.view \? root\.view\.filterJson : "\{\}"/, 'native Albums descriptor carries the funnel');
assert.match(albumsTab, /function onFilterJsonChanged\(\)/, 'a chip toggle re-queries native Albums');
assert.match(albumsTab, /!\(root\.view && root\.view\.favoriteOnly\)/, 'Favorites only forces the legacy Albums reader');
assert.match(kioskHost, /root\.activeTab === "albums" && root\.favoriteOnly\)\n\s+QbzLocal\.loadTab\("albums-legacy"\)/, 'host loads the legacy document under Favorites only');
for (const key of ['dsd', 'hires', 'cd', 'lossy', 'flac', 'alac', 'ape', 'wav', 'mp3', 'aac', 'other', 'local', 'offline', 'plex', 'jellyfin', 'subsonic', 'favorite'])
assert.match(kioskHost, new RegExp(`toggleFilter\\("${key}"\\)`), `chip for ${key}`);
assert.match(kioskHost, /onClicked: root\.clearFilter\(\)/, 'Clear');
// ---- Open (ephemeral) entry point ------------------------------------------
for (const call of ['QbzLocal.ephemeralOpen()', 'QbzLocal.ephemeralOpenCd()', 'QbzLocal.ephemeralOpenSacd()'])
assert.ok(kioskHost.includes(call), `${call} reachable from the kiosk`);
assert.match(kioskHost, /"Open folder…"[\s\S]*"Open audio CD"[\s\S]*"Open SACD image…"/, 'the desktop Open menu rows, verbatim');
// ---- chrome: drag + session cluster ----------------------------------------
assert.match(shell, /enabled: !QbzShell\.systemTitleBar[\s\S]*root\.hostWindow\.startSystemMove\(\)/, 'the back bar is the drag surface');
assert.match(shell, /connectFlyout\.openBelowRight\(kioskQconnectBtn\)/, 'Connect opens from the back bar');
assert.match(shell, /name: "cast"[\s\S]{0,400}onClicked: QbzCast\.openPicker\(\)/, 'Cast opens from the back bar');
assert.match(shell, /QconnectFlyout \{ id: connectFlyout \}[\s\S]*CastPicker \{ \}/, 'flyout + picker are shell-level');
assert.match(flyout, /function openBelowRight\(sourceItem\)/, 'top-bar placement exists');
assert.ok(shell.indexOf('id: sessionCluster') < shell.indexOf('id: windowControls'), 'cluster is declared before the window controls');
assert.match(shell, /- sessionCluster\.width - 6/, 'the search row yields the cluster width');
// ---- transport bar: no hidden menu, Volume + Desktop mode always visible ---
assert.ok(!bar.includes('moreMenu') && !bar.includes('"ellipsis"'), 'the hidden more-menu is gone');
assert.ok(!bar.includes('QconnectFlyout') && !bar.includes('CastPicker'), 'Connect/Cast no longer live in the bar');
const barOrder = ['"skip-back"', '"pause" : "play-fill"', '"skip-forward"', 'id: volumeButton', '"list-music"', '"monitor"'];
let cursor = bar.indexOf('id: transport');
for (const marker of barOrder) {
const at = bar.indexOf(marker, cursor);
assert.ok(at > cursor, `${marker} in transport order`);
cursor = at;
}
assert.match(bar, /name: "monitor"; label: QbzSession\.tr\("Desktop mode"[\s\S]{0,120}onClicked: QbzSession\.toggleProfile\(\)/, 'Desktop mode button');
assert.match(bar, /rotation: -90/, 'the volume slider opens vertically');
assert.match(bar, /volumePopup\.y = Math\.max\(8, g\.y - volumePopup\.height - 8\)/, 'the sheet is clamped inside the window');
assert.match(bar, /sliderLength: Math\.max\(120, Math\.min\(260,/, 'the sheet shortens on a short window');
const volumeButtonAt = bar.indexOf('id: volumeButton');
assert.ok(!/visible: root\.width >= \d+/.test(bar.slice(volumeButtonAt, volumeButtonAt + 200)), 'Volume has no width gate');
// ---- Now Playing transport: one centred row, every button on the axis -----
const transportAt = nowPlaying.indexOf('id: transport\n');
const transportBlock = nowPlaying.slice(transportAt, nowPlaying.indexOf('AudioStamp', transportAt));
assert.ok(nowPlaying.slice(transportAt - 40, transportAt).includes('Row {'), 'transport is a Row, not a Flow');
const buttons = transportBlock.match(/QbzIconButton \{[^\n]*\}/g);
assert.equal(buttons.length, 8, 'eight transport buttons');
for (const b of buttons) assert.match(b, /anchors\.verticalCenter: parent\.verticalCenter/, `centred: ${b.slice(0, 60)}`);
const order = buttons.map(b => b.match(/name: ([^;]+);/)[1]);
assert.deepEqual(order.slice(2, 5).map(n => n.includes('play-fill') ? 'play' : n), ['"skip-back"', 'play', '"skip-forward"'], 'transport in the middle');
assert.equal(order[0], '"info"'); assert.equal(order[1], '"shuffle"');
assert.match(transportBlock, /x: Math\.max\(4, Math\.min\(\(transportRow\.width - width\) \/ 2,/, 'the cluster is centred and yields to the stamp');
// ---- Artists drill-down: the query must not hang off a stale derived guard --
// A root `onSelectedArtistChanged` handler reads the DERIVED `drilled` before it
// is re-evaluated (false), so `if (root.drilled) detailQuery.restart()` never
// ran and every drill-down showed "0 albums". The trigger is a Connections on
// the host, and its guard reads the source.
const artistsTab = qml('kiosk/KioskLocalArtistsTab.qml');
assert.ok(!/^\s*onSelectedArtistChanged:/m.test(artistsTab), 'no root onSelectedArtistChanged handler');
assert.match(artistsTab, /Connections \{\s*target: root\.view\s*function onSelectedArtistChanged\(\) \{\s*if \(root\.view\.selectedArtist !== ""\)\s*detailQuery\.restart\(\)/, 'detail query re-issued from the host signal, guarded on the source');
assert.ok(!/detailQuery[\s\S]{0,400}if \(!root\.nativeActive/.test(artistsTab), 'the detail query is not gated on nativeActive');
// ---- round 2 (2026-09-07): quality badges, quiet immersive exit, 44px bar, no dead button
const localHeader = qml('views/local/LocalAlbumHeader.qml');
const kioskAlbum = qml('kiosk/KioskAlbum.qml');
const immersive = qml('immersive/ImmersiveView.qml');
const settings = qml('settings/SettingsView.qml');
assert.match(localHeader, /visible: root\.kioskHost && kioskQuality\.tier !== ""[\s\S]{0,600}QualityBadgeFull \{/, 'kiosk-only album quality badge in the local header');
assert.match(kioskAlbum, /QualityBadgeFull \{[\s\S]{0,400}detail: root\.header\.qualityDetail/, 'Qobuz kiosk album header carries a quality badge');
assert.ok(!/text: root\.header\.qualityDetail \|\| ""; color: theme\.textMuted/.test(kioskAlbum), 'the muted quality line is gone');
const exitAt = immersive.indexOf('visible: root.preKiosk');
const exitBlock = immersive.slice(exitAt, exitAt + 900);
assert.match(exitBlock, /opacity: root\.chromeVisible \? 1 : 0/, 'kiosk exit follows the auto-hiding chrome');
assert.match(exitBlock, /enabled: root\.chromeVisible/, 'kiosk exit is hit-enabled only while shown');
assert.ok(!/border\.width: 2/.test(exitBlock) && /width: 52/.test(exitBlock), 'kiosk exit is the quiet 52px form');
assert.match(immersive, /onPressed: function \(mouse\) \{ mouse\.accepted = true; root\.wake\(\) \}/, 'a backdrop press wakes the chrome (touch)');
assert.match(shell, /id: backBar[\s\S]{0,400}height: 44/, 'the back bar is 44px');
for (const id of ['backBtn', 'fwdBtn']) assert.match(shell, new RegExp(`ChromeButton \\{\\s*id: ${id}`), `${id} uses the shared chrome form`);
assert.match(shell, /ChromeButton \{\s*name: "cast"/, 'Cast uses the shared chrome form');
assert.match(shell, /id: kioskQconnectBtn[\s\S]{0,200}width: 44\s*height: 36/, 'Connect matches the 44x36 form');
const kioskHeader = settings.slice(settings.indexOf('// --- Header'), settings.indexOf('// --- Sub-nav'));
assert.ok(!kioskHeader.includes('id: logsButton') && !/onClicked: QbzShell\.logOpen\(\)/.test(kioskHeader), 'the dead Share-logs button is gone from the kiosk settings header');
// ---- round 3 (2026-09-07): search dashboard shelves, album actions, immersive exit ----
const search = qml('kiosk/KioskSearch.qml');
// The dashboard preview Components are instantiated by a Loader; reading the
// outer delegate's `section.modelData.rows` directly threw and emptied every
// shelf. Guarded stable props must be used instead.
assert.match(search, /readonly property var sRows: section\.modelData && section\.modelData\.rows/, 'guarded sRows');
assert.match(search, /readonly property string sKind: section\.modelData && section\.modelData\.kind/, 'guarded sKind');
assert.match(search, /rows: section\.sKind !== "track" \? section\.sRows : \[\]/, 'shelf is a direct child bound to guarded rows');
assert.ok(!/sourceComponent: section\.sKind === "track"/.test(search), 'the dashboard preview no longer hides behind a Loader Component');
assert.ok(!/model: section\.modelData\.rows/.test(search), 'no direct modelData.rows read in a preview list');
assert.ok(!/rows: section\.modelData\.rows/.test(search), 'no direct modelData.rows read in the shelf');
const albumQ = qml('kiosk/KioskAlbum.qml');
for (const ic of ['play-fill', 'shuffle', 'list-start', 'list-plus', 'list-end'])
assert.match(albumQ, new RegExp(`QbzIconButton \\{ name: "${ic}"`), `album action icon ${ic}`);
assert.ok(!/model: \["Play", "Shuffle"\]/.test(albumQ), 'the text Play/Shuffle buttons are gone');
assert.match(albumQ, /QbzPlayer\.enqueueAlbum\(root\.header\.id \|\| "", "next"\)/, 'play next enqueues the album');
assert.match(albumQ, /QbzPlayer\.enqueueAlbum\(root\.header\.id \|\| "", "queue"\)/, 'add to queue enqueues the album');
assert.match(albumQ, /QualityBadgeFull \{[\s\S]{0,200}anchors\.right: parent\.right[\s\S]{0,120}anchors\.verticalCenter/, 'quality badge floats inline at the right');
assert.match(albumQ, /index % 2 === 1 \? theme\.surfaceHover/, 'album track rows are zebra-striped');
const immersive2 = qml('immersive/ImmersiveView.qml');
const exitAt2 = immersive2.indexOf('visible: root.preKiosk');
const exitBlock2 = immersive2.slice(exitAt2, exitAt2 + 700);
assert.match(exitBlock2, /anchors\.bottom: parent\.bottom/, 'immersive kiosk exit is anchored to the bottom');
assert.ok(!/anchors\.top: parent\.top/.test(exitBlock2), 'immersive kiosk exit is no longer at the top');
// ---- round 4 (2026-09-07): lyrics centered, artists rail as round-card grid ----
const llv = qml('shell/LyricsLinesView.qml');
assert.match(llv, /property bool centered: false/, 'LyricsLinesView exposes an opt-in centered');
assert.match(llv, /centered: view\.centered/, 'LyricsLinesView threads centered into the row');
const knp = qml('kiosk/KioskNowPlaying.qml');
assert.match(knp, /LyricsLinesView \{[\s\S]{0,400}centered: true/, 'kiosk Now Playing lyrics are centered');
const artistsTab2 = qml('kiosk/KioskLocalArtistsTab.qml');
assert.match(artistsTab2, /GridView \{\s*id: rail/, 'the artists rail is a GridView (multi-column), not a single-column ListView');
assert.match(artistsTab2, /KioskCard \{[\s\S]{0,120}round: true/, 'artists render as round cards');
assert.match(artistsTab2, /positionViewAtIndex\(root\.focusedItem, GridView\.Contain\)/, 'grid focus scroll uses GridView.Contain');
console.log('Kiosk feedback round: funnel parity with the desktop, Open entry point, back-bar drag/Connect/Cast, transport bar and Now Playing transport pass');