Files
yellowjacket/frontend/test/components/progress-line.test.ts
T
logan bd45e5d595
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 8m55s
feat(player): show progress on the phone's bar border
#59 took the seek bar off the phone's transport, so the one thing a
mini player is expected to say without being opened -- how far through
the song it is -- had nowhere left to be said.

It is the shell's element and its own 2px grid row between `bottom-bar`
and `bottom-nav`, because those two are separate components and either
one drawing the line means reaching into the other's box. The fill is
`scaleX()` off the same `PlaybackPositionChanged` the seek bar renders,
with the same `trackChangeId`/`seq` guards and an interval that only
interpolates *between* reports -- never its own clock, which is the
rule that exists because a local counter drifted 30 s away from the
backend across four keyboard seeks.

It is `aria-hidden` and takes no pointer events at any depth: Now
Playing's seek bar is what announces the position, and a 2px strip on
the top edge of the tab bar is exactly where a thumb aiming at a tab
lands. It renders nothing above 600px, from `matchMedia` rather than a
media query, because a stylesheet cannot stop a 1 Hz interval running
for the life of every desktop session about a line nobody can see.

Its phone rule is at the foot of index.css beside `job-band`'s, not in
the phone block above: a media query adds no specificity, so a
`display: block` written before the `display: none` that takes it out
of the desktop grid loses to it and the line never appears at all.

Closes #58
2026-08-21 03:43:37 -04:00

229 lines
7.0 KiB
TypeScript

/**
* The phone's progress line (#58).
*
* **What this tier can and cannot see.** It can see the whole of what
* the issue asks for that is not a pixel: that the line exists only on
* a phone and only with a track, that it renders the position the
* backend reported rather than a count of its own, and that it is
* neither announced nor touchable. It cannot see where it sits — that
* is the shell's grid, and it is asserted in
* `e2e/specs/phone-transport.spec.ts` where there is a real bar with a
* real tab bar under it.
*/
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import '@components/audio-player/progress-line/progress-line';
import { Events } from '../../src/events';
import { emit, flush } from '@test/support/harness';
import { fixture, shadow } from '@test/support/render';
const TRACK = {
fileName: 'song.mp3',
filePath: '/music/song.mp3',
trackLength: 90,
seekPosition: 0,
state: 'playing',
title: 'Song',
artist: 'Artist',
album: 'Album',
coverArt: '',
coverArtSmall: '',
coverArtMedium: '',
coverArtLarge: '',
trackChangeId: 1,
artistMbid: '',
releaseGroupMbid: '',
recordingMbid: '',
};
/**
* Answer `matchMedia` for the phone query, since the runner's own
* window is whatever size the browser provider gives it. Stubbed rather
* than resized for `transport-context.test.ts`'s reason: what is under
* test is the component's reaction to the answer.
*/
const realMatchMedia = window.matchMedia;
function pretendPhone(phone: boolean): void {
window.matchMedia = ((query: string) => ({
matches: phone && query.includes('599'),
media: query,
addEventListener: () => {},
removeEventListener: () => {},
})) as unknown as typeof window.matchMedia;
}
/** The horizontal scale of the fill, or null if there is no line. */
function scale(el: Element): number | null {
const fill = shadow<HTMLElement>(el, '.fill');
if (!fill) return null;
const match = /scaleX\(([^)]+)\)/.exec(fill.style.transform);
return match ? Number(match[1]) : null;
}
describe('<player-progress-line>', () => {
beforeEach(() => {
emit(Events.TrackChanged, null);
emit(Events.PlaybackStateChanged, { state: 'stopped' });
});
afterEach(() => {
window.matchMedia = realMatchMedia;
vi.useRealTimers();
});
it('draws nothing above the phone breakpoint', async () => {
pretendPhone(false);
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 2 });
emit(Events.PlaybackPositionChanged, {
positionSeconds: 45,
trackLength: 90,
trackChangeId: 2,
seq: 1,
playing: true,
});
await flush();
await el.updateComplete;
// The desktop bar carries a real seek bar, and there is no tab
// bar for this to sit on the border of.
expect(el.shadowRoot!.querySelector('.track')).toBeNull();
});
it('draws nothing until there is a track', async () => {
pretendPhone(true);
const el = await fixture('player-progress-line');
expect(el.shadowRoot!.querySelector('.track')).toBeNull();
});
it('renders the fraction the backend reported', async () => {
pretendPhone(true);
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 3 });
emit(Events.PlaybackPositionChanged, {
positionSeconds: 45,
trackLength: 90,
trackChangeId: 3,
seq: 1,
playing: true,
});
await flush();
await el.updateComplete;
expect(scale(el)).toBeCloseTo(0.5, 3);
});
it('resumes mid-track at the position the track arrived with', async () => {
pretendPhone(true);
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, {
...TRACK,
seekPosition: 30,
trackChangeId: 4,
});
await flush();
await el.updateComplete;
expect(scale(el)).toBeCloseTo(1 / 3, 3);
});
it('interpolates between reports, and every report resets it', async () => {
pretendPhone(true);
vi.useFakeTimers();
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 5 });
emit(Events.PlaybackStateChanged, { state: 'playing' });
await vi.advanceTimersByTimeAsync(3000);
await el.updateComplete;
expect(scale(el)).toBeCloseTo(3 / 90, 3);
// The user seeks; the backend lands somewhere else and says so.
// The local count is discarded, never added to -- the seek
// bar's rule, and the reason it has it.
emit(Events.PlaybackPositionChanged, {
positionSeconds: 40,
trackLength: 90,
trackChangeId: 5,
seq: 2,
playing: true,
});
await vi.advanceTimersByTimeAsync(1000);
await el.updateComplete;
expect(scale(el)).toBeCloseTo(41 / 90, 3);
});
it('ignores a report about a track that is no longer loaded', async () => {
pretendPhone(true);
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 6 });
emit(Events.PlaybackPositionChanged, {
positionSeconds: 60,
trackLength: 90,
trackChangeId: 5,
seq: 3,
playing: true,
});
await flush();
await el.updateComplete;
// The store is a singleton, so a line mounting late must not
// adopt a report about the previous track.
expect(scale(el)).toBe(0);
});
it('counts nothing while the player is paused', async () => {
pretendPhone(true);
vi.useFakeTimers();
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 7 });
emit(Events.PlaybackPositionChanged, {
positionSeconds: 10,
trackLength: 90,
trackChangeId: 7,
seq: 1,
playing: false,
});
emit(Events.PlaybackStateChanged, { state: 'paused' });
await vi.advanceTimersByTimeAsync(5000);
await el.updateComplete;
expect(scale(el)).toBeCloseTo(10 / 90, 3);
});
it('is decorative and cannot be touched', async () => {
pretendPhone(true);
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 8 });
await flush();
await el.updateComplete;
// The seek bar on Now Playing is what announces the position;
// this says the same thing with no name and no way to act on
// it, and it sits exactly where a thumb aiming at a tab lands.
expect(el.getAttribute('aria-hidden')).toBe('true');
expect(getComputedStyle(el).pointerEvents).toBe('none');
});
});