Merge remote-tracking branch 'origin/main' into wails-v3
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* The phone's primary navigation (plan 016 B2).
|
||||
*
|
||||
* Three of these are about the thing that makes a second nav dangerous:
|
||||
* it has to agree with the first one. `bottom-nav` emits the same
|
||||
* bubbling, composed `navigate` event `app-sidebar` does and listens
|
||||
* for that event globally, so a navigation from anywhere — a card, a
|
||||
* detail view, the drawer's own sidebar — moves its highlight too. A
|
||||
* tab bar that only tracks its own clicks looks right until the moment
|
||||
* the user arrives somewhere by another route.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
|
||||
import '@components/bottom-nav/bottom-nav';
|
||||
import type { BottomNav } from '@components/bottom-nav/bottom-nav';
|
||||
import { fixture, shadow, shadowAll, update } from '@test/support/render';
|
||||
import { resetHarness } from '@test/support/harness';
|
||||
|
||||
type Nav = BottomNav;
|
||||
|
||||
const tabs = (el: HTMLElement) =>
|
||||
shadowAll<HTMLButtonElement>(el, 'nav button');
|
||||
|
||||
/** Resolve on one occurrence of an event, or reject loudly on time. */
|
||||
const once = (el: Element, name: string, timeoutMs = 2000) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error(`${name} never fired`)),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
el.addEventListener(name, () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}, { once: true });
|
||||
});
|
||||
|
||||
describe('bottom-nav', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
});
|
||||
|
||||
it('offers the four phone destinations and a way to the rest', async () => {
|
||||
const el = await fixture<Nav>('bottom-nav');
|
||||
|
||||
expect(tabs(el).map((b) => b.dataset.testid)).toEqual([
|
||||
'tab-home',
|
||||
'tab-albums',
|
||||
'tab-tracks',
|
||||
'tab-playlists',
|
||||
'tab-more',
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits a navigate event that escapes its shadow root', async () => {
|
||||
const el = await fixture<Nav>('bottom-nav');
|
||||
const seen: string[] = [];
|
||||
|
||||
document.addEventListener('navigate', (e) => {
|
||||
seen.push((e as CustomEvent<{ view: string }>).detail.view);
|
||||
});
|
||||
|
||||
shadow<HTMLButtonElement>(el, '[data-testid="tab-albums"]')?.click();
|
||||
|
||||
// Composed and bubbling, or index.ts's document-level listener --
|
||||
// the only thing that actually changes the view -- never hears it.
|
||||
expect(seen).toEqual(['albums']);
|
||||
});
|
||||
|
||||
it('follows a navigation it did not send', async () => {
|
||||
const el = await fixture<Nav>('bottom-nav');
|
||||
|
||||
document.dispatchEvent(new CustomEvent('navigate', {
|
||||
detail: { view: 'tracks' },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
await update(el, {});
|
||||
|
||||
const current = tabs(el)
|
||||
.filter((b) => b.getAttribute('aria-current') === 'page')
|
||||
.map((b) => b.dataset.testid);
|
||||
|
||||
expect(current).toEqual(['tab-tracks']);
|
||||
});
|
||||
|
||||
it('marks exactly one tab current, and none for a view it has no tab for', async () => {
|
||||
const el = await fixture<Nav>('bottom-nav');
|
||||
|
||||
document.dispatchEvent(new CustomEvent('navigate', {
|
||||
detail: { view: 'settings' },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
await update(el, {});
|
||||
|
||||
// Settings lives in the drawer, so nothing in the bar is current.
|
||||
// Leaving Home highlighted would be a tab bar lying about where
|
||||
// the user is.
|
||||
expect(
|
||||
tabs(el).filter((b) => b.getAttribute('aria-current') === 'page'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('closes the drawer when a navigation happens', async () => {
|
||||
const el = await fixture<Nav>('bottom-nav');
|
||||
const drawer = shadow<HTMLElement & { open: boolean }>(el, 'wa-drawer');
|
||||
|
||||
if (!drawer) throw new Error('no drawer');
|
||||
|
||||
// The drawer animates, so the assertion is its own event rather
|
||||
// than the `open` property: setting `open = false` starts a hide
|
||||
// that has not finished on the next microtask, and a test that
|
||||
// reads the property in between sees the state it is leaving.
|
||||
const shown = once(drawer, 'wa-after-show');
|
||||
|
||||
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
|
||||
await shown;
|
||||
|
||||
const hidden = once(drawer, 'wa-after-hide');
|
||||
|
||||
document.dispatchEvent(new CustomEvent('navigate', {
|
||||
detail: { view: 'settings' },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
|
||||
await hidden;
|
||||
expect(drawer.open).toBe(false);
|
||||
});
|
||||
|
||||
it('gives every tab a name and a target big enough to hit', async () => {
|
||||
const el = await fixture<Nav>('bottom-nav');
|
||||
|
||||
for (const button of tabs(el)) {
|
||||
expect(button.textContent?.trim()).not.toBe('');
|
||||
// 48px is the floor for a touch target; the bar is the one
|
||||
// surface in this app that has no pointer to fall back on.
|
||||
expect(button.getBoundingClientRect().height).toBeGreaterThanOrEqual(48);
|
||||
}
|
||||
});
|
||||
|
||||
it('holds no second sidebar until the drawer is asked for', async () => {
|
||||
const el = await fixture<Nav>('bottom-nav');
|
||||
|
||||
// `app-sidebar` carries a data-testid per destination, so a spare
|
||||
// copy standing by makes every `nav-*` testid ambiguous for the
|
||||
// *whole app*: rendering it unconditionally failed 30 existing
|
||||
// specs with "strict mode violation: resolved to 2 elements", on a
|
||||
// desktop viewport where this element is not even visible.
|
||||
expect(shadow(el, 'app-sidebar')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the drawer sidebar expanded, where there is room for labels', async () => {
|
||||
const el = await fixture<Nav>('bottom-nav');
|
||||
|
||||
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
|
||||
await update(el, {});
|
||||
|
||||
// Without this the sidebar's own auto-collapse (a response to a
|
||||
// narrow *shell*) would render icons in a full-width drawer.
|
||||
expect(shadow<HTMLElement>(el, 'app-sidebar')?.hasAttribute('expanded'))
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { wails } from '../support/wails-fake';
|
||||
|
||||
import type { FolderPicker } from '@components/folder-picker/folder-picker';
|
||||
|
||||
const listings: Record<string, unknown> = {
|
||||
'/storage/emulated/0': {
|
||||
path: '/storage/emulated/0',
|
||||
parent: '/storage/emulated',
|
||||
entries: [
|
||||
{ name: 'Music', path: '/storage/emulated/0/Music' },
|
||||
{ name: 'Podcasts', path: '/storage/emulated/0/Podcasts' },
|
||||
],
|
||||
},
|
||||
'/storage/emulated/0/Music': {
|
||||
path: '/storage/emulated/0/Music',
|
||||
parent: '/storage/emulated/0',
|
||||
entries: [],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `pickDirectory` imports the picker's chunk before it can create the
|
||||
* element, so the element does not exist on the turn the call is made.
|
||||
* That is deliberate -- mounting a dialog and calling showModal() in
|
||||
* one update is the trap `index.ts` documents -- so the test waits for
|
||||
* it rather than assuming it is synchronous.
|
||||
*/
|
||||
async function host(): Promise<FolderPicker> {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const el = document.querySelector<FolderPicker>('folder-picker');
|
||||
|
||||
if (el) {
|
||||
await el.updateComplete;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
|
||||
throw new Error('folder-picker did not mount itself');
|
||||
}
|
||||
|
||||
function click(el: FolderPicker, testid: string): void {
|
||||
el.shadowRoot
|
||||
?.querySelector<HTMLButtonElement>(`[data-testid="${testid}"]`)
|
||||
?.click();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', true);
|
||||
wails.stub('frontendutil.FrontendUtil.DirectoryPicker', '/home/logan/Music');
|
||||
wails.stub('frontendutil.FrontendUtil.DefaultBrowseRoot', '/storage/emulated/0');
|
||||
wails.stub('frontendutil.FrontendUtil.ListDirectories', (path: string) => {
|
||||
const listing = listings[path || '/storage/emulated/0'];
|
||||
|
||||
if (!listing) throw new Error('permission denied');
|
||||
|
||||
return listing;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelector('folder-picker')?.remove();
|
||||
wails.reset();
|
||||
});
|
||||
|
||||
describe('pickDirectory', () => {
|
||||
it('uses the platform dialog off Android', async () => {
|
||||
const { pickDirectory, resetDirectoryPickerCache } = await import(
|
||||
'@utils/pick-directory'
|
||||
);
|
||||
|
||||
resetDirectoryPickerCache();
|
||||
|
||||
await expect(pickDirectory()).resolves.toBe('/home/logan/Music');
|
||||
expect(document.querySelector('folder-picker')).toBeNull();
|
||||
});
|
||||
|
||||
it('normalises the desktop dialog\u2019s empty string to null', async () => {
|
||||
wails.stub('frontendutil.FrontendUtil.DirectoryPicker', '');
|
||||
|
||||
const { pickDirectory, resetDirectoryPickerCache } = await import(
|
||||
'@utils/pick-directory'
|
||||
);
|
||||
|
||||
resetDirectoryPickerCache();
|
||||
|
||||
await expect(pickDirectory()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('browses in-app on Android, and never opens the platform dialog', async () => {
|
||||
wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', false);
|
||||
|
||||
const { pickDirectory, resetDirectoryPickerCache } = await import(
|
||||
'@utils/pick-directory'
|
||||
);
|
||||
|
||||
resetDirectoryPickerCache();
|
||||
const answer = pickDirectory();
|
||||
|
||||
const el = await host();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await el.updateComplete;
|
||||
|
||||
click(el, 'folder-picker-select');
|
||||
|
||||
await expect(answer).resolves.toBe('/storage/emulated/0');
|
||||
});
|
||||
|
||||
it('resolves null when the browser is cancelled', async () => {
|
||||
wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', false);
|
||||
|
||||
const { pickDirectory, resetDirectoryPickerCache } = await import(
|
||||
'@utils/pick-directory'
|
||||
);
|
||||
|
||||
resetDirectoryPickerCache();
|
||||
const answer = pickDirectory();
|
||||
|
||||
const el = await host();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await el.updateComplete;
|
||||
|
||||
el.shadowRoot
|
||||
?.querySelectorAll<HTMLButtonElement>('.actions button')[0]
|
||||
?.click();
|
||||
|
||||
await expect(answer).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('descends into a folder and returns the one it is showing', async () => {
|
||||
wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', false);
|
||||
|
||||
const { pickDirectory, resetDirectoryPickerCache } = await import(
|
||||
'@utils/pick-directory'
|
||||
);
|
||||
|
||||
resetDirectoryPickerCache();
|
||||
const answer = pickDirectory();
|
||||
|
||||
const el = await host();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await el.updateComplete;
|
||||
|
||||
const music = [
|
||||
...(el.shadowRoot?.querySelectorAll<HTMLButtonElement>(
|
||||
'[data-testid="folder-picker-list"] button',
|
||||
) ?? []),
|
||||
].find((b) => b.textContent?.includes('Music'));
|
||||
|
||||
music?.click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await el.updateComplete;
|
||||
|
||||
click(el, 'folder-picker-select');
|
||||
|
||||
await expect(answer).resolves.toBe('/storage/emulated/0/Music');
|
||||
});
|
||||
|
||||
/**
|
||||
* A directory that cannot be read is not a failed picker. Android's
|
||||
* storage root holds directories no app may enter, and stranding the
|
||||
* user in an empty dialog with no way back is worse than saying so.
|
||||
*/
|
||||
it('stays put and explains when a folder cannot be opened', async () => {
|
||||
wails.stub('frontendutil.FrontendUtil.HasNativeDirectoryPicker', false);
|
||||
|
||||
const { pickDirectory, resetDirectoryPickerCache } = await import(
|
||||
'@utils/pick-directory'
|
||||
);
|
||||
|
||||
resetDirectoryPickerCache();
|
||||
const answer = pickDirectory();
|
||||
|
||||
const el = await host();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await el.updateComplete;
|
||||
|
||||
// 'Podcasts' has no listing, so ListDirectories rejects.
|
||||
const bad = [
|
||||
...(el.shadowRoot?.querySelectorAll<HTMLButtonElement>(
|
||||
'[data-testid="folder-picker-list"] button',
|
||||
) ?? []),
|
||||
].find((b) => b.textContent?.includes('Podcasts'));
|
||||
|
||||
bad?.click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.shadowRoot?.querySelector('[role="alert"]')).toBeTruthy();
|
||||
expect(
|
||||
el.shadowRoot?.querySelector('[data-testid="folder-picker-path"]')
|
||||
?.textContent,
|
||||
).toContain('/storage/emulated/0');
|
||||
|
||||
click(el, 'folder-picker-select');
|
||||
await expect(answer).resolves.toBe('/storage/emulated/0');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Long-press as the touch route to a context menu (plan 016 B2 phase 3).
|
||||
*
|
||||
* These run in a real browser with real event dispatch, which is the
|
||||
* only place the two things that make this hard are true: the synthetic
|
||||
* event has to cross a shadow boundary to reach the listener a
|
||||
* component actually bound, and the suppressors have to tell a trusted
|
||||
* event from ours at document capture without eating the one they exist
|
||||
* to deliver.
|
||||
*
|
||||
* The timings are real rather than faked, because the thing under test
|
||||
* *is* a timing, and 600 ms twice is cheaper than a fake-timer harness
|
||||
* that would also have to fake the pointer events.
|
||||
*/
|
||||
import { describe, expect, it, afterEach, beforeEach } from 'vitest';
|
||||
|
||||
import {
|
||||
installLongPressContextMenu,
|
||||
LONG_PRESS_MS,
|
||||
MOVE_TOLERANCE_PX,
|
||||
} from '@utils/long-press';
|
||||
|
||||
/** A press that has certainly resolved, either way. */
|
||||
const HELD = LONG_PRESS_MS + 120;
|
||||
|
||||
/** A press that has certainly not. */
|
||||
const BRIEF = Math.round(LONG_PRESS_MS / 4);
|
||||
|
||||
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
let uninstall: (() => void) | null = null;
|
||||
let host: HTMLElement;
|
||||
let inner: HTMLElement;
|
||||
|
||||
/** A row inside a shadow root, which is where every menu in this app
|
||||
* is bound — an element in the light DOM would pass a weaker test. */
|
||||
function mountRow(): { host: HTMLElement; inner: HTMLElement } {
|
||||
const el = document.createElement('div');
|
||||
const root = el.attachShadow({ mode: 'open' });
|
||||
const row = document.createElement('div');
|
||||
|
||||
row.textContent = 'a track';
|
||||
root.append(row);
|
||||
document.body.append(el);
|
||||
|
||||
return { host: el, inner: row };
|
||||
}
|
||||
|
||||
function press(
|
||||
el: EventTarget,
|
||||
type: string,
|
||||
init: PointerEventInit = {},
|
||||
): void {
|
||||
el.dispatchEvent(
|
||||
new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
cancelable: true,
|
||||
pointerType: 'touch',
|
||||
isPrimary: true,
|
||||
clientX: 40,
|
||||
clientY: 60,
|
||||
...init,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record every `contextmenu` that reaches the listener, *as the
|
||||
* listener sees it*.
|
||||
*
|
||||
* `target` is retargeted for the scope reading it, so an assertion made
|
||||
* after dispatch has finished reports the shadow host however the event
|
||||
* was dispatched - which is the same answer a broken implementation
|
||||
* gives. It has to be read from inside the handler, where the component
|
||||
* reads it.
|
||||
*/
|
||||
function recordMenus(el: EventTarget): { event: MouseEvent; target: EventTarget | null }[] {
|
||||
const seen: { event: MouseEvent; target: EventTarget | null }[] = [];
|
||||
|
||||
el.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
// Every real handler does this; the gesture must work anyway.
|
||||
e.stopPropagation();
|
||||
seen.push({ event: e as MouseEvent, target: e.target });
|
||||
});
|
||||
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe('long-press opens a context menu', () => {
|
||||
beforeEach(() => {
|
||||
uninstall = installLongPressContextMenu();
|
||||
({ host, inner } = mountRow());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
uninstall?.();
|
||||
uninstall = null;
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it('dispatches one at the touch point, on the element touched', async () => {
|
||||
const seen = recordMenus(inner);
|
||||
|
||||
press(inner, 'pointerdown');
|
||||
await wait(HELD);
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]?.event.clientX).toBe(40);
|
||||
expect(seen[0]?.event.clientY).toBe(60);
|
||||
// Dispatched on the row itself, not on its shadow host - which is
|
||||
// the difference between a per-row handler firing and only a
|
||||
// delegated one firing.
|
||||
expect(seen[0]?.target).toBe(inner);
|
||||
});
|
||||
|
||||
it('is cancelled by a press that moves', async () => {
|
||||
const seen = recordMenus(inner);
|
||||
|
||||
press(inner, 'pointerdown');
|
||||
press(inner, 'pointermove', {
|
||||
clientX: 40 + MOVE_TOLERANCE_PX + 5,
|
||||
clientY: 60,
|
||||
});
|
||||
await wait(HELD);
|
||||
|
||||
expect(seen).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('tolerates the jitter a finger cannot help', async () => {
|
||||
const seen = recordMenus(inner);
|
||||
|
||||
press(inner, 'pointerdown');
|
||||
press(inner, 'pointermove', { clientX: 43, clientY: 62 });
|
||||
await wait(HELD);
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('is cancelled by lifting early, and by a scroll', async () => {
|
||||
const seen = recordMenus(inner);
|
||||
|
||||
press(inner, 'pointerdown');
|
||||
await wait(BRIEF);
|
||||
press(inner, 'pointerup');
|
||||
await wait(HELD);
|
||||
|
||||
expect(seen).toHaveLength(0);
|
||||
|
||||
press(inner, 'pointerdown');
|
||||
press(inner, 'pointercancel');
|
||||
await wait(HELD);
|
||||
|
||||
expect(seen).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores a mouse, which has a right button of its own', async () => {
|
||||
const seen = recordMenus(inner);
|
||||
|
||||
press(inner, 'pointerdown', { pointerType: 'mouse' });
|
||||
await wait(HELD);
|
||||
|
||||
expect(seen).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('swallows the click that ends the gesture, and only that one', async () => {
|
||||
let clicks = 0;
|
||||
|
||||
inner.addEventListener('click', () => {
|
||||
clicks += 1;
|
||||
});
|
||||
|
||||
press(inner, 'pointerdown');
|
||||
await wait(HELD);
|
||||
press(inner, 'pointerup');
|
||||
inner.click();
|
||||
|
||||
expect(clicks).toBe(0);
|
||||
|
||||
// The next tap is a tap: on a phone that is the user choosing an
|
||||
// item in the menu that just opened, so eating it would make the
|
||||
// gesture useless.
|
||||
press(inner, 'pointerdown');
|
||||
press(inner, 'pointerup');
|
||||
inner.click();
|
||||
|
||||
expect(clicks).toBe(1);
|
||||
});
|
||||
|
||||
it('stands down where the browser fires its own', async () => {
|
||||
const seen = recordMenus(inner);
|
||||
|
||||
press(inner, 'pointerdown');
|
||||
await wait(BRIEF);
|
||||
// Chromium does this itself on touch; WebKit and the Android
|
||||
// WebView vary, which is the whole reason both halves exist. A
|
||||
// test cannot dispatch a *trusted* event, which is why the module
|
||||
// tells its own apart by identity rather than by `isTrusted`.
|
||||
inner.dispatchEvent(
|
||||
new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
await wait(HELD);
|
||||
|
||||
// One menu: the browser's. Not two.
|
||||
expect(seen).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* The full-screen now-playing view (plan 016 B2, phase 2).
|
||||
*
|
||||
* What is worth pinning here is not the layout but the *composition*:
|
||||
* it renders the same `<seek-bar>`, `<player-controls>` and
|
||||
* `<volume-control>` the desktop transport does, rather than its own.
|
||||
* A phone layout that reimplements the transport is a second transport
|
||||
* to fix every bug in — and the seek bar in particular carries
|
||||
* interpolation rules that took a plan of their own to get right.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
|
||||
import '@components/now-playing-view/now-playing-view';
|
||||
import { Events } from '../../src/events';
|
||||
import { emit, resetHarness, stub } from '@test/support/harness';
|
||||
import { fixture, shadow, text } from '@test/support/render';
|
||||
import type { TrackInfo } from '@store/player-store';
|
||||
|
||||
const TRACK: TrackInfo = {
|
||||
fileName: 'tideline.mp3',
|
||||
filePath: '/music/tideline.mp3',
|
||||
trackLength: 245,
|
||||
seekPosition: 0,
|
||||
state: 'playing',
|
||||
title: 'Tideline',
|
||||
artist: 'Sea Change',
|
||||
album: 'Ebb',
|
||||
coverArt: '/covers/ebb.jpg',
|
||||
coverArtSmall: '/covers/ebb_sm.jpg',
|
||||
coverArtMedium: '/covers/ebb_md.jpg',
|
||||
coverArtLarge: '/covers/ebb_lg.jpg',
|
||||
trackChangeId: 1,
|
||||
artistMbid: '',
|
||||
releaseGroupMbid: '',
|
||||
recordingMbid: '',
|
||||
};
|
||||
|
||||
describe('now-playing-view', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
});
|
||||
|
||||
it('reuses the real transport components', async () => {
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
|
||||
const el = await fixture('now-playing-view');
|
||||
|
||||
for (const tag of ['seek-bar', 'player-controls', 'volume-control']) {
|
||||
expect(shadow(el, tag), `${tag} is not rendered`).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the track, and the largest cover tier that is kept', async () => {
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
|
||||
const el = await fixture('now-playing-view');
|
||||
|
||||
expect(text(el, '[data-testid="npv-title"]')).toBe('Tideline');
|
||||
|
||||
// `saveCoverArt` records the largest *tier* as the path; there is
|
||||
// no full-resolution original on disk to reach for.
|
||||
expect(
|
||||
shadow<HTMLImageElement>(el, '[data-testid="npv-art"]')?.getAttribute('src'),
|
||||
).toBe('/covers/ebb_lg.jpg');
|
||||
});
|
||||
|
||||
it('says so when nothing is playing, rather than rendering an empty frame', async () => {
|
||||
// The player store is a singleton and outlives a test, so "no
|
||||
// track" has to be stated rather than assumed from a fresh mount.
|
||||
emit(Events.TrackChanged, null);
|
||||
|
||||
const el = await fixture('now-playing-view');
|
||||
|
||||
expect(shadow(el, '[data-testid="npv-empty"]')).not.toBeNull();
|
||||
expect(shadow(el, '[data-testid="npv-art"]')).toBeNull();
|
||||
|
||||
// …and the way out is still there, which is the whole point of
|
||||
// rendering the header in both branches.
|
||||
expect(shadow(el, '[data-testid="npv-back"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('leaves by the nav stack, not by guessing where it came from', async () => {
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
|
||||
const el = await fixture('now-playing-view');
|
||||
let backs = 0;
|
||||
|
||||
document.addEventListener('navigate-back', () => {
|
||||
backs += 1;
|
||||
});
|
||||
|
||||
shadow<HTMLButtonElement>(el, '[data-testid="npv-back"]')?.click();
|
||||
|
||||
// `navigate-back` pops what index.ts pushed. Dispatching a
|
||||
// `navigate` to a hardcoded view would strand anyone who arrived
|
||||
// here from a detail page.
|
||||
expect(backs).toBe(1);
|
||||
});
|
||||
|
||||
it('gives the favourite button a target and a state', async () => {
|
||||
stub('playlist.Service.ToggleFavorite', undefined);
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
|
||||
const el = await fixture('now-playing-view');
|
||||
const fav = shadow<HTMLButtonElement>(el, '[data-testid="npv-favorite"]');
|
||||
|
||||
expect(fav).not.toBeNull();
|
||||
expect(fav?.getAttribute('aria-pressed')).toBe('false');
|
||||
|
||||
// A button that says only "heart" says nothing; the name carries
|
||||
// the track and the playlist it goes to.
|
||||
expect(fav?.getAttribute('aria-label')).toContain('Tideline');
|
||||
|
||||
expect(fav!.getBoundingClientRect().height).toBeGreaterThanOrEqual(48);
|
||||
});
|
||||
|
||||
it('gives the way out a thumb-sized target', async () => {
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
|
||||
const el = await fixture('now-playing-view');
|
||||
const back = shadow<HTMLButtonElement>(el, '[data-testid="npv-back"]');
|
||||
|
||||
expect(back!.getBoundingClientRect().height).toBeGreaterThanOrEqual(48);
|
||||
expect(back?.getAttribute('aria-label')).toBe('Back');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user