Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions __TEST__/e2e/a11y.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -79,20 +79,21 @@ test('transcribe modal (Local and Cloud tabs) has no #402-class violations', asy

test('modal label-buttons are keyboard-operable; toggles are out of the tab order', async ({ page }) => {
const r = await page.evaluate(() => {
const infoBtn = document.getElementById('info-btn');
const toggle = document.getElementById('info-modal');
const gapsBtn = document.getElementById('remove-gaps-btn');
const toggle = document.getElementById('remove-gaps-modal');
return {
btnTabbable: infoBtn.tabIndex === 0,
btnWired: infoBtn.dataset.a11yWired === '1',
btnTabbable: gapsBtn.tabIndex === 0,
btnWired: gapsBtn.dataset.a11yWired === '1',
toggleHidden: toggle.getAttribute('aria-hidden') === 'true',
toggleUntabbable: toggle.tabIndex === -1,
};
});
expect(r).toEqual({ btnTabbable: true, btnWired: true, toggleHidden: true, toggleUntabbable: true });

// Enter on the focused label-button opens the modal (was impossible before —
// labels aren't natively keyboard-activatable)
await page.focus('#info-btn');
// labels aren't natively keyboard-activatable). The info button, the
// previous example here, moved into the project kebab menu (#456).
await page.focus('#remove-gaps-btn');
await page.keyboard.press('Enter');
expect(await page.evaluate(() => document.getElementById('info-modal').checked)).toBe(true);
expect(await page.evaluate(() => document.getElementById('remove-gaps-modal').checked)).toBe(true);
});
13 changes: 13 additions & 0 deletions __TEST__/e2e/helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,16 @@ export const ISSUE_371_WORDS = [
[30560, 80], [30800, 400], [31200, 80], [31360, 80], [31520, 80], [31680, 80], [31920, 80],
[32160, 80], [32400, 80], [32640, 80], [32800, 80], [33120, 720],
];

// Await an ASYNC in-page condition by polling page.evaluate (which properly
// awaits async functions). page.waitForFunction must NOT be given an async
// predicate: it treats the returned pending Promise as truthy and resolves
// immediately — a whole class of #456 test races traced back to that.
export async function pollPage(page, fn, arg, { timeout = 10000, interval = 100 } = {}) {
const deadline = Date.now() + timeout;
for (;;) {
if (await page.evaluate(fn, arg)) return;
if (Date.now() > deadline) throw new Error('pollPage: condition not met within ' + timeout + 'ms');
await page.waitForTimeout(interval);
}
}
350 changes: 350 additions & 0 deletions __TEST__/e2e/library.spec.mjs

Large diffs are not rendered by default.

315 changes: 232 additions & 83 deletions __TEST__/e2e/project-save.spec.mjs

Large diffs are not rendered by default.

46 changes: 46 additions & 0 deletions __TEST__/unit/hyperaudio-save.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -342,3 +342,49 @@ test('the writer sanitizes hostile media entry names with the shared rule (§ 10
assert.ok(zip.file('media/.._evil.wav') !== null); // separator neutralized, ".." substring kept
assert.equal(zip.file('media/../evil.wav'), null);
});

/* ---- Library index rules (#456) — pure layer of the project library ---- */

test('library entries sort by last edit, created date the fallback (#456)', () => {
const sorted = save.sortLibraryEntries([
{ id: 'a', modifiedAt: 100 },
{ id: 'b', modifiedAt: 300 },
{ id: 'c', createdAt: 200 }, // never written: created decides
{ id: 'd', modifiedAt: 0, createdAt: 400 }, // modifiedAt 0 falls back too
]);
assert.deepEqual(sorted.map((e) => e.id), ['d', 'b', 'c', 'a']);
});

test('sortLibraryEntries does not mutate its input', () => {
const entries = [{ id: 'a', modifiedAt: 1 }, { id: 'b', modifiedAt: 2 }];
save.sortLibraryEntries(entries);
assert.deepEqual(entries.map((e) => e.id), ['a', 'b']);
});

test('per-project dirty: a draft newer than the last manual Save (#456)', () => {
assert.equal(save.isEntryDirty({ lastDraftAt: 2, lastSavedAt: 1 }), true);
assert.equal(save.isEntryDirty({ lastDraftAt: 1, lastSavedAt: 1 }), false);
assert.equal(save.isEntryDirty({ lastDraftAt: 0, lastSavedAt: 2 }), false); // freshly saved
assert.equal(save.isEntryDirty({ lastDraftAt: 5 }), true); // never saved (fresh transcription)
assert.equal(save.isEntryDirty({}), false); // nothing written yet
});

test('project ids are unique and safe as OPFS directory names (#456)', () => {
const ids = new Set();
for (let i = 0; i < 100; i++) ids.add(save.newProjectId());
assert.equal(ids.size, 100);
for (const id of ids) assert.match(id, /^[A-Za-z0-9-]+$/);
});

test('gather-side class sanitizer keeps the speaker class, strips pollution (#456)', () => {
const html = '<p><span data-m="320" data-d="0" class="speaker">[Maria] </span>'
+ '<span data-m="320" data-d="520" class="active read">Benvenuti </span>'
+ '<span data-m="1100" data-d="400" class="read speaker-adjacent">a </span></p>';
const out = save.sanitizeTranscriptClasses(html);
assert.ok(out.includes('class="speaker"')); // semantic class survives…
assert.ok(!out.includes('active')); // …playback classes go
assert.ok(!out.includes('speaker-adjacent')); // substring must not fake a match
// a polluted speaker span ("speaker read") collapses to exactly class="speaker"
const mixed = save.sanitizeTranscriptClasses('<span data-m="0" class="speaker read">[A] </span>');
assert.equal(mixed, '<span data-m="0" class="speaker">[A] </span>');
});
Loading
Loading