mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
200 lines
6.8 KiB
TypeScript
200 lines
6.8 KiB
TypeScript
import type { Locator, Page } from '@playwright/test';
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { expect, test } from './fixtures';
|
|
|
|
/**
|
|
* The audit found the unsaved-changes actions wrapping in most locales. These
|
|
* are the long-label locales it measured, plus Arabic for the RTL row.
|
|
*/
|
|
const LOCALES = ['en', 'de', 'ru', 'fr', 'hu', 'ar'] as const;
|
|
type Locale = (typeof LOCALES)[number];
|
|
|
|
const I18N_DIR = join(__dirname, '../../web/src/assets/i18n');
|
|
|
|
interface UnsavedDialogLabels {
|
|
CANCEL: string;
|
|
SETTINGS: {
|
|
UNSAVED_DIALOG_DISCARD: string;
|
|
UNSAVED_DIALOG_SAVE: string;
|
|
};
|
|
}
|
|
|
|
/** The shipped labels, in DOM order: dismiss, discard, primary save. */
|
|
function expectedLabels(locale: Locale): string[] {
|
|
const labels = JSON.parse(
|
|
readFileSync(join(I18N_DIR, `${locale}.json`), 'utf8')
|
|
) as UnsavedDialogLabels;
|
|
return [
|
|
labels.CANCEL,
|
|
labels.SETTINGS.UNSAVED_DIALOG_DISCARD,
|
|
labels.SETTINGS.UNSAVED_DIALOG_SAVE,
|
|
];
|
|
}
|
|
|
|
/** Below the 640px phone breakpoint the row stacks by design. */
|
|
const PHONE_VIEWPORT = { width: 360, height: 740 };
|
|
|
|
/**
|
|
* Layout boxes relative to the actions row (its `position: relative` makes it
|
|
* the buttons' offset parent). Offsets ignore the dialog's scale-in transform,
|
|
* which `getBoundingClientRect` would include mid-animation.
|
|
*/
|
|
interface ActionBox {
|
|
testId: string | null;
|
|
top: number;
|
|
bottom: number;
|
|
left: number;
|
|
right: number;
|
|
width: number;
|
|
/** Label broken over more than one line inside its button. */
|
|
labelLines: number;
|
|
/** Label wider than its button (clipped or spilling out). */
|
|
overflows: boolean;
|
|
}
|
|
|
|
interface ActionRow {
|
|
/** The row's content box, between its paddings. */
|
|
contentLeft: number;
|
|
contentRight: number;
|
|
buttons: ActionBox[];
|
|
}
|
|
|
|
async function openSettings(page: Page) {
|
|
await page.locator('a[href$="/workspace/settings"]').click();
|
|
await page.waitForURL(/\/workspace\/settings\/general$/);
|
|
await expect(page.locator('.settings-container')).toBeVisible();
|
|
}
|
|
|
|
async function useLanguage(page: Page, locale: Locale) {
|
|
if (locale === 'en') {
|
|
return;
|
|
}
|
|
await page.locator('[data-test-id="select-language"]').click();
|
|
await page.locator(`mat-option[data-test-id="${locale}"]`).click();
|
|
|
|
const saveButton = page.locator('[data-test-id="save-settings"]');
|
|
await saveButton.click();
|
|
await expect(saveButton).toBeHidden();
|
|
}
|
|
|
|
/**
|
|
* Stages an edit, then leaves the settings area so the guard asks. Waiting for
|
|
* the locale's own labels proves the translation loaded before measuring.
|
|
*/
|
|
async function openUnsavedDialog(page: Page, locale: Locale): Promise<Locator> {
|
|
await page
|
|
.locator('[data-test-id="select-theme"] [data-test-id="DARK_THEME"]')
|
|
.click();
|
|
await expect(
|
|
page.locator('[data-test-id="settings-unsaved-bar"]')
|
|
).toBeVisible();
|
|
|
|
// The rail's Dashboard link stays visible in the phone top bar.
|
|
await page
|
|
.getByRole('navigation')
|
|
.locator('a[href$="/workspace/dashboard"]')
|
|
.click();
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(dialog).toBeVisible();
|
|
await expect(dialog.locator('mat-dialog-actions button')).toHaveText(
|
|
expectedLabels(locale)
|
|
);
|
|
return dialog;
|
|
}
|
|
|
|
async function measureActions(dialog: Locator): Promise<ActionRow> {
|
|
return dialog.locator('mat-dialog-actions').evaluate((row) => {
|
|
const rowStyle = getComputedStyle(row);
|
|
const buttons = Array.from(row.querySelectorAll('button')).map(
|
|
(button) => {
|
|
const label =
|
|
button.querySelector('.mdc-button__label') ?? button;
|
|
return {
|
|
testId: button.getAttribute('data-test-id'),
|
|
top: button.offsetTop,
|
|
bottom: button.offsetTop + button.offsetHeight,
|
|
left: button.offsetLeft,
|
|
right: button.offsetLeft + button.offsetWidth,
|
|
width: button.offsetWidth,
|
|
labelLines: label.getClientRects().length,
|
|
overflows: button.scrollWidth > button.clientWidth + 1,
|
|
};
|
|
}
|
|
);
|
|
return {
|
|
contentLeft: parseFloat(rowStyle.paddingLeft),
|
|
contentRight:
|
|
(row as HTMLElement).clientWidth -
|
|
parseFloat(rowStyle.paddingRight),
|
|
buttons,
|
|
};
|
|
});
|
|
}
|
|
|
|
function expectReadableLabels(row: ActionRow) {
|
|
expect(row.buttons.map((button) => button.testId)).toEqual([
|
|
'unsaved-dialog-stay',
|
|
'unsaved-dialog-discard',
|
|
'unsaved-dialog-save',
|
|
]);
|
|
for (const button of row.buttons) {
|
|
expect(button.labelLines, `${button.testId} label wraps`).toBe(1);
|
|
expect(button.overflows, `${button.testId} label is cut`).toBe(false);
|
|
expect(button.left).toBeGreaterThanOrEqual(row.contentLeft - 1);
|
|
expect(button.right).toBeLessThanOrEqual(row.contentRight + 1);
|
|
}
|
|
}
|
|
|
|
test.describe('Settings unsaved-changes dialog layout', () => {
|
|
for (const locale of LOCALES) {
|
|
test(`@settings @web keeps the actions on one row (${locale})`, async ({
|
|
page,
|
|
}) => {
|
|
await page.goto('/');
|
|
await openSettings(page);
|
|
await useLanguage(page, locale);
|
|
const dialog = await openUnsavedDialog(page, locale);
|
|
|
|
const row = await measureActions(dialog);
|
|
expectReadableLabels(row);
|
|
const [first] = row.buttons;
|
|
for (const button of row.buttons) {
|
|
expect(
|
|
Math.abs(button.top - first.top),
|
|
`${button.testId} left the row`
|
|
).toBeLessThanOrEqual(1);
|
|
}
|
|
});
|
|
}
|
|
|
|
for (const locale of ['de', 'ru'] as const) {
|
|
test(`@settings @web stacks the actions full width on a phone (${locale})`, async ({
|
|
page,
|
|
}) => {
|
|
await page.goto('/');
|
|
await openSettings(page);
|
|
await useLanguage(page, locale);
|
|
await page.setViewportSize(PHONE_VIEWPORT);
|
|
const dialog = await openUnsavedDialog(page, locale);
|
|
|
|
const row = await measureActions(dialog);
|
|
expectReadableLabels(row);
|
|
// One full-width action per row, in DOM order: dismiss on top,
|
|
// the primary save at the bottom.
|
|
row.buttons.forEach((button, index) => {
|
|
expect(
|
|
Math.abs(
|
|
button.width - (row.contentRight - row.contentLeft)
|
|
)
|
|
).toBeLessThanOrEqual(1);
|
|
if (index > 0) {
|
|
expect(button.top).toBeGreaterThanOrEqual(
|
|
row.buttons[index - 1].bottom
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
});
|