diff --git a/apps/client/src/features/dictation/hooks/use-streaming-dictation.test.tsx b/apps/client/src/features/dictation/hooks/use-streaming-dictation.test.tsx new file mode 100644 index 00000000..48f7fb25 --- /dev/null +++ b/apps/client/src/features/dictation/hooks/use-streaming-dictation.test.tsx @@ -0,0 +1,206 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +// Shared, hoisted test state the module mocks write into. `onSpeechEnd` is the +// VAD callback the hook registers on MicVAD.new — capturing it lets us drive +// "a speech segment ended" deterministically. `pending` collects the deferred +// transcription promises so the test controls their resolution order, which is +// the whole point: out-of-order HTTP responses must NOT scramble the emitted +// text (the in-order emitter under test). +const h = vi.hoisted(() => { + return { + onSpeechEnd: null as null | ((audio: Float32Array) => void), + pending: [] as { resolve: (s: string) => void; reject: (e: unknown) => void }[], + notify: null as null | ReturnType, + }; +}); + +// Lazy-imported VAD: capture the onSpeechEnd handler and hand back a no-op +// instance (start/pause/destroy all resolve). +vi.mock("@ricky0123/vad-web", () => ({ + MicVAD: { + new: vi.fn(async (opts: { onSpeechEnd: (a: Float32Array) => void }) => { + h.onSpeechEnd = opts.onSpeechEnd; + return { + start: vi.fn(async () => {}), + pause: vi.fn(async () => {}), + destroy: vi.fn(async () => {}), + }; + }), + }, +})); + +// Each transcribeAudio call returns a promise we resolve/reject by index. +vi.mock("@/features/dictation/services/dictation-service", () => ({ + transcribeAudio: vi.fn( + () => + new Promise((resolve, reject) => { + h.pending.push({ resolve, reject }); + }), + ), +})); + +// Avoid real WAV encoding; the segment payload is irrelevant to ordering. +vi.mock("@/features/dictation/utils/encode-wav", () => ({ + encodeWavPcm16: vi.fn(() => new Blob()), +})); + +const notifyShow = vi.fn(); +vi.mock("@mantine/notifications", () => ({ + notifications: { show: (...args: unknown[]) => notifyShow(...args) }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (s: string) => s }), +})); + +import { useStreamingDictation } from "./use-streaming-dictation"; + +// jsdom has no AudioContext; the hook constructs one and calls resume(). A +// trivial stub is enough — the real audio path is irrelevant to ordering. +class FakeAudioContext { + state = "running"; + resume() { + return Promise.resolve(); + } + close() { + this.state = "closed"; + return Promise.resolve(); + } +} + +async function startRecording(onText: (t: string) => void) { + const hook = renderHook(() => useStreamingDictation({ onText })); + await act(async () => { + await hook.result.current.start(); + }); + // The VAD registered its onSpeechEnd and start() resolved into "recording". + expect(h.onSpeechEnd).toBeTypeOf("function"); + expect(hook.result.current.status).toBe("recording"); + return hook; +} + +// Fire N ended speech segments (seq 0..N-1), each kicking off one transcription. +async function emitSegments(n: number) { + await act(async () => { + for (let i = 0; i < n; i++) h.onSpeechEnd!(new Float32Array(8)); + }); +} + +describe("useStreamingDictation — in-order segment emitter", () => { + beforeEach(() => { + vi.clearAllMocks(); + h.onSpeechEnd = null; + h.pending = []; + notifyShow.mockClear(); + (window as unknown as { AudioContext: unknown }).AudioContext = + FakeAudioContext; + }); + + it("emits transcriptions in segment order even when responses resolve out of order", async () => { + const emitted: string[] = []; + await startRecording((t) => emitted.push(t)); + await emitSegments(3); + expect(h.pending).toHaveLength(3); + + // Resolve seq 1 FIRST: it must be buffered, not emitted, because seq 0 is + // still outstanding (nextEmit == 0). + await act(async () => { + h.pending[1].resolve("second"); + }); + expect(emitted).toEqual([]); + + // Resolve seq 0: this unblocks the buffer and flushes 0 then 1 in order. + await act(async () => { + h.pending[0].resolve("first"); + }); + expect(emitted).toEqual(["first", "second"]); + + // seq 2 resolves last and flushes immediately (it is now next). + await act(async () => { + h.pending[2].resolve("third"); + }); + expect(emitted).toEqual(["first", "second", "third"]); + }); + + it("trims whitespace and drops empty/whitespace-only transcriptions while still advancing", async () => { + const emitted: string[] = []; + await startRecording((t) => emitted.push(t)); + await emitSegments(3); + + await act(async () => { + h.pending[0].resolve(" hello "); // leading/trailing space trimmed + h.pending[1].resolve(" "); // whitespace-only -> not emitted, but seq advances + h.pending[2].resolve("world"); + }); + + expect(emitted).toEqual(["hello", "world"]); + }); + + it("a failed segment shows one notification and is skipped so later segments still flush in order", async () => { + const emitted: string[] = []; + await startRecording((t) => emitted.push(t)); + await emitSegments(2); + + // seq 0 fails: the user sees a notification and the emitter advances past it. + await act(async () => { + h.pending[0].reject({ message: "boom" }); + }); + expect(notifyShow).toHaveBeenCalledTimes(1); + expect(emitted).toEqual([]); + + // seq 1 still flushes (it is now next), proving one failure did not stall. + await act(async () => { + h.pending[1].resolve("survivor"); + }); + expect(emitted).toEqual(["survivor"]); + }); + + it("an OUT-OF-ORDER failed segment is buffered as empty and skipped without stalling later text", async () => { + const emitted: string[] = []; + await startRecording((t) => emitted.push(t)); + await emitSegments(3); + + // seq 1 (NOT next-to-emit) fails first: it takes the else branch — an empty + // placeholder is buffered (resultsRef.set(seq, "")) so the emitter can later + // skip it. One notification, nothing emitted yet (seq 0 still gates). + await act(async () => { + h.pending[1].reject({ message: "boom" }); + }); + expect(notifyShow).toHaveBeenCalledTimes(1); + expect(emitted).toEqual([]); + + // seq 0 flushes; the drain then reaches the buffered empty seq 1 and SKIPS + // past it to seq 2. + await act(async () => { + h.pending[0].resolve("alpha"); + }); + expect(emitted).toEqual(["alpha"]); + + // seq 2 emits — proving the empty placeholder let the emitter advance past + // the failed seq 1. Without the else branch's placeholder the drain would + // stall at the missing seq 1 and "gamma" would never flush. + await act(async () => { + h.pending[2].resolve("gamma"); + }); + expect(emitted).toEqual(["alpha", "gamma"]); + }); + + it("ignores a transcription that resolves AFTER cancel() (stale epoch — no emit)", async () => { + const emitted: string[] = []; + const hook = await startRecording((t) => emitted.push(t)); + await emitSegments(1); + + // Hard discard the session: the in-flight request is now stale. + act(() => { + hook.result.current.cancel(); + }); + expect(hook.result.current.status).toBe("idle"); + + // Its late resolution must be dropped (no emit into the new/empty session). + await act(async () => { + h.pending[0].resolve("late"); + }); + expect(emitted).toEqual([]); + }); +}); diff --git a/apps/client/src/features/editor/components/link/internal-link-paste.test.ts b/apps/client/src/features/editor/components/link/internal-link-paste.test.ts new file mode 100644 index 00000000..f3d290f8 --- /dev/null +++ b/apps/client/src/features/editor/components/link/internal-link-paste.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock the page-service so importing the module under test does not pull in the +// axios/api-client chain. `createMentionAction` is wired to `getPageById`; the +// spy lets us assert that wiring without any network. `vi.hoisted` keeps the spy +// available inside the hoisted vi.mock factory. +const { getPageById } = vi.hoisted(() => ({ getPageById: vi.fn() })); +vi.mock("@/features/page/services/page-service.ts", () => ({ + getPageById, +})); + +// `uuid` v7 is used for the mention node id; pin only v7 so assertions are +// stable, keeping the rest (e.g. `validate`, used by extractPageSlugId) real. +vi.mock("uuid", async (importOriginal) => ({ + ...(await importOriginal()), + v7: () => "fixed-mention-uuid", +})); + +import { + handleInternalLink, + createMentionAction, +} from "./internal-link-paste"; + +// Minimal ProseMirror-ish EditorView fake. We record what handleInternalLink +// builds and dispatches without standing up a real schema/state. +function makeView() { + const tr = { + replaceWith: vi.fn(function (this: unknown) { + return tr; + }), + insertText: vi.fn(function (this: unknown) { + return tr; + }), + addMark: vi.fn(function (this: unknown) { + return tr; + }), + }; + const schema = { + nodes: { + mention: { + // Echo the attrs back so we can assert exactly what was created. + create: vi.fn((attrs: Record) => ({ + type: "mention", + attrs, + })), + }, + }, + marks: { + link: { + create: vi.fn((attrs: Record) => ({ + type: "link", + attrs, + })), + }, + }, + }; + const view = { + state: { schema, tr }, + dispatch: vi.fn(), + }; + return { view, tr, schema }; +} + +describe("handleInternalLink", () => { + beforeEach(() => vi.clearAllMocks()); + + it("does nothing when validateFn rejects the url (no resolve, no dispatch)", async () => { + const onResolveLink = vi.fn(); + const validateFn = vi.fn(() => false); + const { view } = makeView(); + + await handleInternalLink({ validateFn, onResolveLink })( + "any-url", + view as never, + 3, + "creator-1", + ); + + expect(validateFn).toHaveBeenCalledWith("any-url", view); + expect(onResolveLink).not.toHaveBeenCalled(); + expect(view.dispatch).not.toHaveBeenCalled(); + }); + + it("on resolve: inserts a mention node carrying the resolved page + anchor and dispatches replaceWith at pos", async () => { + const page = { + id: "page-id-99", + title: "My Page", + slugId: "slugABC", + }; + const onResolveLink = vi.fn().mockResolvedValue(page); + const { view, tr, schema } = makeView(); + + // extractPageSlugId("doc-slug-xyz789") -> "xyz789" (last hyphen segment). + await handleInternalLink({ validateFn: () => true, onResolveLink })( + "doc-slug-xyz789", + view as never, + 5, + "creator-7", + "anchor-42", + ); + + // The linked page id is the extracted slug-id, not the whole url. + expect(onResolveLink).toHaveBeenCalledWith("xyz789", "creator-7"); + expect(schema.nodes.mention.create).toHaveBeenCalledWith({ + id: "fixed-mention-uuid", + label: "My Page", + entityType: "page", + entityId: "page-id-99", + slugId: "slugABC", + creatorId: "creator-7", + anchorId: "anchor-42", + }); + expect(tr.replaceWith).toHaveBeenCalledWith(5, 5, { + type: "mention", + attrs: expect.objectContaining({ entityId: "page-id-99" }), + }); + expect(tr.insertText).not.toHaveBeenCalled(); + expect(view.dispatch).toHaveBeenCalledTimes(1); + expect(view.dispatch).toHaveBeenCalledWith(tr); + }); + + it("falls back to 'Untitled' label when the resolved page has no title", async () => { + const onResolveLink = vi + .fn() + .mockResolvedValue({ id: "p", title: "", slugId: "s" }); + const { view, schema } = makeView(); + + await handleInternalLink({ validateFn: () => true, onResolveLink })( + "abc-id1", + view as never, + 0, + "c", + ); + + expect(schema.nodes.mention.create).toHaveBeenCalledWith( + expect.objectContaining({ label: "Untitled" }), + ); + }); + + it("on reject: inserts the raw url as plain text with a link mark and dispatches", async () => { + const onResolveLink = vi.fn().mockRejectedValue(new Error("not found")); + const { view, tr, schema } = makeView(); + + await handleInternalLink({ validateFn: () => true, onResolveLink })( + "http://x/page-id2", + view as never, + 4, + "creator-1", + ); + + // No mention node on the failure path. + expect(schema.nodes.mention.create).not.toHaveBeenCalled(); + expect(tr.insertText).toHaveBeenCalledWith("http://x/page-id2", 4); + expect(schema.marks.link.create).toHaveBeenCalledWith({ + href: "http://x/page-id2", + }); + // Mark spans exactly the inserted url text: [pos, pos + url.length]. + expect(tr.addMark).toHaveBeenCalledWith(4, 4 + "http://x/page-id2".length, { + type: "link", + attrs: { href: "http://x/page-id2" }, + }); + expect(view.dispatch).toHaveBeenCalledTimes(1); + }); +}); + +describe("createMentionAction", () => { + beforeEach(() => vi.clearAllMocks()); + + it("resolves the link via getPageById and inserts the mention", async () => { + getPageById.mockResolvedValue({ + id: "real-page", + title: "Real", + slugId: "rslug", + }); + const { view, schema } = makeView(); + + await createMentionAction("ref-pageABC", view as never, 2, "creator-9"); + + expect(getPageById).toHaveBeenCalledWith({ pageId: "pageABC" }); + expect(schema.nodes.mention.create).toHaveBeenCalledWith( + expect.objectContaining({ entityId: "real-page", label: "Real" }), + ); + }); + + it("propagates a getPageById failure to the plain-link fallback", async () => { + getPageById.mockRejectedValue(new Error("404")); + const { view, tr } = makeView(); + + await createMentionAction("ref-pageABC", view as never, 1, "creator-9"); + + // Failure path: the url is inserted as text, not as a mention node. + expect(tr.insertText).toHaveBeenCalledWith("ref-pageABC", 1); + }); +});