Files
yellowjacket/frontend/test/components/view-visibility.test.ts
T
logan 43d78a731a feat(shell): draw only the destinations the user kept
The navigation reads the resolved map from the backend rather than
holding a copy of the defaults, which would be the copy that shipped in
the binary rather than the one being edited.

Hiding takes away the nav item and nothing else: `navigate` still
resolves a hidden view, which detail views and the launch page depend
on. No special case was needed for the highlight, because #72 moved
that onto `active-view-store` -- the sidebar asks `isActive(id)` per
*rendered* item, so a hidden view lights nothing exactly as a detail
view does.

Downloads is gated at the nav on `downloadStore.available` rather than
in the config, so switching it on in Settings still means what it says
once a client exists, and the tab appears without a restart. `available`
is false until the providers have loaded, which makes the item appear on
a fresh launch rather than appearing and then vanishing.

The tab bar honours the toggles too, and the reason is local rather than
a general rule about phones: "More" opens the *same* `<app-sidebar>`,
which filters, so an unfiltered bar would contradict its own drawer one
tap away. Which four tabs is still plan 016's subset; this only removes
from it, and "More" is never filtered.

`services/view-meta.ts` is the destination list, on `shortcut-meta.ts`'s
pattern, because Settings is now a second reader of the same labels in
the same order.

Two existing sidebar tests had to say which world they describe: eleven
destinations now assumes a configured download client.
2026-08-19 19:33:44 -04:00

192 lines
6.0 KiB
TypeScript

/**
* Which destinations the navigation offers (#25).
*
* Eleven sidebar entries is more than most libraries need, so they are
* individually toggleable. The assertions here are about the **nav**
* and not about the setting being saved: "the config was written" is
* the plumbing, and a spec that measures the plumbing is how #69 and
* #72 both shipped green on a broken build.
*
* Two singletons make ordering matter, and both are driven the way the
* app drives them rather than reset: `GeneralConfigChanged` is what the
* backend emits when a toggle is saved, and `DownloadProvidersChanged`
* is what it emits when a client is configured. So each case states the
* world it wants and is independent of which one ran first.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import '@components/sidebar/app-sidebar';
import '@components/bottom-nav/bottom-nav';
import { activeViewStore } from '@store/active-view-store';
import { stub, emit, flush, resetHarness } from '@test/support/harness';
import { Events } from '../../src/events';
import { fixture, shadowAll } from '@test/support/render';
import type { LitElement } from 'lit';
const PROVIDER = {
id: 1,
kind: 'slskd',
name: 'Sound',
enabled: true,
priority: 50,
};
/** Every view id the sidebar is currently drawing, in order. */
const navIDs = (el: HTMLElement) =>
shadowAll<HTMLButtonElement>(el, 'nav button')
.map((b) => b.dataset.testid?.replace(/^nav-/, ''))
.filter((id): id is string => id !== undefined);
const tabIDs = (el: HTMLElement) =>
shadowAll<HTMLButtonElement>(el, 'nav button')
.map((b) => b.dataset.testid?.replace(/^tab-/, ''))
.filter((id): id is string => id !== undefined);
/**
* State the backend's resolved answer and push the event that says it
* changed. The map is *resolved* — every known view, defaults already
* applied — because that is what the binding returns and the whole
* reason the frontend holds no copy of the defaults.
*/
async function setViews(views: Record<string, boolean>): Promise<void> {
stub('config.Config.GetViewVisibility', views);
emit(Events.GeneralConfigChanged, {});
await flush();
await flush();
}
async function setClientConfigured(configured: boolean): Promise<void> {
stub('download.Service.ListProviders', configured ? [PROVIDER] : []);
emit(Events.DownloadProvidersChanged);
await flush();
await flush();
}
const ALL_VISIBLE = {
home: true,
playlists: true,
artists: true,
genres: true,
albums: true,
tracks: true,
explore: true,
downloads: true,
autotag: true,
jobs: true,
settings: true,
};
describe('view visibility', () => {
beforeEach(async () => {
resetHarness();
await setViews(ALL_VISIBLE);
await setClientConfigured(true);
});
it('draws every destination the config keeps', async () => {
const el = await fixture<LitElement>('app-sidebar');
expect(navIDs(el)).toEqual([
'home',
'playlists',
'artists',
'genres',
'albums',
'tracks',
'explore',
'downloads',
'autotag',
'jobs',
'settings',
]);
});
it('drops the ones the user switched off', async () => {
const el = await fixture<LitElement>('app-sidebar');
await setViews({ ...ALL_VISIBLE, autotag: false, jobs: false });
await el.updateComplete;
expect(navIDs(el)).not.toContain('autotag');
expect(navIDs(el)).not.toContain('jobs');
expect(navIDs(el)).toContain('settings');
});
/**
* Hiding is about the nav item, not about the view. Detail views
* navigate into these and the launch page is one of them, so the
* shell's own statement of where the user is has to survive a
* destination that draws no item — and it does so with no special
* case here, because #72 moved the highlight onto `active-view-store`
* and this only filters what is rendered.
*/
it('lights nothing when the active view is a hidden one', async () => {
const el = await fixture<LitElement>('app-sidebar');
await setViews({ ...ALL_VISIBLE, autotag: false });
activeViewStore.setView('autotag', true);
await el.updateComplete;
const lit = shadowAll<HTMLButtonElement>(el, 'nav button')
.filter((b) => b.getAttribute('aria-current') === 'page');
expect(lit).toHaveLength(0);
expect(navIDs(el)).not.toContain('autotag');
activeViewStore.setView('albums', true);
});
/**
* A destination for a feature that cannot work is worse than an
* absent one, so Downloads asks the download client rather than the
* config — and it appears when one is configured, without a restart
* (#37's rule, one surface over).
*/
it('hides Downloads until a client is configured', async () => {
const el = await fixture<LitElement>('app-sidebar');
await setClientConfigured(false);
await el.updateComplete;
expect(navIDs(el)).not.toContain('downloads');
await setClientConfigured(true);
await el.updateComplete;
expect(navIDs(el)).toContain('downloads');
});
/**
* The tab bar honours the toggles too, and the reason is local: its
* "More" drawer opens the same `<app-sidebar>`, which filters. An
* unfiltered bar would contradict its own drawer one tap away.
*/
it('drops a hidden destination from the phone tab bar', async () => {
const el = await fixture<LitElement>('bottom-nav');
expect(tabIDs(el)).toEqual(['home', 'albums', 'tracks', 'playlists', 'more']);
await setViews({ ...ALL_VISIBLE, albums: false });
await el.updateComplete;
expect(tabIDs(el)).toEqual(['home', 'tracks', 'playlists', 'more']);
});
/** "More" is not a destination and is never filtered away: it is how
* everything else is still reachable. */
it('keeps More when every tab is hidden', async () => {
const el = await fixture<LitElement>('bottom-nav');
await setViews({
...ALL_VISIBLE,
home: false,
albums: false,
tracks: false,
playlists: false,
});
await el.updateComplete;
expect(tabIDs(el)).toEqual(['more']);
});
});