volume-control asks the player whether there is a volume of ours to
control, and renders nothing when there is not. The decision is in the
control rather than at either mount point because there are two, and
one of them -- the bottom bar's -- lives in index.html, which has no
module scope to make it conditional.
It could not have been a width, and that is the whole design decision.
Every other stand-down rule in this app is keyed on a viewport, because
a width is what a browser can answer and what every tier can test.
This one is a property of the build: keyed on width, an Android tablet
at 600px or more draws the bar's slider over a level the backend has
pinned -- a control that cannot act, on exactly the platform the rule
exists for, which library-status-indicator settled is worse than none.
The same rule is wrong the other way below 600px, where a narrow
desktop window has no hardware keys to fall back on. index.css keeps
its phone rule, which is now about room and says so.
Rendering nothing and hiding the host are both needed and are separate
assertions: an empty shadow root is what stops a by-role or positional
query finding a button that cannot act, and :host([hidden]) is what
stops the element taking a flex item's worth of the transport. The
host rule has to be written down, since :host { display: inline-flex }
outranks the UA's [hidden].
Measured at 424x439 by flipping the constant and rebuilding: the album
art goes 39px to 68px and the transport 172px to 143px -- 29px, being
the 21px control plus the 8px gap a hidden box stops drawing. The
bar's centring is unaffected, since #23's outer columns are the same
min() expression rather than content-sized.
volume-ownership.test.ts is the tier that can exercise the Android
rendering, on an ordinary Linux runner, because the predicate is a
stubbable backend answer. Both of its tests were confirmed to fail on
the build before this.
Closes #64
Closes #172
279 lines
11 KiB
TypeScript
279 lines
11 KiB
TypeScript
import { test, expect, LONG_TRACK } from '../support/fixtures.js';
|
||
|
||
/**
|
||
* The phone shell (plan 016 B2, phase 1).
|
||
*
|
||
* This is the tier that can actually answer the question. Wails v3's
|
||
* server mode serves the real frontend, so a Chromium at 390×844 is the
|
||
* same document an Android WebView renders — the only thing a device
|
||
* adds here is the WebView's own quirks, and CI runs the WebKit half
|
||
* for exactly that reason.
|
||
*
|
||
* The assertions are the three things B2 is *for*: the eleven-item
|
||
* sidebar is gone, the four destinations plan 016 committed to are
|
||
* reachable with a thumb, and nothing scrolls sideways. The last one is
|
||
* the one that hides: `overflow-x: auto` on `body` means a shell that
|
||
* does not fit produces a scrollbar rather than a broken layout, which
|
||
* looks survivable in a screenshot and is not.
|
||
*/
|
||
|
||
/** A common small phone. Narrower than any device this is likely to meet. */
|
||
const PHONE = { width: 390, height: 844 };
|
||
|
||
/** The narrowest thing still sold, near enough. */
|
||
const SMALL_PHONE = { width: 360, height: 780 };
|
||
|
||
const horizontalOverflow = (page: import('@playwright/test').Page) =>
|
||
page.evaluate(() => ({
|
||
scrollWidth: document.body.scrollWidth,
|
||
clientWidth: document.body.clientWidth,
|
||
}));
|
||
|
||
test.describe('the shell on a phone', () => {
|
||
test.beforeEach(async ({ app }) => {
|
||
await app.setViewportSize(PHONE);
|
||
});
|
||
|
||
test('replaces the sidebar with a bottom tab bar', async ({ app }) => {
|
||
await expect(app.locator('div.sidebar')).toBeHidden();
|
||
|
||
const nav = app.locator('bottom-nav');
|
||
|
||
await expect(nav).toBeVisible();
|
||
|
||
// Four tabs and a way to everything else, which is the shape the
|
||
// plan argues for: a tab bar is 3-5 items before the targets stop
|
||
// being thumb-sized.
|
||
for (const id of ['home', 'albums', 'tracks', 'playlists', 'more']) {
|
||
await expect(app.getByTestId(`tab-${id}`)).toBeVisible();
|
||
}
|
||
});
|
||
|
||
test('navigates from a tab', async ({ app }) => {
|
||
await app.getByTestId('tab-albums').click();
|
||
|
||
await expect(app.getByTestId('main-content'))
|
||
.toHaveAttribute('data-active-view', 'albums');
|
||
|
||
await app.getByTestId('tab-home').click();
|
||
|
||
await expect(app.getByTestId('main-content'))
|
||
.toHaveAttribute('data-active-view', 'home');
|
||
});
|
||
|
||
test('reaches the views with no tab through the drawer', async ({ app }) => {
|
||
await app.getByTestId('tab-more').click();
|
||
|
||
// Scoped to the drawer: the desktop sidebar is still in the DOM
|
||
// (hidden by the media query, not removed), so an unscoped testid
|
||
// matches two elements and Playwright's strict mode refuses --
|
||
// which is the right complaint, since the two really are different
|
||
// buttons.
|
||
//
|
||
// The drawer holds the *same* sidebar the desktop uses, so Settings
|
||
// -- which a phone still needs occasionally -- is reachable without
|
||
// a second list of destinations to keep in step.
|
||
const settings = app
|
||
.getByTestId('nav-drawer')
|
||
.getByTestId('nav-settings');
|
||
|
||
await expect(settings).toBeVisible();
|
||
await settings.click();
|
||
|
||
await expect(app.getByTestId('main-content'))
|
||
.toHaveAttribute('data-active-view', 'settings');
|
||
|
||
// And the drawer gets out of the way once it has done its job.
|
||
await expect(app.getByTestId('nav-drawer')).toBeHidden();
|
||
});
|
||
|
||
test('has a named drawer', async ({ app }) => {
|
||
await app.getByTestId('tab-more').click();
|
||
|
||
// The a11y snapshot never prints a dialog's name, so this asks for
|
||
// the role and the name together -- which is the check that caught
|
||
// eleven unnamed dialogs.
|
||
await expect(
|
||
app.getByRole('dialog', { name: 'All views' }),
|
||
).toBeVisible();
|
||
});
|
||
|
||
for (const vp of [PHONE, SMALL_PHONE]) {
|
||
test(`does not scroll sideways at ${vp.width}×${vp.height}`, async ({ app }) => {
|
||
await app.setViewportSize(vp);
|
||
await app.getByTestId('tab-tracks').click();
|
||
await expect(app.getByTestId('main-content'))
|
||
.toHaveAttribute('data-active-view', 'tracks');
|
||
|
||
const { scrollWidth, clientWidth } = await horizontalOverflow(app);
|
||
|
||
expect(scrollWidth, `body overflows by ${scrollWidth - clientWidth}px`)
|
||
.toBeLessThanOrEqual(clientWidth);
|
||
});
|
||
}
|
||
|
||
test('opens the full-screen now playing, and comes back', async ({ app }) => {
|
||
// Something has to be playing for the mini player to be a way in.
|
||
await app.getByTestId('tab-tracks').click();
|
||
await expect(app.getByTestId('main-content'))
|
||
.toHaveAttribute('data-active-view', 'tracks');
|
||
|
||
await app.locator('track-list .track-row').first().dblclick();
|
||
await expect(app.getByTestId('now-playing-title')).not.toBeEmpty();
|
||
|
||
await app.getByTestId('open-now-playing').click();
|
||
|
||
await expect(app.getByTestId('main-content'))
|
||
.toHaveAttribute('data-active-view', 'now-playing');
|
||
|
||
// The seek bar and volume that phase 1 took out of the bottom bar
|
||
// are here, and they are the *same* components -- this view
|
||
// composes the transport rather than reimplementing it.
|
||
await expect(app.locator('now-playing-view seek-bar')).toBeVisible();
|
||
|
||
// Volume is here **because the player says there is one** (#64),
|
||
// not because this is a phone. This tier is the platform that owns
|
||
// its own volume, so what it can assert is that the control's
|
||
// presence follows that answer -- an inverted polarity in
|
||
// `volume-style-store` fails here and in `bottom-bar.spec.ts`, and
|
||
// the *absent* branch is checked in the component tier, where the
|
||
// binding can be stubbed. Nothing here can reach the Android side.
|
||
const systemOwns = await app.evaluate(
|
||
async () =>
|
||
(await window.__yjEvents.call(
|
||
'player.Player.SystemOwnsVolume',
|
||
[],
|
||
5_000,
|
||
)) as boolean,
|
||
);
|
||
|
||
expect(systemOwns, 'this platform should own its own volume').toBe(false);
|
||
|
||
await expect(app.locator('now-playing-view volume-control')).toBeVisible();
|
||
|
||
// Back goes where the user came from, through the nav stack.
|
||
await app.getByTestId('npv-back').click();
|
||
await expect(app.getByTestId('main-content'))
|
||
.toHaveAttribute('data-active-view', 'tracks');
|
||
});
|
||
|
||
/**
|
||
* The same journey with a track that has **no cover art** (#150).
|
||
*
|
||
* The test above starts the *first* row of the track list, so which
|
||
* track it plays is the order the scan inserted them in — and the
|
||
* answer decided whether it passed. A track with artwork renders an
|
||
* `<img>`, which is no obstacle; one without renders a placeholder
|
||
* `wa-icon`, which took every click aimed at the button beneath it,
|
||
* because that button is absolutely positioned with `z-index: auto`
|
||
* and the art is a *later* sibling. They tied, and the later one won.
|
||
*
|
||
* So this picks a track *for* the property that broke it, which is
|
||
* the only way the assertion means anything: the version above passes
|
||
* on a broken build roughly two runs in three, which is exactly how
|
||
* it came to cost three CI cycles across two branches that could not
|
||
* have caused it.
|
||
*/
|
||
test('opens the full-screen now playing for a track with no art', async ({
|
||
app,
|
||
}) => {
|
||
// `LONG_TRACK` by name, and not "the first track with no
|
||
// CoverArt": the *library* model reports that field empty for
|
||
// every row in this fixture (31 of 31), so filtering on it selects
|
||
// nothing in particular and picked a 2-second track, which had
|
||
// finished before the assertions ran. The placeholder check below
|
||
// is what actually holds the property this test needs.
|
||
const started = await app.evaluate(async (longTitle) => {
|
||
const tracks = (await window.__yjEvents.call(
|
||
'library.Library.GetTracks',
|
||
[0],
|
||
10_000,
|
||
)) as { FilePath: string; TrackName: string }[];
|
||
|
||
const bare = tracks.find((t) => t.TrackName === longTitle);
|
||
|
||
if (!bare) return null;
|
||
|
||
await window.__yjEvents.call(
|
||
'queue.Queue.SetQueue',
|
||
[[bare.FilePath], 0, false, { type: '', id: 0, label: '' }],
|
||
10_000,
|
||
);
|
||
await window.__yjEvents.call('queue.Queue.Play', [], 5_000);
|
||
|
||
return bare.TrackName;
|
||
}, LONG_TRACK);
|
||
|
||
expect(started).toBe(LONG_TRACK);
|
||
|
||
await expect(app.getByTestId('now-playing-title')).not.toBeEmpty();
|
||
|
||
// **The placeholder is the whole point**, so it is asserted rather
|
||
// than assumed: this test is about the thing that renders when
|
||
// there is no artwork. If the fixture ever gives this album a
|
||
// cover, this fails and says so instead of passing while measuring
|
||
// the easy case.
|
||
//
|
||
// One selector rather than a chain from the host: Playwright's CSS
|
||
// engine pierces an open shadow root, and chaining from the host
|
||
// element does not reach into it.
|
||
await expect(
|
||
app.locator('now-playing .cover-placeholder'),
|
||
).toBeAttached();
|
||
|
||
await app.getByTestId('open-now-playing').click();
|
||
|
||
await expect(app.getByTestId('main-content'))
|
||
.toHaveAttribute('data-active-view', 'now-playing');
|
||
|
||
await app.getByTestId('npv-back').click();
|
||
});
|
||
|
||
test('offers no way in on a desktop, where the bar is whole', async ({ app }) => {
|
||
await app.setViewportSize({ width: 1440, height: 900 });
|
||
|
||
// The button exists in the markup at every size; CSS decides. If
|
||
// this becomes visible on a desktop it is a 48px hit target over
|
||
// the cover art, swallowing the clicks that open the preview.
|
||
await expect(app.getByTestId('open-now-playing')).toBeHidden();
|
||
});
|
||
|
||
test('keeps the transport, minus what a thumb cannot use', async ({ app }) => {
|
||
// The player bar stays: this is a music player, and what is playing
|
||
// has to be visible and pausable from every view.
|
||
await expect(app.locator('audio-player')).toBeVisible();
|
||
await expect(app.locator('now-playing')).toBeVisible();
|
||
|
||
// Volume is the hardware keys' job on a phone, and a 4px seek bar
|
||
// is not a thumb target -- both belong to the full-screen
|
||
// now-playing view.
|
||
//
|
||
// `.bottom-bar volume-control`, not `audio-player volume-control`:
|
||
// #42 moved the control out of that component and into the bar, and
|
||
// **the old locator would have kept passing** — `toBeHidden()` is
|
||
// satisfied by an element that does not exist, so this assertion
|
||
// would have gone on reporting success about nothing. Its partner
|
||
// below is what makes this one mean something.
|
||
await expect(app.locator('.bottom-bar volume-control')).toBeHidden();
|
||
|
||
// The element is there and hidden, rather than absent: the check
|
||
// above cannot tell those apart on its own.
|
||
await expect(app.locator('.bottom-bar volume-control')).toHaveCount(1);
|
||
|
||
// And the seek bar is still inside the transport, where it stands
|
||
// down by its own media query.
|
||
await expect(
|
||
app.locator('audio-player').locator('seek-bar'),
|
||
).toBeHidden();
|
||
});
|
||
});
|
||
|
||
test.describe('the desktop shell is unchanged', () => {
|
||
test('keeps the sidebar and hides the tab bar', async ({ app }) => {
|
||
await app.setViewportSize({ width: 1440, height: 900 });
|
||
|
||
await expect(app.locator('div.sidebar')).toBeVisible();
|
||
await expect(app.locator('bottom-nav')).toBeHidden();
|
||
});
|
||
});
|