feat(phase0): courses, migrations, persisted audio, stable segment IDs, course-aware UI
Data layer (Zod-gated, behind StorageRepo; web Dexie v2 + native expo-sqlite user_version migrations, both with no-data-loss segment-id backfill): - Course entity + courseId/lectureDate/instructor/location/tags/lectureNumber on transcripts; listCourses/createCourse/updateCourse/deleteCourse, listByCourse, reassign. Stable per-segment ids assigned on create/update + backfilled. - Persisted source audio (Dexie blob / expo-file-system) via putMedia/getMediaUrl; the editor replays it after reload. UI/stores: - Pre-capture sheet (title + course + lecture date + language) before transcribing. - Course-aware library home (All/Unsorted/per-course filter) + Courses screen. - coursesStore; transcriptsStore course filter + reassign; transcribeStore threads course/date and persists media. 16 web-repo tests incl. v1->v2 migration no-data-loss; 148 total green; web export OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+201
-27
@@ -4,11 +4,58 @@
|
||||
// under vitest. We test the Dexie repo as the reference implementation of the
|
||||
// StorageRepo contract; the native expo-sqlite repo (native-only) is NOT
|
||||
// imported here because expo-sqlite cannot load outside a device/simulator.
|
||||
//
|
||||
// IMPORTANT: repo.web.ts is *not* statically imported. To exercise the v1->v2
|
||||
// migration we must first seed a v1-shaped DB with a raw Dexie connection, and
|
||||
// only THEN import repo.web (whose module-eval opens the DB at v2, triggering
|
||||
// the upgrade). The dynamically-imported repo is stashed in a module-level
|
||||
// binding so all subsequent tests reuse it.
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { repo } from './repo.web';
|
||||
import { parseDraft, type TranscriptDraft } from './schema';
|
||||
import Dexie from 'dexie';
|
||||
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
||||
import {
|
||||
parseDraft,
|
||||
type TranscriptDraft,
|
||||
type StorageRepo,
|
||||
} from './index';
|
||||
|
||||
// Populated by the migration beforeAll (which seeds v1 then imports repo.web).
|
||||
let repo: StorageRepo;
|
||||
|
||||
// The v1 record we seed: NO segment ids, NO courseId, but WITH a searchText
|
||||
// column (exactly the legacy shape the v1 repo persisted).
|
||||
const V1_ID = 't_legacy';
|
||||
const v1Row = {
|
||||
id: V1_ID,
|
||||
title: 'Legacy Lecture',
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
durationSec: 42.5,
|
||||
modelId: 'tiny.en' as const,
|
||||
segmentCount: 2,
|
||||
segments: [
|
||||
{ start: 0, end: 2, text: 'legacy hello' },
|
||||
{ start: 2, end: 5, text: 'legacy world', confidence: 0.8 },
|
||||
],
|
||||
searchText: 'legacy lecture\nlegacy hello\nlegacy world',
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
// 1) Seed a v1 DB with a raw Dexie connection (only the v1 store/schema).
|
||||
const v1 = new Dexie('wisp');
|
||||
v1.version(1).stores({ transcripts: 'id, createdAt' });
|
||||
await v1.open();
|
||||
await v1.table('transcripts').put(v1Row);
|
||||
v1.close();
|
||||
|
||||
// 2) Now import repo.web, which opens 'wisp' at v2 and runs the upgrade.
|
||||
const mod = await import('./repo.web');
|
||||
repo = mod.repo;
|
||||
|
||||
// Stub object-URL creation so getMediaUrl returns a string under Node.
|
||||
globalThis.URL.createObjectURL = () => 'blob:mock';
|
||||
});
|
||||
|
||||
// Helper: a minimal valid draft, overridable per test.
|
||||
function makeDraft(over: Partial<TranscriptDraft> = {}): TranscriptDraft {
|
||||
@@ -24,31 +71,76 @@ function makeDraft(over: Partial<TranscriptDraft> = {}): TranscriptDraft {
|
||||
};
|
||||
}
|
||||
|
||||
// Wipe storage between tests so ordering/search assertions are deterministic.
|
||||
beforeEach(async () => {
|
||||
const all = await repo.list();
|
||||
await Promise.all(all.map((m) => repo.remove(m.id)));
|
||||
describe('v1 -> v2 migration (no data loss)', () => {
|
||||
it('keeps the legacy transcript, backfills segment ids, leaves it Unsorted', async () => {
|
||||
const fetched = await repo.get(V1_ID);
|
||||
expect(fetched).toBeDefined();
|
||||
|
||||
// Identity-preserving fields survive untouched.
|
||||
expect(fetched!.title).toBe('Legacy Lecture');
|
||||
expect(fetched!.durationSec).toBe(42.5);
|
||||
|
||||
// Segment text/timing is identical to the v1 body...
|
||||
expect(fetched!.segments.map((s) => s.text)).toEqual([
|
||||
'legacy hello',
|
||||
'legacy world',
|
||||
]);
|
||||
expect(fetched!.segments.map((s) => ({ start: s.start, end: s.end }))).toEqual([
|
||||
{ start: 0, end: 2 },
|
||||
{ start: 2, end: 5 },
|
||||
]);
|
||||
expect(fetched!.segments[1]!.confidence).toBe(0.8);
|
||||
|
||||
// ...but every segment now carries a backfilled stable id.
|
||||
for (const s of fetched!.segments) {
|
||||
expect(s.id).toMatch(/^s_/);
|
||||
}
|
||||
|
||||
// No courseId was set by v1 => the row is "Unsorted" (null/undefined).
|
||||
expect(fetched!.courseId == null).toBe(true);
|
||||
|
||||
// And it shows up under the Unsorted bucket.
|
||||
const unsorted = await repo.listByCourse(null);
|
||||
expect(unsorted.map((m) => m.id)).toContain(V1_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StorageRepo (Dexie web impl)', () => {
|
||||
it('create -> get round-trips segments and metadata', async () => {
|
||||
// Wipe transcripts/courses/media between tests so ordering/search/course
|
||||
// assertions are deterministic. (Runs after the migration beforeAll.)
|
||||
beforeEach(async () => {
|
||||
const all = await repo.list();
|
||||
await Promise.all(all.map((m) => repo.remove(m.id)));
|
||||
const courses = await repo.listCourses();
|
||||
await Promise.all(courses.map((c) => repo.deleteCourse(c.id)));
|
||||
});
|
||||
|
||||
it('create -> get round-trips segments and metadata, assigns segment ids', async () => {
|
||||
const created = await repo.create(makeDraft());
|
||||
|
||||
expect(created.id).toMatch(/^t_/);
|
||||
expect(created.createdAt).toBeTypeOf('number');
|
||||
expect(created.updatedAt).toBe(created.createdAt);
|
||||
expect(created.segmentCount).toBe(2);
|
||||
// courseId defaults to null ("Unsorted") when absent.
|
||||
expect(created.courseId).toBeNull();
|
||||
expect(created.hasMedia).toBe(false);
|
||||
// Every segment got a stable id assigned on write.
|
||||
for (const s of created.segments) {
|
||||
expect(s.id).toMatch(/^s_/);
|
||||
}
|
||||
|
||||
const fetched = await repo.get(created.id);
|
||||
expect(fetched).toBeDefined();
|
||||
expect(fetched!.title).toBe('My Recording');
|
||||
expect(fetched!.durationSec).toBe(12.5);
|
||||
expect(fetched!.modelId).toBe('tiny.en');
|
||||
// Segments survive the round-trip exactly.
|
||||
expect(fetched!.segments).toEqual([
|
||||
// Text/timing survive the round-trip exactly.
|
||||
expect(fetched!.segments.map((s) => ({ start: s.start, end: s.end, text: s.text }))).toEqual([
|
||||
{ start: 0, end: 2, text: 'hello world' },
|
||||
{ start: 2, end: 5, text: 'second segment', confidence: 0.9 },
|
||||
{ start: 2, end: 5, text: 'second segment' },
|
||||
]);
|
||||
expect(fetched!.segments[1]!.confidence).toBe(0.9);
|
||||
});
|
||||
|
||||
it('get returns undefined for an unknown id', async () => {
|
||||
@@ -57,7 +149,6 @@ describe('StorageRepo (Dexie web impl)', () => {
|
||||
|
||||
it('list returns metadatas newest-first (without segments)', async () => {
|
||||
const a = await repo.create(makeDraft({ title: 'first' }));
|
||||
// Force distinct, increasing createdAt timestamps.
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
const b = await repo.create(makeDraft({ title: 'second' }));
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
@@ -65,12 +156,12 @@ describe('StorageRepo (Dexie web impl)', () => {
|
||||
|
||||
const metas = await repo.list();
|
||||
expect(metas.map((m) => m.id)).toEqual([c.id, b.id, a.id]);
|
||||
// Metas omit the segment body.
|
||||
expect((metas[0] as unknown as Record<string, unknown>).segments).toBeUndefined();
|
||||
expect(metas[0]!.segmentCount).toBe(2);
|
||||
});
|
||||
|
||||
it('update changes title + segments and re-derives searchText for search', async () => {
|
||||
it('update merges title/segments/courseId and re-ids segments + re-derives search', async () => {
|
||||
const course = await repo.createCourse({ name: 'Physics' });
|
||||
const created = await repo.create(
|
||||
makeDraft({ title: 'Old Title', segments: [{ start: 0, end: 1, text: 'apple' }] }),
|
||||
);
|
||||
@@ -79,6 +170,7 @@ describe('StorageRepo (Dexie web impl)', () => {
|
||||
|
||||
const updated = await repo.update(created.id, {
|
||||
title: 'Brand New Title',
|
||||
courseId: course.id,
|
||||
segments: [
|
||||
{ start: 0, end: 1, text: 'banana' },
|
||||
{ start: 1, end: 2, text: 'cherry' },
|
||||
@@ -86,16 +178,19 @@ describe('StorageRepo (Dexie web impl)', () => {
|
||||
});
|
||||
|
||||
expect(updated.title).toBe('Brand New Title');
|
||||
expect(updated.courseId).toBe(course.id);
|
||||
expect(updated.segmentCount).toBe(2);
|
||||
expect(updated.updatedAt).toBeGreaterThan(originalUpdatedAt);
|
||||
// New segments got ids.
|
||||
for (const s of updated.segments) {
|
||||
expect(s.id).toMatch(/^s_/);
|
||||
}
|
||||
|
||||
// Old text is no longer findable...
|
||||
expect(await repo.search('apple')).toHaveLength(0);
|
||||
// ...and the new text + new title are.
|
||||
const byText = await repo.search('cherry');
|
||||
expect(byText.map((m) => m.id)).toEqual([created.id]);
|
||||
const byTitle = await repo.search('brand new');
|
||||
expect(byTitle.map((m) => m.id)).toEqual([created.id]);
|
||||
expect((await repo.search('cherry')).map((m) => m.id)).toEqual([created.id]);
|
||||
expect((await repo.search('brand new')).map((m) => m.id)).toEqual([created.id]);
|
||||
});
|
||||
|
||||
it('update throws for an unknown id', async () => {
|
||||
@@ -110,24 +205,106 @@ describe('StorageRepo (Dexie web impl)', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// Title match, different case.
|
||||
expect((await repo.search('meeting')).map((m) => m.id)).toEqual([t.id]);
|
||||
// Segment-text match, different case.
|
||||
expect((await repo.search('budget')).map((m) => m.id)).toEqual([t.id]);
|
||||
// Non-match.
|
||||
expect(await repo.search('zzz-not-present')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('remove deletes a transcript', async () => {
|
||||
it('remove deletes a transcript (and its media)', async () => {
|
||||
const created = await repo.create(makeDraft());
|
||||
await repo.putMedia(created.id, { data: new ArrayBuffer(4), mime: 'audio/mpeg' });
|
||||
expect(await repo.get(created.id)).toBeDefined();
|
||||
|
||||
await repo.remove(created.id);
|
||||
expect(await repo.get(created.id)).toBeUndefined();
|
||||
expect(await repo.list()).toHaveLength(0);
|
||||
// Media is gone too: no playable URL remains.
|
||||
expect(await repo.getMediaUrl(created.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parseDraft rejects a NaN duration', () => {
|
||||
// --- courses -------------------------------------------------------------
|
||||
|
||||
it('listByCourse splits Unsorted vs a course', async () => {
|
||||
const course = await repo.createCourse({ name: 'Bio 101' });
|
||||
const inCourse = await repo.create(makeDraft({ title: 'lecture', courseId: course.id }));
|
||||
const unsorted = await repo.create(makeDraft({ title: 'loose note' }));
|
||||
|
||||
const courseRows = await repo.listByCourse(course.id);
|
||||
expect(courseRows.map((m) => m.id)).toEqual([inCourse.id]);
|
||||
|
||||
const unsortedRows = await repo.listByCourse(null);
|
||||
expect(unsortedRows.map((m) => m.id)).toEqual([unsorted.id]);
|
||||
});
|
||||
|
||||
it('createCourse / listCourses (alphabetical) / updateCourse', async () => {
|
||||
const zebra = await repo.createCourse({ name: 'Zebra Studies', code: 'ZOO-1' });
|
||||
const apple = await repo.createCourse({ name: 'Apple Theory' });
|
||||
|
||||
expect(apple.id).toMatch(/^c_/);
|
||||
expect(apple.createdAt).toBeTypeOf('number');
|
||||
|
||||
// Alphabetical by name.
|
||||
const listed = await repo.listCourses();
|
||||
expect(listed.map((c) => c.name)).toEqual(['Apple Theory', 'Zebra Studies']);
|
||||
|
||||
const renamed = await repo.updateCourse(zebra.id, { name: 'Aardvark Studies' });
|
||||
expect(renamed.name).toBe('Aardvark Studies');
|
||||
expect(renamed.code).toBe('ZOO-1'); // unspecified fields preserved
|
||||
expect(renamed.updatedAt).toBeGreaterThanOrEqual(zebra.updatedAt);
|
||||
|
||||
const relisted = await repo.listCourses();
|
||||
expect(relisted.map((c) => c.name)).toEqual(['Aardvark Studies', 'Apple Theory']);
|
||||
});
|
||||
|
||||
it('deleteCourse reassigns its transcripts to Unsorted, then removes the course', async () => {
|
||||
const course = await repo.createCourse({ name: 'Doomed' });
|
||||
const t = await repo.create(makeDraft({ title: 'orphan-to-be', courseId: course.id }));
|
||||
|
||||
await repo.deleteCourse(course.id);
|
||||
|
||||
// The course is gone.
|
||||
expect((await repo.listCourses()).map((c) => c.id)).not.toContain(course.id);
|
||||
// The transcript survives, now Unsorted.
|
||||
const fetched = await repo.get(t.id);
|
||||
expect(fetched).toBeDefined();
|
||||
expect(fetched!.courseId).toBeNull();
|
||||
expect((await repo.listByCourse(null)).map((m) => m.id)).toContain(t.id);
|
||||
});
|
||||
|
||||
it('reassign moves a transcript between courses and to Unsorted', async () => {
|
||||
const course = await repo.createCourse({ name: 'Chemistry' });
|
||||
const t = await repo.create(makeDraft());
|
||||
expect(t.courseId).toBeNull();
|
||||
|
||||
await repo.reassign(t.id, course.id);
|
||||
expect((await repo.listByCourse(course.id)).map((m) => m.id)).toEqual([t.id]);
|
||||
|
||||
await repo.reassign(t.id, null);
|
||||
expect((await repo.listByCourse(course.id))).toHaveLength(0);
|
||||
expect((await repo.listByCourse(null)).map((m) => m.id)).toContain(t.id);
|
||||
});
|
||||
|
||||
// --- media ---------------------------------------------------------------
|
||||
|
||||
it('putMedia flips hasMedia true and getMediaUrl returns a url; removeMedia flips it false', async () => {
|
||||
const t = await repo.create(makeDraft());
|
||||
expect(t.hasMedia).toBe(false);
|
||||
|
||||
await repo.putMedia(t.id, { data: new ArrayBuffer(8), mime: 'audio/mpeg' });
|
||||
|
||||
const afterPut = await repo.get(t.id);
|
||||
expect(afterPut!.hasMedia).toBe(true);
|
||||
expect(await repo.getMediaUrl(t.id)).toBe('blob:mock');
|
||||
|
||||
await repo.removeMedia(t.id);
|
||||
const afterRemove = await repo.get(t.id);
|
||||
expect(afterRemove!.hasMedia).toBe(false);
|
||||
expect(await repo.getMediaUrl(t.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- validation gatekeeping ---------------------------------------------
|
||||
|
||||
it('parseDraft rejects a NaN/Infinity duration', () => {
|
||||
expect(() => parseDraft(makeDraft({ durationSec: NaN }))).toThrow();
|
||||
expect(() => parseDraft(makeDraft({ durationSec: Infinity }))).toThrow();
|
||||
});
|
||||
@@ -142,9 +319,6 @@ describe('StorageRepo (Dexie web impl)', () => {
|
||||
});
|
||||
|
||||
it('create rejects an invalid draft (defense in depth)', async () => {
|
||||
// create() calls parseDraft internally, so bad input never persists.
|
||||
await expect(
|
||||
repo.create(makeDraft({ durationSec: -1 })),
|
||||
).rejects.toThrow();
|
||||
await expect(repo.create(makeDraft({ durationSec: -1 }))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user