Files
iptvnator/apps/web/src/app/settings/settings.component.search.spec.ts
T
ea51cae3db feat(settings): search settings from the header and the command palette (#1714)
* feat(settings): search settings from the header and the command palette

The header search on the Settings page was shown but disabled. It now
searches a shared index of all 56 settings rows by translated title,
description and English synonyms, replaces the section page with ranked
results, and opens a result by scrolling to, focusing and briefly
highlighting its row. Enter opens the best match, and the section
navigation shows per-section match counts.

The command palette gains a "Settings" group that lists the best six
matches for a non-empty query, so any setting is one Ctrl/Cmd+K away.
Rows hidden by the current form state fall back to the control that
reveals them; rows the runtime cannot render are never returned.

The index ships through a new @iptvnator/workspace/shell/util/settings-search
sub-entrypoint so it stays out of the eager bundle, and a registry spec
keeps it in step with the section templates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): let search reveals win over pending input and gate embedded MPV rows

- A reveal (result click, command palette, Enter) now cancels a search
  keystroke still waiting for its debounce, so its q navigation can no
  longer supersede the reveal and leave the results open.
- Embedded MPV extra options and auto-reconnect require a lazily probed
  embedded MPV capability; frame copy also needs frameCopyAvailable, so
  search never offers a row the settings page cannot render.
- Keyboard users keep a focus-visible ring on the revealed row after the
  highlight fades.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspace): wait for palette probes without Promise.allSettled

The web tsconfig lib predates Promise.allSettled; use Promise.all over
rejection-safe probes instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 07:42:53 +02:00

182 lines
6.4 KiB
TypeScript

import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { Router } from '@angular/router';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
import {
SETTINGS_SEARCH_ENTRIES,
SettingsSearchEntry,
SettingsSearchService,
} from '@iptvnator/workspace/shell/util/settings-search';
import { SettingsComponent } from './settings.component';
import { SETTINGS_REVEALED_CLASS } from './settings-search.facade';
import {
configureSettingsComponentTestBed,
createElectronStub,
createEpgBridgeStub,
MockRouter,
setSettingsSearchQuery,
setSettingsSection,
stubSettingsSideEffects,
} from './test-stubs/settings-test-harness.stub';
function entry(id: string): SettingsSearchEntry {
const found = SETTINGS_SEARCH_ENTRIES.find((item) => item.id === id);
if (!found) {
throw new Error(`unknown settings entry ${id}`);
}
return found;
}
/**
* Settings search on the page: the shell writes the header term to `q`,
* the page swaps the section for ranked results, and a chosen result (or a
* command palette pick) scrolls to and highlights the row. The labels are
* translation keys here (no translations are loaded), which the index
* searches like any other text.
*/
describe('SettingsComponent search', () => {
let fixture: ComponentFixture<SettingsComponent>;
let component: SettingsComponent;
let settingsSearch: SettingsSearchService;
let settingsCtx: SettingsContextService;
let scrollIntoView: jest.Mock;
const originalElectron = window.electron;
const originalScrollIntoView = Element.prototype.scrollIntoView;
const element = (): HTMLElement => fixture.nativeElement as HTMLElement;
const query = (selector: string) => element().querySelector(selector);
beforeEach(waitForAsync(() => {
configureSettingsComponentTestBed(createEpgBridgeStub());
}));
beforeEach(async () => {
window.electron = createElectronStub();
scrollIntoView = jest.fn();
Element.prototype.scrollIntoView = scrollIntoView;
fixture = TestBed.createComponent(SettingsComponent);
component = fixture.componentInstance;
settingsSearch = TestBed.inject(SettingsSearchService);
settingsCtx = TestBed.inject(SettingsContextService);
stubSettingsSideEffects(component);
const loadSettings = jest.spyOn(component.form, 'loadSettings');
fixture.detectChanges();
await loadSettings.mock.results[0].value;
await fixture.whenStable();
fixture.detectChanges();
});
afterEach(() => {
jest.useRealTimers();
window.electron = originalElectron;
Element.prototype.scrollIntoView = originalScrollIntoView;
});
it('replaces the section page with ranked results while searching', () => {
expect(query('app-settings-general-section')).not.toBeNull();
setSettingsSearchQuery('theme');
fixture.detectChanges();
expect(query('app-settings-general-section')).toBeNull();
expect(
query('[data-test-id="settings-search-result-theme"]')
).not.toBeNull();
expect(
query('[data-test-id="settings-search-summary"]')?.getAttribute(
'aria-live'
)
).toBe('polite');
setSettingsSearchQuery('');
fixture.detectChanges();
expect(query('[data-test-id="settings-search-results"]')).toBeNull();
expect(query('app-settings-general-section')).not.toBeNull();
});
it('shows an empty state when nothing matches', () => {
setSettingsSearchQuery('zzzz-no-such-setting');
fixture.detectChanges();
expect(query('[data-test-id="settings-search-empty"]')).not.toBeNull();
expect(query('.settings-search-result')).toBeNull();
});
it('publishes match counts per section for the navigation', () => {
setSettingsSearchQuery('dark');
fixture.detectChanges();
expect(settingsCtx.matchCounts()).toEqual(
expect.objectContaining({ general: 1 })
);
setSettingsSearchQuery('');
fixture.detectChanges();
expect(settingsCtx.matchCounts()).toBeNull();
});
it('reveals the chosen result through the settings search service', () => {
const reveal = jest.spyOn(settingsSearch, 'reveal');
setSettingsSearchQuery('theme');
fixture.detectChanges();
(
query(
'[data-test-id="settings-search-result-theme"]'
) as HTMLButtonElement
).click();
expect(reveal).toHaveBeenCalledWith(entry('theme'));
expect(
(TestBed.inject(Router) as unknown as MockRouter).navigate
).toHaveBeenCalledWith(['/workspace/settings', 'general']);
});
it('scrolls to, focuses and briefly highlights a revealed row', () => {
jest.useFakeTimers();
settingsSearch.reveal(entry('show-captions'));
fixture.detectChanges();
const row = query('[data-setting-id="show-captions"]') as HTMLElement;
expect(scrollIntoView).toHaveBeenCalled();
expect(scrollIntoView.mock.contexts[0]).toBe(row);
expect(row.classList).toContain(SETTINGS_REVEALED_CLASS);
expect(document.activeElement).toBe(row);
expect(settingsSearch.pendingReveal()).toBeNull();
jest.runOnlyPendingTimers();
expect(row.classList).not.toContain(SETTINGS_REVEALED_CLASS);
});
it('waits for the section page before revealing a row on it', () => {
settingsSearch.reveal(entry('epg-offset'));
fixture.detectChanges();
expect(scrollIntoView).not.toHaveBeenCalled();
expect(settingsSearch.pendingReveal()?.id).toBe('epg-offset');
setSettingsSection('epg');
fixture.detectChanges();
expect(query('[data-setting-id="epg-offset"]')?.classList).toContain(
SETTINGS_REVEALED_CLASS
);
});
it('falls back to the controlling row when the searched row is hidden', () => {
// The API key row only renders while TMDB is enabled.
setSettingsSection('tmdb');
fixture.detectChanges();
expect(query('[data-setting-id="tmdb-api-key"]')).toBeNull();
settingsSearch.reveal(entry('tmdb-api-key'));
fixture.detectChanges();
expect(query('[data-setting-id="tmdb-enable"]')?.classList).toContain(
SETTINGS_REVEALED_CLASS
);
});
});