Compare commits
6 Commits
feat/git-s
...
test/244-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42a1fa1d3a | ||
|
|
ef27b6d440 | ||
|
|
96b9ec11d6 | ||
|
|
f8d26420eb | ||
|
|
5c1187b864 | ||
|
|
14f83abe78 |
14
.github/workflows/develop.yml
vendored
14
.github/workflows/develop.yml
vendored
@@ -75,7 +75,9 @@ jobs:
|
|||||||
APP_URL: http://localhost:3000
|
APP_URL: http://localhost:3000
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg18
|
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
|
||||||
|
# pull rate-limit that randomly fails on shared GitHub runner IPs).
|
||||||
|
image: mirror.gcr.io/pgvector/pgvector:pg18
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: docmost
|
POSTGRES_DB: docmost
|
||||||
POSTGRES_USER: docmost
|
POSTGRES_USER: docmost
|
||||||
@@ -88,7 +90,8 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 20
|
--health-retries 20
|
||||||
redis:
|
redis:
|
||||||
image: redis:7
|
# via mirror.gcr.io (see postgres note above).
|
||||||
|
image: mirror.gcr.io/library/redis:7
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
options: >-
|
options: >-
|
||||||
@@ -135,7 +138,9 @@ jobs:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg18
|
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
|
||||||
|
# pull rate-limit that randomly fails on shared GitHub runner IPs).
|
||||||
|
image: mirror.gcr.io/pgvector/pgvector:pg18
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: docmost
|
POSTGRES_DB: docmost
|
||||||
POSTGRES_USER: docmost
|
POSTGRES_USER: docmost
|
||||||
@@ -148,7 +153,8 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 20
|
--health-retries 20
|
||||||
redis:
|
redis:
|
||||||
image: redis:7
|
# via mirror.gcr.io (see postgres note above).
|
||||||
|
image: mirror.gcr.io/library/redis:7
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
options: >-
|
options: >-
|
||||||
|
|||||||
7
.github/workflows/test.yml
vendored
7
.github/workflows/test.yml
vendored
@@ -27,7 +27,9 @@ jobs:
|
|||||||
# TEST_*_URL overrides are needed.
|
# TEST_*_URL overrides are needed.
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg18
|
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
|
||||||
|
# pull rate-limit that randomly fails on shared GitHub runner IPs).
|
||||||
|
image: mirror.gcr.io/pgvector/pgvector:pg18
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: docmost
|
POSTGRES_USER: docmost
|
||||||
POSTGRES_PASSWORD: docmost_dev_pw
|
POSTGRES_PASSWORD: docmost_dev_pw
|
||||||
@@ -40,7 +42,8 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
redis:
|
redis:
|
||||||
image: redis:7
|
# via mirror.gcr.io (see postgres note above).
|
||||||
|
image: mirror.gcr.io/library/redis:7
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
options: >-
|
options: >-
|
||||||
|
|||||||
@@ -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<typeof Object>,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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<string>((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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
IconUnderline,
|
IconUnderline,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
|
IconClearFormatting,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import classes from "./bubble-menu.module.css";
|
import classes from "./bubble-menu.module.css";
|
||||||
@@ -117,6 +118,14 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
command: () => props.editor.chain().focus().toggleSpoiler().run(),
|
command: () => props.editor.chain().focus().toggleSpoiler().run(),
|
||||||
icon: IconEyeOff,
|
icon: IconEyeOff,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Clear formatting",
|
||||||
|
// Action, not a toggle — never show an active/highlighted state.
|
||||||
|
isActive: () => false,
|
||||||
|
// Mirror the fixed-toolbar behavior: strip all inline marks from the selection.
|
||||||
|
command: () => props.editor.chain().focus().unsetAllMarks().run(),
|
||||||
|
icon: IconClearFormatting,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const commentItem: BubbleMenuItem = {
|
const commentItem: BubbleMenuItem = {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,14 +12,6 @@ function sanitizeMdLinkText(value: string): string {
|
|||||||
.replace(/[\r\n]+/g, ' ');
|
.replace(/[\r\n]+/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape a value placed inside a double-quoted HTML attribute (img src/alt/
|
|
||||||
// data-caption in the raw-HTML image fallback). Only & and " are special in
|
|
||||||
// that context; escaping them is idempotent because parse5/marked decode them
|
|
||||||
// back on re-import.
|
|
||||||
function escapeHtmlAttr(value: string): string {
|
|
||||||
return value.replace(/&/g, '&').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tags turndown treats as void (self-closing). Footnote references render as an
|
// Tags turndown treats as void (self-closing). Footnote references render as an
|
||||||
// empty <sup data-footnote-ref> whose meaning lives entirely in its data-id;
|
// empty <sup data-footnote-ref> whose meaning lives entirely in its data-id;
|
||||||
// without marking it void, turndown's blank-node removal drops it before our
|
// without marking it void, turndown's blank-node removal drops it before our
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ const HOST_CONTRACT_METHODS = [
|
|||||||
"unsharePage",
|
"unsharePage",
|
||||||
"restorePageVersion",
|
"restorePageVersion",
|
||||||
"transformPage",
|
"transformPage",
|
||||||
|
"stashPage",
|
||||||
// write (comment)
|
// write (comment)
|
||||||
"createComment",
|
"createComment",
|
||||||
"resolveComment",
|
"resolveComment",
|
||||||
|
|||||||
Reference in New Issue
Block a user