@@ -148,7 +157,7 @@
-
+
{#snippet after()}
{
@@ -202,9 +208,16 @@ export class InMemoryDemoApi implements IMiniLcmJsInvokable {
]);
}
+ #write = true;
+
+ /** Test-only: toggle the write feature exposed by {@link supportedFeatures}. */
+ setWrite(write: boolean) {
+ this.#write = write;
+ }
+
supportedFeatures() {
return Promise.resolve({
- write: true,
+ write: this.#write,
audio: true,
customViews: true,
} satisfies IMiniLcmFeatures);
diff --git a/frontend/viewer/src/project/project-context.svelte.ts b/frontend/viewer/src/project/project-context.svelte.ts
index fe551a409d..e90941b86e 100644
--- a/frontend/viewer/src/project/project-context.svelte.ts
+++ b/frontend/viewer/src/project/project-context.svelte.ts
@@ -98,6 +98,11 @@ export class ProjectContext {
public get features(): IMiniLcmFeatures {
return this.#features.current;
}
+
+ /** Re-fetch {@link features} from the API (e.g. after a test toggles demo write). */
+ public refetchFeatures(): Promise {
+ return this.#features.refetch();
+ }
public get historyService(): IHistoryServiceJsInvokable | undefined {
return this.#historyService;
}
diff --git a/frontend/viewer/tests/ui/browse-hotkeys.test.ts b/frontend/viewer/tests/ui/browse-hotkeys.test.ts
new file mode 100644
index 0000000000..74ecb292b6
--- /dev/null
+++ b/frontend/viewer/tests/ui/browse-hotkeys.test.ts
@@ -0,0 +1,92 @@
+import {expect, test, type Locator, type Page} from '@playwright/test';
+
+import {DemoProjectPage} from './demo-project.page';
+
+/** Demo project uses dictionary terminology ("New Word"); entry view would say "New Entry". */
+function newEntryDialog(page: Page): Locator {
+ return page.getByRole('dialog').filter({has: page.getByRole('heading', {name: /New (Entry|Word)/})});
+}
+
+test.describe('Browse hotkeys', () => {
+ let projectPage: DemoProjectPage;
+
+ test.beforeEach(async ({page}) => {
+ projectPage = new DemoProjectPage(page);
+ await projectPage.goto();
+ });
+
+ test.describe('New entry (Ctrl/Cmd+E)', () => {
+ test('Ctrl/Cmd+E opens the new entry dialog', async ({page}) => {
+ await page.keyboard.press('ControlOrMeta+e');
+ await expect(newEntryDialog(page)).toBeVisible();
+ });
+
+ test('Ctrl/Cmd+E works while the search input is focused', async ({page}) => {
+ await projectPage.entriesList.searchInput.click();
+ await expect(projectPage.entriesList.searchInput).toBeFocused();
+
+ await page.keyboard.press('ControlOrMeta+e');
+ await expect(newEntryDialog(page)).toBeVisible();
+ });
+
+ test('plain E does not open the new entry dialog', async ({page}) => {
+ await page.keyboard.press('e');
+ await expect(page.getByRole('dialog')).toHaveCount(0);
+ });
+
+ test('Ctrl/Cmd+E does not reset an already-open new entry dialog', async ({page}) => {
+ await page.keyboard.press('ControlOrMeta+e');
+
+ const dialog = newEntryDialog(page);
+ await expect(dialog).toBeVisible();
+
+ const lexemeInput = dialog.locator('[style*="grid-area: lexemeForm"] input').first();
+ await expect(lexemeInput).toBeVisible();
+ await lexemeInput.fill('hotkey-preserve');
+ await expect(lexemeInput).toHaveValue('hotkey-preserve');
+
+ await page.keyboard.press('ControlOrMeta+e');
+
+ await expect(page.getByRole('dialog')).toHaveCount(1);
+ await expect(lexemeInput).toHaveValue('hotkey-preserve');
+ });
+
+ test('Ctrl/Cmd+E does nothing when the project is read-only', async ({page}) => {
+ await page.evaluate(async () => {
+ await window.__PLAYWRIGHT_UTILS__.setWrite(false);
+ });
+ await expect(page.getByRole('button', {name: /New (Entry|Word)/})).toHaveCount(0);
+
+ await page.keyboard.press('ControlOrMeta+e');
+ await expect(page.getByRole('dialog')).toHaveCount(0);
+ });
+ });
+
+ test.describe('Search focus (Ctrl/Cmd+F)', () => {
+ test('Ctrl/Cmd+F focuses the Filter search input and selects existing text', async ({page}) => {
+ const filter = 'hotkey-select';
+ await projectPage.entriesList.searchInput.fill(filter);
+ await projectPage.entriesList.searchInput.blur();
+ await expect(projectPage.entriesList.searchInput).not.toBeFocused();
+
+ await page.keyboard.press('ControlOrMeta+f');
+
+ const searchInput = projectPage.entriesList.searchInput;
+ await expect(searchInput).toBeFocused();
+ await expect(searchInput).toHaveValue(filter);
+ await expect.poll(async () => searchInput.evaluate((el: HTMLInputElement) => ({
+ start: el.selectionStart,
+ end: el.selectionEnd,
+ length: el.value.length,
+ }))).toEqual({start: 0, end: filter.length, length: filter.length});
+ });
+
+ test('Ctrl/Cmd+F focuses search after selecting an entry', async ({page}) => {
+ await projectPage.entriesList.selectEntryByIndex(0);
+ await expect(projectPage.entriesList.searchInput).not.toBeFocused();
+
+ await page.keyboard.press('ControlOrMeta+f');
+ await expect(projectPage.entriesList.searchInput).toBeFocused();
+ });
+ });
+});
diff --git a/frontend/viewer/tests/ui/test.d.ts b/frontend/viewer/tests/ui/test.d.ts
index fb511c51c7..8f812d8d4c 100644
--- a/frontend/viewer/tests/ui/test.d.ts
+++ b/frontend/viewer/tests/ui/test.d.ts
@@ -5,6 +5,10 @@ export { }; // for some reason this is required in order to make global changes
declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/naming-convention
- __PLAYWRIGHT_UTILS__: {demoApi: IMiniLcmJsInvokable}
+ __PLAYWRIGHT_UTILS__: {
+ demoApi: IMiniLcmJsInvokable;
+ /** Toggle demo write feature and refetch project features. */
+ setWrite: (write: boolean) => Promise;
+ };
}
}