Skip to content
Merged
236 changes: 231 additions & 5 deletions __TEST__/e2e/storage.spec.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
// Storage picker regression net (#410): saved projects are referenced by KEY
// STRING, not by storage.key(i) position — positional indices shift whenever
// any other module writes a key (the transcribe prefs do so on every toggle),
// which loaded the wrong entry or threw. Also: corrupted entries must not kill
// the click handler, and filenames must render as text, not markup.
// Storage picker regression net (#410) + the Recents storage model (#434):
// saved projects are referenced by KEY STRING, not by storage.key(i) position
// — positional indices shift whenever any other module writes a key (the
// transcribe prefs do so on every toggle), which loaded the wrong entry or
// threw. Corrupted entries must not kill the click handler, and filenames must
// render as text, not markup. Since #434, entries are keyed by stable ID with
// the display name in meta (legacy name-keyed entries migrate on first list
// render), rows can be renamed inline and deleted (two-step), and the list
// orders by last-updated.
import { test, expect } from '@playwright/test';

const seed = (page) => page.evaluate(() => {
Expand Down Expand Up @@ -59,6 +63,228 @@ test('a corrupted entry does not throw and the picker keeps working (#410)', asy
expect(await page.evaluate(() => document.querySelector('#hypertranscript').textContent)).toContain('ALPHA');
});

test('legacy name-keyed entries migrate to stable ID keys on first list render (#434)', async ({ page }) => {
await seed(page);
const r = await page.evaluate(() => ({
legacyKeys: Object.keys(localStorage).filter((k) => k.endsWith('.hyperaudio')),
docKeys: Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')),
names: [...document.querySelectorAll('.file-item')].map((a) => a.textContent).sort(),
}));
expect(r.legacyKeys).toEqual([]);
expect(r.docKeys.length).toBe(2);
expect(r.names).toEqual(['alpha', 'beta']);
});

test('rows order by last edit, falling back to creation date; migrated entries carry a date (#434)', async ({ page }) => {
await seed(page); // alpha + beta migrate with created = migration time
await page.evaluate(() => {
const entry = (name, meta) => JSON.stringify({
hypertranscript: '<article><section><p><span data-m="0" data-d="1">x </span></p></section></article>',
video: 'https://example.com/a.mp3', summary: 's', topics: [],
meta: Object.assign({ name }, meta),
});
const now = Date.now();
localStorage.setItem('hyperaudio:doc:t1', entry('edited-recently', { updated: now + 100000 }));
localStorage.setItem('hyperaudio:doc:t2', entry('created-recently', { created: now + 50000 })); // never edited
loadLocalStorageOptions();
});
const names = await page.evaluate(() =>
[...document.querySelectorAll('.file-item')].map((a) => a.textContent));
// migrated alpha/beta share a creation stamp → tie broken alphabetically
expect(names).toEqual(['edited-recently', 'created-recently', 'alpha', 'beta']);
});

test('rename via the row action edits the name in place; the key is untouched (#434)', async ({ page }) => {
await seed(page);
const keysBefore = await page.evaluate(() => Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).sort());
const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'alpha' }) });
await row.hover();
await row.locator('.recents-rename').click();
const input = page.locator('.recents-rename-input');
await input.fill('interview notes');
await input.press('Enter');
await expect(page.locator('.file-item', { hasText: 'interview notes' })).toHaveCount(1);
const after = await page.evaluate(() => ({
keys: Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).sort(),
names: Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:'))
.map((k) => JSON.parse(localStorage.getItem(k)).meta.name).sort(),
}));
expect(after.keys).toEqual(keysBefore);
expect(after.names).toEqual(['beta', 'interview notes']);
});

test('escape cancels a rename without changing the name (#434)', async ({ page }) => {
await seed(page);
const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'alpha' }) });
await row.hover();
await row.locator('.recents-rename').click();
const input = page.locator('.recents-rename-input');
await input.fill('should-not-stick');
await input.press('Escape');
const names = await page.evaluate(() =>
[...document.querySelectorAll('.file-item')].map((a) => a.textContent).sort());
expect(names).toEqual(['alpha', 'beta']);
});

test('delete is two-step and removes the entry (#434)', async ({ page }) => {
await seed(page);
const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'alpha' }) });
await row.hover();
const del = row.locator('.recents-delete');
await del.click();
// armed, not deleted
await expect(del).toHaveText('Delete?');
expect(await page.evaluate(() => Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).length)).toBe(2);
await del.click();
await expect(page.locator('.file-item', { hasText: 'alpha' })).toHaveCount(0);
await expect(page.locator('.file-item', { hasText: 'beta' })).toHaveCount(1);
expect(await page.evaluate(() => Object.keys(localStorage).filter((k) => k.startsWith('hyperaudio:doc:')).length)).toBe(1);
});

test('deleting the loaded entry offers Restore; restoring re-saves the on-screen doc (#434)', async ({ page }) => {
await seed(page);
await page.evaluate(() => {
[...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click();
});
await page.waitForTimeout(300);
const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) });
await row.hover();
const del = row.locator('.recents-delete');
await del.click();
await del.click(); // confirm
await expect(page.locator('.file-item', { hasText: 'beta' })).toHaveCount(0);
const notice = page.locator('#recents-notice');
await expect(notice).toContainText('no longer being saved');
// the transcript is still on screen
expect(await page.evaluate(() => document.querySelector('#hypertranscript').textContent)).toContain('BETA');
await notice.locator('.recents-notice-action').click();
await expect(notice).toHaveCount(0);
await expect(page.locator('.file-item', { hasText: 'beta' })).toHaveCount(1);
await expect(page.locator('.file-item.active')).toHaveText('beta');
const restored = await page.evaluate(() => {
const key = Object.keys(localStorage).find((k) =>
k.startsWith('hyperaudio:doc:') && JSON.parse(localStorage.getItem(k)).meta.name === 'beta');
return JSON.parse(localStorage.getItem(key));
});
expect(restored.hypertranscript).toContain('BETA');
});

test('a pending Restore is withdrawn when the screen holds a different document (#434)', async ({ page }) => {
await seed(page);
await page.evaluate(() => {
[...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click();
});
await page.waitForTimeout(300);
const row = page.locator('.recents-row', { has: page.locator('.file-item', { hasText: 'beta' }) });
await row.hover();
const del = row.locator('.recents-delete');
await del.click();
await del.click();
await expect(page.locator('#recents-notice')).toContainText('no longer being saved');
// loading another entry invalidates restore-from-screen
await page.evaluate(() => {
[...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'alpha').click();
});
await expect(page.locator('#recents-notice')).toHaveCount(0);
});

test('clicking a row loads it and marks it active; the highlight survives a re-render (#434)', async ({ page }) => {
await seed(page);
await page.evaluate(() => {
[...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click();
});
await page.waitForTimeout(300);
await expect(page.locator('.file-item.active')).toHaveText('beta');
await page.evaluate(() => loadLocalStorageOptions());
await expect(page.locator('.file-item.active')).toHaveText('beta');
});

test('a new transcription auto-saves to Recents named after its media; repeats get a suffix (#435)', async ({ page }) => {
await seed(page);
const transcribe = () => page.evaluate(() => {
document.querySelector('#hyperplayer').src = 'https://example.com/media/clip.mp4';
document.querySelector('#hypertranscript').innerHTML =
'<article><section><p><span data-m="0" data-d="500">FRESH </span></p></section></article>';
document.dispatchEvent(new CustomEvent('hyperaudioInit'));
});
await transcribe();
await expect(page.locator('.file-item', { hasText: 'clip.mp4' })).toHaveCount(1);
// the new entry is active and sits first (newest updated)
await expect(page.locator('.file-item.active')).toHaveText('clip.mp4');
expect(await page.evaluate(() => document.querySelector('.file-item').textContent)).toBe('clip.mp4');
// a second transcription of the same media coexists rather than overwriting
await transcribe();
const names = await page.evaluate(() =>
[...document.querySelectorAll('.file-item')].map((a) => a.textContent));
expect(names).toContain('clip.mp4');
expect(names).toContain('clip.mp4 (2)');
});

test('the autosave disclosure shows once ever, is info-toned, and dismisses (#435)', async ({ page }) => {
await seed(page); // clears localStorage, so the flag is unset
const transcribe = () => page.evaluate(() => {
document.querySelector('#hyperplayer').src = 'https://example.com/media/clip.mp4';
document.querySelector('#hypertranscript').innerHTML =
'<article><section><p><span data-m="0" data-d="500">X </span></p></section></article>';
document.dispatchEvent(new CustomEvent('hyperaudioInit'));
});
await transcribe();
const notice = page.locator('#recents-notice');
await expect(notice).toHaveClass(/notice-info/);
await expect(notice).toContainText('on this device only');
await notice.locator('.recents-notice-dismiss').click();
await expect(notice).toHaveCount(0);
await transcribe();
await expect(notice).toHaveCount(0); // never again
});

test('auto-add never captures the previous document\'s captions/summary/topics (#435)', async ({ page }) => {
await seed(page);
const saved = await page.evaluate(() => {
// the state the engines leave at hyperaudioInit time: the caption track,
// summary, and topics still belong to the PREVIOUS document (the intro
// demo on a fresh session) — regeneration happens after the event
document.getElementById('hyperplayer-vtt').src =
'data:text/vtt;charset=utf-8,' + encodeURIComponent('WEBVTT\n\n00:00.000 --> 00:01.000\nSTALE DEMO CUE');
document.getElementById('summary').innerHTML = 'stale demo summary';
document.getElementById('topics').innerHTML = 'stale, demo, topics';
document.querySelector('#hyperplayer').src = 'https://example.com/media/clip.mp4';
document.querySelector('#hypertranscript').innerHTML =
'<article><section><p><span data-m="0" data-d="500">FRESH </span></p></section></article>';
document.dispatchEvent(new CustomEvent('hyperaudioInit'));
const key = Object.keys(localStorage).find((k) =>
k.startsWith('hyperaudio:doc:') && JSON.parse(localStorage.getItem(k)).meta.name === 'clip.mp4');
return JSON.parse(localStorage.getItem(key));
});
expect(saved.captions).toBeUndefined(); // load regenerates from the transcript instead
expect(saved.summary).toBe('');
expect(saved.topics).toEqual([]);
expect(saved.hypertranscript).toContain('FRESH');
});

test('edits autosave (debounced) to the active entry and bump its updated stamp (#435)', async ({ page }) => {
await seed(page);
await page.evaluate(() => {
[...document.querySelectorAll('.file-item')].find((a) => a.textContent === 'beta').click();
});
await page.waitForTimeout(300);
const before = await page.evaluate(() => {
const key = Object.keys(localStorage).find((k) =>
k.startsWith('hyperaudio:doc:') && JSON.parse(localStorage.getItem(k)).meta.name === 'beta');
return { key, updated: JSON.parse(localStorage.getItem(key)).meta.updated || 0 };
});
await page.evaluate(() => {
const span = document.querySelector('#hypertranscript span[data-m]');
span.textContent = 'EDITED ';
span.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.waitForTimeout(2600); // past the 2s debounce
const after = await page.evaluate((key) => JSON.parse(localStorage.getItem(key)), before.key);
expect(after.hypertranscript).toContain('EDITED');
expect(after.meta.name).toBe('beta'); // autosave keeps the name
expect(after.meta.updated).toBeGreaterThan(before.updated);
});

test('a filename containing markup renders as text (#410)', async ({ page }) => {
await seed(page);
await page.evaluate(() => {
Expand Down
149 changes: 149 additions & 0 deletions __TEST__/unit/storage.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// Unit tests for the Recents storage model (#434): entries keyed by stable ID
// with the display name in meta, legacy name-keyed entries migrated in place,
// name collisions suffixed instead of overwriting, and the list ordered by
// last-updated. All helpers run against a fake Storage so no DOM is needed.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const {
isDocKey,
isLegacyKey,
entryName,
entryMediaKey,
uniqueEntryName,
listDocEntries,
migrateLegacyEntries,
renameTranscriptEntry,
deleteTranscriptEntry,
mediaNameFromRef,
} = require('../../js/hyperaudio-lite-editor-storage.js');

function fakeStorage(init = {}) {
const map = new Map(Object.entries(init));
return {
get length() { return map.size; },
key: (i) => [...map.keys()][i] ?? null,
getItem: (k) => (map.has(k) ? map.get(k) : null),
setItem: (k, v) => { map.set(k, String(v)); },
removeItem: (k) => { map.delete(k); },
};
}

const entry = (fields = {}) => JSON.stringify(Object.assign({
hypertranscript: '<article><section><p><span data-m="0" data-d="1">x </span></p></section></article>',
video: 'https://example.com/a.mp3',
summary: 's',
topics: [],
}, fields));

test('key classification: doc keys vs legacy keys vs unrelated keys', () => {
assert.ok(isDocKey('hyperaudio:doc:abc123'));
assert.ok(!isDocKey('alpha.hyperaudio'));
assert.ok(isLegacyKey('alpha.hyperaudio'));
assert.ok(!isLegacyKey('hyperaudio:doc:abc123'));
assert.ok(!isLegacyKey('hyperaudioTranscribePrefs'));
assert.ok(!isLegacyKey('.hyperaudio')); // name part must be non-empty (indexOf > 0)
});

test('migration: legacy entry moves to an ID key, name and mediaKey preserved', () => {
const s = fakeStorage({ 'interview.hyperaudio': entry() });
migrateLegacyEntries(s);
assert.equal(s.getItem('interview.hyperaudio'), null);
const rows = listDocEntries(s);
assert.equal(rows.length, 1);
assert.ok(isDocKey(rows[0].key));
const migrated = JSON.parse(s.getItem(rows[0].key));
assert.equal(migrated.meta.name, 'interview');
assert.equal(migrated.meta.mediaKey, 'interview'); // media stays under its legacy key
assert.ok(migrated.meta.created > 0); // stamped so the entry sorts by date from now on
assert.equal(migrated.hypertranscript.includes('data-m'), true);
});

test('migration: an unparseable legacy entry stays on its legacy key and is still listed', () => {
const s = fakeStorage({ 'broken.hyperaudio': '{not json', 'ok.hyperaudio': entry() });
migrateLegacyEntries(s);
assert.equal(s.getItem('broken.hyperaudio'), '{not json');
const names = listDocEntries(s).map((r) => r.name).sort();
assert.deepEqual(names, ['broken', 'ok']);
});

test('migration is idempotent', () => {
const s = fakeStorage({ 'a.hyperaudio': entry() });
migrateLegacyEntries(s);
const keysAfterFirst = listDocEntries(s).map((r) => r.key);
migrateLegacyEntries(s);
assert.deepEqual(listDocEntries(s).map((r) => r.key), keysAfterFirst);
});

test('uniqueEntryName: suffixes instead of colliding, own key excluded', () => {
const s = fakeStorage({
'hyperaudio:doc:one': entry({ meta: { name: 'interview' } }),
'hyperaudio:doc:two': entry({ meta: { name: 'interview (2)' } }),
});
assert.equal(uniqueEntryName('fresh', s, null), 'fresh');
assert.equal(uniqueEntryName('interview', s, null), 'interview (3)');
// an entry keeping its own name is not a collision with itself
assert.equal(uniqueEntryName('interview', s, 'hyperaudio:doc:one'), 'interview');
});

test('listDocEntries: last-edited first, creation date stands in when never edited', () => {
const s = fakeStorage({
'hyperaudio:doc:a': entry({ meta: { name: 'older', updated: 1000 } }),
'hyperaudio:doc:b': entry({ meta: { name: 'newest', updated: 3000 } }),
'hyperaudio:doc:c': entry({ meta: { name: 'created-only', created: 2000 } }), // never edited → creation date
'hyperaudio:doc:d': entry({ meta: { name: 'zeta' } }), // no dates at all →
'hyperaudio:doc:e': entry({ meta: { name: 'alpha' } }), // bottom, alphabetical
});
assert.deepEqual(listDocEntries(s).map((r) => r.name),
['newest', 'created-only', 'older', 'alpha', 'zeta']);
});

test('rename: one-field update, de-duplicated, key untouched; empty name rejected', () => {
const s = fakeStorage({
'hyperaudio:doc:one': entry({ meta: { name: 'a', mediaKey: 'm1', updated: 42 } }),
'hyperaudio:doc:two': entry({ meta: { name: 'taken' } }),
});
assert.equal(renameTranscriptEntry('hyperaudio:doc:one', 'fresh', s), true);
let e = JSON.parse(s.getItem('hyperaudio:doc:one'));
assert.equal(e.meta.name, 'fresh');
assert.equal(e.meta.mediaKey, 'm1'); // media key survives the rename
assert.equal(e.meta.updated, 42); // renaming must not reorder the list

assert.equal(renameTranscriptEntry('hyperaudio:doc:one', 'taken', s), true);
e = JSON.parse(s.getItem('hyperaudio:doc:one'));
assert.equal(e.meta.name, 'taken (2)');

assert.equal(renameTranscriptEntry('hyperaudio:doc:one', ' ', s), false);
assert.equal(renameTranscriptEntry('hyperaudio:doc:missing', 'x', s), false);
});

test('delete: removes the entry, including an unparseable legacy one', () => {
const s = fakeStorage({
'hyperaudio:doc:one': entry({ meta: { name: 'a', mediaKey: 'm1' } }),
'broken.hyperaudio': '{not json',
});
deleteTranscriptEntry('hyperaudio:doc:one', s);
assert.equal(s.getItem('hyperaudio:doc:one'), null);
deleteTranscriptEntry('broken.hyperaudio', s);
assert.equal(s.getItem('broken.hyperaudio'), null);
assert.equal(listDocEntries(s).length, 0);
});

test('mediaNameFromRef: URL basename (decoded), plain filename passthrough, Untitled fallback (#435)', () => {
assert.equal(mediaNameFromRef('https://example.com/media/clip.mp4'), 'clip.mp4');
assert.equal(mediaNameFromRef('https://example.com/media/My%20Interview.mp3?token=1#t=10'), 'My Interview.mp3');
assert.equal(mediaNameFromRef('https://example.com/'), 'Untitled'); // no basename in the path
assert.equal(mediaNameFromRef('interview.mp4'), 'interview.mp4'); // a local file's real name
assert.equal(mediaNameFromRef(''), 'Untitled');
assert.equal(mediaNameFromRef(null), 'Untitled');
});

test('entryName / entryMediaKey fall back sensibly for malformed entries', () => {
assert.equal(entryName('alpha.hyperaudio', null), 'alpha');
assert.equal(entryName('hyperaudio:doc:x', null), 'Untitled');
assert.equal(entryMediaKey('alpha.hyperaudio', null), 'alpha');
assert.equal(entryMediaKey('hyperaudio:doc:x', null), null);
assert.equal(entryMediaKey('hyperaudio:doc:x', { meta: { mediaKey: 'm' } }), 'm');
});
Loading
Loading