Three properties the phone's band needs, added here so it is the same panel rather than a second job UI -- which is what keeps `applyJobControl` and its "you will discard hours of downloading" confirmation in the picture. `kinds="*"` is every kind, which is what the header indicator was for. Spelled as a star rather than taken as the meaning of an empty attribute, because empty is what a typo and a dropped binding both produce and "show everything" is the wrong thing to do by accident; empty still shows nothing. `density` is passed to `job-row`, whose `compact` variant its own source calls "the popover density" -- which is exactly what the band replaces. `full` stays the default, so the four settings call sites are untouched. `active-only` drops terminal rows. The band is in the layout, so a finished row there holds the content down after the work is done; Settings keeps them, because that is where "did the last scan work" is asked and a finished row there dismisses itself.
223 lines
7.4 KiB
TypeScript
223 lines
7.4 KiB
TypeScript
/**
|
|
* Background work, shown where the work is started (#27).
|
|
*
|
|
* The Jobs tab is gone; each kind's rows now live beside the thing that
|
|
* starts it — scans in Settings → Libraries, index work in Search
|
|
* Index, downloads under the download clients, the autotag apply in the
|
|
* Autotag view. What those four surfaces never had, and what this
|
|
* carries to them, is the *generic* affordances: pause, cancel,
|
|
* "Details", and a finished job you can dismiss.
|
|
*
|
|
* The assertions are about which rows a panel owns and what its buttons
|
|
* do, not about the store — `job-store` already has the snapshot
|
|
* covered, and a panel that renders the right rows for the wrong reason
|
|
* would pass either way.
|
|
*/
|
|
import { describe, expect, it, beforeEach } from 'vitest';
|
|
|
|
import '@components/jobs/job-panel';
|
|
import { emit, flush, lastArgs, calls, resetHarness, stub } from '@test/support/harness';
|
|
import { Events } from '../../src/events';
|
|
import { fixture, shadow, shadowAll } from '@test/support/render';
|
|
import type { LitElement } from 'lit';
|
|
|
|
/** A job snapshot entry, with the fields `job-row` actually reads. */
|
|
const job = (over: Record<string, unknown> = {}) => ({
|
|
id: 'scan:1',
|
|
kind: 'library-scan',
|
|
title: 'Scanning Music',
|
|
state: 'running',
|
|
current: 3,
|
|
total: 10,
|
|
caps: { pausable: true, cancellable: true },
|
|
stages: null,
|
|
stats: null,
|
|
startedAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
logCount: 0,
|
|
warnCount: 0,
|
|
errorCount: 0,
|
|
...over,
|
|
});
|
|
|
|
/** Push a full snapshot, which is what the backend emits. */
|
|
async function snapshot(jobs: unknown[]): Promise<void> {
|
|
emit(Events.JobsChanged, jobs);
|
|
await flush();
|
|
}
|
|
|
|
const rows = (el: HTMLElement) => shadowAll(el, 'job-row');
|
|
|
|
const titles = (el: HTMLElement) =>
|
|
rows(el).map((row) => (row as HTMLElement & { job: { title: string } }).job.title);
|
|
|
|
describe('<job-panel>', () => {
|
|
beforeEach(async () => {
|
|
resetHarness();
|
|
stub('jobs.Service.GetJobs', []);
|
|
await snapshot([]);
|
|
});
|
|
|
|
it('renders only the kinds it was asked for', async () => {
|
|
const el = await fixture<LitElement>('job-panel', {
|
|
kinds: 'index-build,catalog-enrich',
|
|
});
|
|
|
|
await snapshot([
|
|
job({ id: 'scan:1', kind: 'library-scan', title: 'Scanning Music' }),
|
|
job({ id: 'idx', kind: 'index-build', title: 'Building the index' }),
|
|
job({ id: 'enrich', kind: 'catalog-enrich', title: 'Filling in artists' }),
|
|
]);
|
|
await el.updateComplete;
|
|
|
|
// The title is inside `job-row`'s own shadow root, so this asks
|
|
// the rows what they are drawing rather than reading the panel's
|
|
// text -- which would pass whether or not a row rendered.
|
|
expect(titles(el)).toEqual(['Building the index', 'Filling in artists']);
|
|
});
|
|
|
|
/**
|
|
* #62. The phone's band has no kinds to name: it is standing in for
|
|
* the header indicator, whose whole job was to be the one view of
|
|
* everything at once.
|
|
*/
|
|
it('answers for every kind when asked with a star', async () => {
|
|
const el = await fixture<LitElement>('job-panel', { kinds: '*' });
|
|
|
|
await snapshot([
|
|
job({ id: 'scan:1', kind: 'library-scan', title: 'Scanning Music' }),
|
|
job({ id: 'idx', kind: 'index-build', title: 'Building the index' }),
|
|
job({ id: 'dl:1', kind: 'download', title: 'Downloading Glass Harbour' }),
|
|
]);
|
|
await el.updateComplete;
|
|
|
|
expect(titles(el)).toEqual([
|
|
'Scanning Music',
|
|
'Building the index',
|
|
'Downloading Glass Harbour',
|
|
]);
|
|
});
|
|
|
|
/**
|
|
* The other half of that, and the reason it is a star rather than the
|
|
* meaning of an empty attribute: empty is what a typo and a dropped
|
|
* binding both produce, and "show everything" is the wrong thing to
|
|
* do by accident.
|
|
*/
|
|
it('still shows nothing when asked for nothing', async () => {
|
|
const el = await fixture<LitElement>('job-panel', { kinds: '' });
|
|
|
|
await snapshot([job({ id: 'scan:1', kind: 'library-scan' })]);
|
|
await el.updateComplete;
|
|
|
|
expect([el.hidden, rows(el)].map(String)).toEqual(['true', '']);
|
|
});
|
|
|
|
/**
|
|
* `full` stays the default so the four settings call sites are
|
|
* untouched; the band asks for the density `job-row` calls "the
|
|
* popover density", because on the phone this panel *is* the popover.
|
|
*/
|
|
it('passes its density to the rows, defaulting to full', async () => {
|
|
const settings = await fixture<LitElement>('job-panel', { kinds: '*' });
|
|
|
|
await snapshot([job()]);
|
|
await settings.updateComplete;
|
|
|
|
const band = await fixture<LitElement>('job-panel', {
|
|
kinds: '*',
|
|
density: 'compact',
|
|
});
|
|
|
|
await snapshot([job()]);
|
|
await band.updateComplete;
|
|
|
|
expect([
|
|
rows(settings)[0]?.getAttribute('variant'),
|
|
rows(band)[0]?.getAttribute('variant'),
|
|
]).toEqual(['full', 'compact']);
|
|
});
|
|
|
|
/**
|
|
* An idle panel in four places is four pieces of furniture describing
|
|
* an absence — and `hidden` rather than an empty render, because the
|
|
* host's own margin would otherwise still be spent.
|
|
*/
|
|
it('takes up no room when it has nothing to say', async () => {
|
|
const el = await fixture<LitElement>('job-panel', { kinds: 'download' });
|
|
|
|
await snapshot([job({ id: 'scan:1', kind: 'library-scan' })]);
|
|
await el.updateComplete;
|
|
|
|
expect(el.hidden).toBe(true);
|
|
expect(rows(el)).toHaveLength(0);
|
|
|
|
await snapshot([
|
|
job({ id: 'dl:1', kind: 'download', title: 'Downloading Glass Harbour' }),
|
|
]);
|
|
await el.updateComplete;
|
|
|
|
expect(el.hidden).toBe(false);
|
|
expect(rows(el)).toHaveLength(1);
|
|
});
|
|
|
|
/**
|
|
* The controls go through `applyJobControl`, which is what carries
|
|
* the index build's "you will discard hours of downloading"
|
|
* confirmation across this move. A host drawing its own buttons would
|
|
* have dropped it silently.
|
|
*/
|
|
it('pauses through the shared handler', async () => {
|
|
const el = await fixture<LitElement>('job-panel', { kinds: 'library-scan' });
|
|
|
|
await snapshot([job()]);
|
|
await el.updateComplete;
|
|
|
|
const row = rows(el)[0]!;
|
|
|
|
shadow<HTMLButtonElement>(row, 'button[aria-label^="Pause"]')?.click();
|
|
await flush();
|
|
|
|
expect(lastArgs('jobs.Service.PauseJob')).toEqual(['scan:1']);
|
|
});
|
|
|
|
/**
|
|
* Cancelling an index build asks first; cancelling a scan does not,
|
|
* because a scan is cheap to re-run. Both answers live in
|
|
* `applyJobControl` and both had to survive the move.
|
|
*/
|
|
it('does not ask before cancelling a scan', async () => {
|
|
const el = await fixture<LitElement>('job-panel', { kinds: 'library-scan' });
|
|
|
|
await snapshot([job()]);
|
|
await el.updateComplete;
|
|
|
|
const row = rows(el)[0]!;
|
|
|
|
shadow<HTMLButtonElement>(row, 'button[aria-label^="Stop"]')?.click();
|
|
await flush();
|
|
|
|
expect(lastArgs('jobs.Service.CancelJob')).toEqual(['scan:1']);
|
|
});
|
|
|
|
/**
|
|
* A finished job is dismissed one at a time. There is deliberately no
|
|
* "Clear finished" here: `ClearFinishedJobs` is global, so a Clear in
|
|
* the Libraries panel would discard the index build's history too.
|
|
*/
|
|
it('keeps finished jobs, dismissible one by one', async () => {
|
|
const el = await fixture<LitElement>('job-panel', { kinds: 'library-scan' });
|
|
|
|
await snapshot([job({ state: 'complete' })]);
|
|
await el.updateComplete;
|
|
|
|
const row = rows(el)[0]!;
|
|
|
|
shadow<HTMLButtonElement>(row, 'button[aria-label^="Dismiss"]')?.click();
|
|
await flush();
|
|
|
|
expect(lastArgs('jobs.Service.DismissJob')).toEqual(['scan:1']);
|
|
expect(calls('jobs.Service.ClearFinishedJobs')).toHaveLength(0);
|
|
});
|
|
});
|