The complaint was having to notice the metadata was missing, then go and hunt the album down on the Autotag page. The album page now says it while you are looking at the thing: "MusicBrainz has a match for this album: <release> by <artist>", with Apply tags and Review in Autotag. Four things about it are load-bearing. **Applying is offered only where it would do the whole album.** A tagging group is a folder, so a multi-disc album is several, and one button that applied to the best-scoring group would leave the album holding a mix of old and new tags — the exact case the app's Blocking notification level exists for. `groupCount` is the test, and the answer there is review rather than apply. **It rewrites files, so it asks.** `confirmAction()` with an impact line that says it cannot be undone and that nothing is moved or deleted, because "rewrites your files" reads worse than it is. The apply goes through `ApplyAsync`, the registered-job path, so progress belongs to the jobs indicator and this page does not grow a second one — what it owes the user is the acknowledgement, because the button is here. The suggestion clears itself on success rather than inviting a second click while the job runs. **The banner does not quote a percentage.** The backend has a score and deliberately keeps it out of the sentence: 0.95 reads as a probability and is not one. Which release it is, is the part a person can judge. **"Review in Autotag" lands on that album.** The queue is sorted by score so the intended folder is often near the top, and "often" is a link that sometimes opens a different album. Autotag is a cached primary view, so there is no construction to hand a payload to: the request goes on as an attribute and the view *consumes* it, or every later visit would reopen a folder the user finished with long ago. `ICON_AUTOTAG` joins the vocabulary at the same time, on the rule `ICON_PLAYLIST` was chosen by — an icon names the noun it acts on, so a suggestion pointing at Autotag wears the Autotag destination's own mark. It was written inline in the sidebar; two call sites is where a name stops being one component's detail, so the sweep governs it now. Verified against the running app with a staged match: the banner, the confirm dialog's wording, and the navigation landing on the right folder with the attribute consumed. Closes #28
142 lines
4.9 KiB
TypeScript
142 lines
4.9 KiB
TypeScript
/**
|
|
* The icon vocabulary is one table, and nothing writes around it.
|
|
*
|
|
* A wrong-but-real icon name renders perfectly: no error, no fallback,
|
|
* no failing assertion anywhere. That is how `plus` came to mean "add
|
|
* to the queue", "add to a playlist", "make a new playlist" and "you do
|
|
* not own this" — the first two adjacent in the same context menu —
|
|
* while `list` meant the queue, the Playlists destination *and* adding
|
|
* to the queue.
|
|
*
|
|
* `src/icons/index.ts` catches a name that is not *bundled*. Nothing
|
|
* catches a name that is bundled and means something else, so this
|
|
* sweeps the source for the governed ones. It is the same shape as
|
|
* `TestNoDirectRuntimeEmits` and `TestNoWritesOnTheReadPool` in the
|
|
* backend, and exists for the same reason: the rule is about every call
|
|
* site, so checking one is checking nothing.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import { bundledIconNames } from '../../src/icons';
|
|
import * as icons from '@utils/icon-language';
|
|
|
|
/** Every component source, as text. */
|
|
const SOURCES = import.meta.glob<string>('../../src/**/*.ts', {
|
|
eager: true,
|
|
query: '?raw',
|
|
import: 'default',
|
|
});
|
|
|
|
/**
|
|
* The names that carry a meaning the table owns.
|
|
*
|
|
* Deliberately not every bundled name. `check` is `ICON_IN_LIBRARY`
|
|
* here and also the "Copied" confirmation in `job-log-view`, which is
|
|
* a different, perfectly good meaning — governing it would force a
|
|
* false rename. What belongs on this list is a name that was actually
|
|
* overloaded.
|
|
*/
|
|
const GOVERNED = [
|
|
'plus',
|
|
'list',
|
|
'bookmark',
|
|
'solid/bookmark',
|
|
'regular/bookmark',
|
|
'bars-staggered',
|
|
'tag',
|
|
];
|
|
|
|
/** The one file allowed to say them, plus its own test. */
|
|
const DEFINITION = /icon-language\.(ts|test\.ts)$/;
|
|
|
|
describe('the icon vocabulary', () => {
|
|
/**
|
|
* A sweep over nothing passes. This is the assertion that makes the
|
|
* rest of the file mean something, and it is the first thing that
|
|
* breaks if the glob pattern stops matching after a move.
|
|
*/
|
|
it('actually reads the source', () => {
|
|
const paths = Object.keys(SOURCES);
|
|
|
|
expect(paths.length).toBeGreaterThan(100);
|
|
expect(paths.some((p) => p.endsWith('/track-list.ts'))).toBe(true);
|
|
expect(SOURCES[paths[0]!]).toContain('import');
|
|
});
|
|
|
|
it.each(GOVERNED)('is not written around for %s', (name) => {
|
|
const offenders: string[] = [];
|
|
|
|
for (const [path, source] of Object.entries(SOURCES)) {
|
|
if (DEFINITION.test(path)) continue;
|
|
|
|
// Both spellings: an icon in a template, and an icon name in a
|
|
// data table (which is how the sidebar and bottom-nav carry
|
|
// theirs).
|
|
const literal = new RegExp(
|
|
`(name="${name}"|icon: '${name}'|name=\\$\\{[^}]*'${name}')`,
|
|
);
|
|
|
|
if (literal.test(source)) offenders.push(path);
|
|
}
|
|
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* A meaning with no icon behind it is the state the badge's `queued`
|
|
* spent a year in — declared, styled, and produced by nothing.
|
|
*/
|
|
it('gives every meaning a name', () => {
|
|
const values = Object.entries(icons).filter(([k]) => k.startsWith('ICON_'));
|
|
|
|
expect(values.length).toBeGreaterThan(0);
|
|
|
|
for (const [key, value] of values) {
|
|
expect(`${key}=${value}`).toMatch(/^ICON_[A-Z_]+=[a-z]+[a-z/-]*$/);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Every name in the table is a name the app actually ships.
|
|
*
|
|
* This is the loop the vocabulary closes. A name that is not bundled
|
|
* renders a circled question mark and reports itself to
|
|
* `__yjIconMisses` — at *runtime*, from a state something has to
|
|
* reach first. `bookmark-check` is Font Awesome **Pro**, and it was
|
|
* on `explore-artist-details`'s Follow button, drawn for every
|
|
* followed artist, invisible to `offline-icons.spec.ts` because no
|
|
* spec had ever followed one. Reaching the state is no longer how
|
|
* this is found.
|
|
*/
|
|
it('names only icons that are bundled', () => {
|
|
const bundled = new Set(bundledIconNames());
|
|
const missing = Object.entries(icons)
|
|
.filter(([k]) => k.startsWith('ICON_'))
|
|
.filter(([, v]) => !bundled.has(v as string))
|
|
.map(([k, v]) => `${k} (${v})`);
|
|
|
|
expect(missing).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* The two states of the request toggle have to be the same glyph in
|
|
* two weights, or they do not read as each other's opposite — which
|
|
* is what a plus against a bookmark was.
|
|
*/
|
|
it('makes the request toggle an outline/solid pair', () => {
|
|
expect(icons.ICON_CAN_REQUEST).toBe(`regular/${icons.ICON_REQUESTED.replace('solid/', '')}`);
|
|
});
|
|
|
|
/**
|
|
* The queue and the Playlists destination wore the same icon, and
|
|
* "add to queue" and "add to playlist" sat next to each other wearing
|
|
* a third same one. Whatever the table says, these three have to
|
|
* differ from each other.
|
|
*/
|
|
it('keeps the queue, playlists and creating something apart', () => {
|
|
const three = [icons.ICON_QUEUE, icons.ICON_PLAYLIST, icons.ICON_NEW];
|
|
|
|
expect(new Set(three).size).toBe(3);
|
|
});
|
|
});
|