Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions apps/roam/src/utils/__tests__/conceptConversion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DiscourseNode } from "~/utils/getDiscourseNodes";

const { mockedGetPageUidByPageTitle, mockedGetDiscourseNodes } = vi.hoisted(
() => ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
mockedGetPageUidByPageTitle: vi.fn((_title: string) => ""),
mockedGetDiscourseNodes: vi.fn((): DiscourseNode[] => []),
}),
);
vi.mock("roamjs-components/queries/getPageUidByPageTitle", () => ({
default: mockedGetPageUidByPageTitle,
}));
vi.mock("~/utils/getDiscourseNodes", () => ({
default: mockedGetDiscourseNodes,
}));
vi.mock("~/utils/getDiscourseRelations", () => ({ default: () => [] }));
vi.mock("roamjs-components/queries/getPageTitleByPageUid", () => ({
default: () => "",
}));

// getNodeExtraData queries Roam for the author and timestamps of every concept.
vi.hoisted(() => {
(globalThis as { window?: unknown }).window = {
roamAlphaAPI: {
util: { generateUID: () => "someUid" },
q: () => [["author-1", "page-1", 1000, 2000]],
},
};
});

import {
discourseNodeBlockToLocalConcept,
discourseNodeSchemaToLocalConcept,
} from "~/utils/conceptConversion";

const CONTEXT = { spaceId: 1, userId: 2 } as never;

const nodeType = (overrides: Partial<DiscourseNode>): DiscourseNode => ({
text: "Evidence",
type: "_EVD-node",
shortcut: "e",
format: "[[EVD]] - {content} - {Source}",
specification: [],
backedBy: "user",
canvasSettings: {},
...overrides,
});

const SOURCE_TYPE = nodeType({
text: "Source",
type: "src-node",
format: "@{content}",
});

// Roam-like lookup: only pages that exist resolve to a uid.
const PAGE_UIDS: Record<string, string> = {
"@sun2019direct": "source-1",
"@sun2019direct/fig2": "source-2",
};

beforeEach(() => {
mockedGetPageUidByPageTitle.mockReset();
mockedGetPageUidByPageTitle.mockImplementation(
(title: string) => PAGE_UIDS[title] ?? "",
);
mockedGetDiscourseNodes.mockReturnValue([SOURCE_TYPE]);
});

describe("discourseNodeSchemaToLocalConcept source slot", () => {
it("declares a sourceDocument slot filled by the Source node type", () => {
const concept = discourseNodeSchemaToLocalConcept(CONTEXT, nodeType({}));
expect(concept.local_reference_content).toEqual({
sourceDocument: "src-node",
});
expect(concept.literal_content).toMatchObject({
roles: ["sourceDocument"],
});
});

it("falls back to the default source type when none is configured", () => {
mockedGetDiscourseNodes.mockReturnValue([]);
const concept = discourseNodeSchemaToLocalConcept(CONTEXT, nodeType({}));
expect(concept.local_reference_content).toEqual({
sourceDocument: "_SRC-node",
});
});

it("declares no slot when the format has no source placeholder", () => {
const concept = discourseNodeSchemaToLocalConcept(
CONTEXT,
nodeType({ text: "Claim", type: "clm", format: "[[CLM]] - {content}" }),
);
expect(concept.local_reference_content).toBeUndefined();
expect(concept.literal_content).toEqual({ label: "Claim" });
});

it("keeps the label and template it already carried", () => {
const concept = discourseNodeSchemaToLocalConcept(
CONTEXT,
nodeType({ template: [{ text: "Question:" }] }),
);
expect(concept.literal_content).toEqual({
label: "Evidence",
template: "* Question:\n",
roles: ["sourceDocument"],
});
});
});

describe("discourseNodeBlockToLocalConcept source slot", () => {
const convert = (title: string, schema: DiscourseNode = nodeType({})) =>
discourseNodeBlockToLocalConcept(CONTEXT, {
nodeUid: "node-1",
schemaUid: schema.type,
text: title,
title,
schema,
});

it("resolves the source page named in the title", () => {
const concept = convert(
"[[EVD]] - REM sleep aids recall - [[@sun2019direct]]",
);
expect(concept.local_reference_content).toEqual({
sourceDocument: "source-1",
});
});

// Leniency on the target type: see sourceSlot.ts
it("accepts a source that is a node of another type", () => {
mockedGetDiscourseNodes.mockReturnValue([
SOURCE_TYPE,
nodeType({ text: "Claim", type: "clm", format: "[[CLM]] - {content}" }),
]);
mockedGetPageUidByPageTitle.mockImplementation(() => "claim-1");
const concept = convert(
"[[EVD]] - REM sleep aids recall - [[CLM]] - a claim",
);
expect(concept.local_reference_content).toEqual({
sourceDocument: "claim-1",
});
});

it("omits the slot when the source page is not a discourse node", () => {
mockedGetPageUidByPageTitle.mockImplementation(() => "some-page");
const concept = convert(
"[[EVD]] - REM sleep aids recall - [[a plain page]]",
);
expect(concept.local_reference_content).toBeUndefined();
});

it("omits the slot when the source page does not exist", () => {
const concept = convert(
"[[EVD]] - REM sleep aids recall - [[@unknownref]]",
);
expect(concept.local_reference_content).toBeUndefined();
});

it("skips a source containing a slash, even when the page exists", () => {
const concept = convert(
"[[EVD]] - REM sleep aids recall - [[@sun2019direct/fig2]]",
);
expect(concept.local_reference_content).toBeUndefined();
expect(mockedGetPageUidByPageTitle).not.toHaveBeenCalled();
});

it("omits the slot when the node type has no source placeholder", () => {
const concept = convert(
"[[CLM]] - REM sleep aids recall",
nodeType({ text: "Claim", type: "clm", format: "[[CLM]] - {content}" }),
);
expect(concept.local_reference_content).toBeUndefined();
});

it("uses the page title, not the block text, of a block-backed node", () => {
const concept = discourseNodeBlockToLocalConcept(CONTEXT, {
nodeUid: "node-1",
schemaUid: "_EVD-node",
text: "[[EVD]] - REM sleep aids recall - [[@sun2019direct]] the block text",
title: "[[EVD]] - REM sleep aids recall - [[@sun2019direct]]",
schema: nodeType({}),
});
expect(concept.local_reference_content).toEqual({
sourceDocument: "source-1",
});
});
});
154 changes: 149 additions & 5 deletions apps/roam/src/utils/__tests__/roamToCrossAppConverters.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Json } from "@repo/database/dbTypes";
import defaultDiscourseNodes from "~/data/defaultDiscourseNodes";

vi.mock("roamjs-components/queries/getFullTreeByParentUid", () => ({
default: () => ({ children: [] }),
Expand All @@ -8,12 +9,34 @@ vi.mock("roamjs-components/queries/getPageViewType", () => ({
default: () => "bullet",
}));
vi.mock("~/utils/pageToMarkdown", () => ({ toMarkdown: () => "" }));
vi.mock("~/utils/getDiscourseNodes", () => ({
default: vi.fn(() => defaultDiscourseNodes),
}));

const { mockedGetPageUidByPageTitle } = vi.hoisted(() => ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
mockedGetPageUidByPageTitle: vi.fn((_title: string) => ""),
}));
vi.mock("roamjs-components/queries/getPageUidByPageTitle", () => ({
default: mockedGetPageUidByPageTitle,
}));

// Runs before the imports below: getDiscourseNodes calls generateUID at module load.
vi.hoisted(() => {
(globalThis as { window?: unknown }).window = {
roamAlphaAPI: { util: { generateUID: () => "someUid" } },
};
});

import {
nodeSchemaToCrossApp,
nodeUidsWithTypeToCrossApp,
} from "~/utils/roamToCrossAppConverters";
import type { DiscourseNode } from "~/utils/getDiscourseNodes";
import getDiscourseNodes, {
type DiscourseNode,
} from "~/utils/getDiscourseNodes";

const mockedGetDiscourseNodes = vi.mocked(getDiscourseNodes);

const USER_ROW = { ":db/id": 5, ":user/uid": "user-1" };

Expand Down Expand Up @@ -65,14 +88,15 @@ describe("nodeUidsWithTypeToCrossApp timestamps", () => {
});
});

const nodeSchema = (): DiscourseNode => ({
const nodeSchema = (overrides: Partial<DiscourseNode>): DiscourseNode => ({
text: "Evidence",
type: "_EVD-node",
shortcut: "e",
format: "[[EVD]] - {content}",
format: "[[EVD]] - {content} - {Source}",
specification: [],
backedBy: "user",
canvasSettings: {},
...overrides,
});

// For the timestamp tests: what Roam holds about one node type page.
Expand All @@ -82,14 +106,27 @@ const convertSchemaPull = (pullResult: Record<string, unknown> | null) => {
pull: () => pullResult,
},
};
return nodeSchemaToCrossApp(nodeSchema());
return nodeSchemaToCrossApp(nodeSchema({}));
};

const schemaPull = {
":create/time": 1000,
":create/user": { ":user/uid": "user-1" },
};

const convertSchema = (node: DiscourseNode) => {
(globalThis as { window: unknown }).window = {
roamAlphaAPI: {
pull: () => ({
":create/time": 1000,
":edit/time": 2000,
":create/user": { ":user/uid": "user-1" },
}),
},
};
return nodeSchemaToCrossApp(node);
};

describe("nodeSchemaToCrossApp timestamps", () => {
it("takes the block edit time, as written when the page props change", () => {
const schema = convertSchemaPull({ ...schemaPull, ":edit/time": 3000 });
Expand Down Expand Up @@ -124,3 +161,110 @@ describe("nodeSchemaToCrossApp timestamps", () => {
expect(convertSchemaPull({ ":create/time": 1000 })).toBeNull();
});
});

describe("nodeSchemaToCrossApp source slot", () => {
it("adds a sourceDocument slot definition pointing at the Source node type", () => {
mockedGetDiscourseNodes.mockReturnValue([
nodeSchema({ text: "Source", type: "src-node", format: "@{content}" }),
]);
expect(convertSchema(nodeSchema({}))?.slotDefinitions).toEqual({
sourceDocument: "src-node",
});
});

it("falls back to the default source type when no Source node exists", () => {
mockedGetDiscourseNodes.mockReturnValue([]);
expect(convertSchema(nodeSchema({}))?.slotDefinitions).toEqual({
sourceDocument: "_SRC-node",
});
});
});

describe("nodeUidsWithTypeToCrossApp source slot", () => {
const EVIDENCE_SCHEMA = nodeSchema({
type: "schema-1",
});
const SOURCE_SCHEMA = nodeSchema({
text: "Source",
type: "src-node",
format: "@{content}",
});
// Roam-like lookup: only existing pages resolve to a uid.
const PAGE_UIDS: Record<string, string> = {
"@sun2019direct": "source-1",
"@sun2019direct/fig2": "source-2",
};

beforeEach(() => {
mockedGetPageUidByPageTitle.mockReset();
mockedGetPageUidByPageTitle.mockImplementation(
(title: string) => PAGE_UIDS[title] ?? "",
);
});

it("resolves the source page from the title into a sourceDocument slot", async () => {
mockedGetDiscourseNodes.mockReturnValue([EVIDENCE_SCHEMA, SOURCE_SCHEMA]);
const node = await convertRow({
...baseRow,
":node/title": "[[EVD]] - REM sleep aids recall - [[@sun2019direct]]",
});
expect(node.slots).toEqual({ sourceDocument: "source-1" });
});

// Leniency on the target type: see sourceSlot.ts
it("accepts a source that is a node of another type", async () => {
mockedGetDiscourseNodes.mockReturnValue([
EVIDENCE_SCHEMA,
SOURCE_SCHEMA,
nodeSchema({ text: "Claim", type: "clm", format: "[[CLM]] - {content}" }),
]);
mockedGetPageUidByPageTitle.mockImplementation(() => "claim-1");
const node = await convertRow({
...baseRow,
":node/title": "[[EVD]] - REM sleep aids recall - [[CLM]] - a claim",
});
expect(node.slots).toEqual({ sourceDocument: "claim-1" });
});

it("omits slots when the source page is not a discourse node", async () => {
mockedGetDiscourseNodes.mockReturnValue([EVIDENCE_SCHEMA, SOURCE_SCHEMA]);
mockedGetPageUidByPageTitle.mockImplementation(() => "some-page");
const node = await convertRow({
...baseRow,
":node/title": "[[EVD]] - REM sleep aids recall - [[a plain page]]",
});
expect(node.slots).toBeUndefined();
});

it("omits slots when the source page does not exist", async () => {
mockedGetDiscourseNodes.mockReturnValue([EVIDENCE_SCHEMA, SOURCE_SCHEMA]);
const node = await convertRow({
...baseRow,
":node/title": "[[EVD]] - REM sleep aids recall - [[@unknownref]]",
});
expect(node.slots).toBeUndefined();
});

it("skips sources containing a slash, even when the page exists", async () => {
mockedGetDiscourseNodes.mockReturnValue([EVIDENCE_SCHEMA, SOURCE_SCHEMA]);
const node = await convertRow({
...baseRow,
":node/title":
"[[EVD]] - REM sleep aids recall - [[@sun2019direct/fig2]]",
});
expect(node.slots).toBeUndefined();
expect(mockedGetPageUidByPageTitle).not.toHaveBeenCalled();
});

it("omits slots when the schema format has no source placeholder", async () => {
mockedGetDiscourseNodes.mockReturnValue([
nodeSchema({ type: "schema-1", format: "[[CLM]] - {content}" }),
SOURCE_SCHEMA,
]);
const node = await convertRow({
...baseRow,
":node/title": "[[CLM]] - REM sleep aids recall",
});
expect(node.slots).toBeUndefined();
});
});
Loading