test(#244): cover dictation ordered-emitter + internal-link paste (Phase 2 tail)
Backfill the two genuinely-uncovered infra-free units from the #244 Part B test backlog (the rest was already covered by #248/#257): - use-streaming-dictation: the in-order transcription emitter. Drives the real hook via renderHook with mocked VAD + deferred transcribeAudio so the test controls response order. Asserts out-of-order HTTP responses still emit text in segment order; whitespace trimmed and empty results dropped while the sequence advances; a failed segment shows one notification and is skipped so later segments still flush; a response resolving after cancel() is dropped (stale-epoch guard). - internal-link-paste (handleInternalLink / createMentionAction): validateFn reject → no resolve/dispatch; resolve → mention node with the resolved page + anchor dispatched via replaceWith at pos; "Untitled" fallback; reject → raw url inserted as text under a link mark; createMentionAction wiring to getPageById on success + failure. Test-only; no production code changed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof import("uuid")>()),
|
||||
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<string, unknown>) => ({
|
||||
type: "mention",
|
||||
attrs,
|
||||
})),
|
||||
},
|
||||
},
|
||||
marks: {
|
||||
link: {
|
||||
create: vi.fn((attrs: Record<string, unknown>) => ({
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user