Skip to content

Commit 93bfb3e

Browse files
authored
Merge pull request #1815 from quantified-uncertainty/worktree-agent-a9259a85
test(crux): add coverage for authoring pipeline and statements utilities
2 parents c540094 + f3fcb7a commit 93bfb3e

File tree

3 files changed

+831
-0
lines changed

3 files changed

+831
-0
lines changed
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
/**
2+
* Tests for deployment.ts utility functions.
3+
*
4+
* Covers:
5+
* - convertSlugsToNumericIds: EntityLink and DataInfoBox ID conversion
6+
* - validateCrossLinks: EntityLink counting and footnote balance checks
7+
*
8+
* All tests are offline — no file system access beyond what's explicitly mocked.
9+
*/
10+
11+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
12+
13+
// We need to mock the modules that read from the filesystem before importing
14+
// deployment.ts, because getSlugToNumericMap reads database.json on first call.
15+
vi.mock('../../lib/session/edit-log.ts', () => ({
16+
appendEditLog: vi.fn(),
17+
getDefaultRequestedBy: vi.fn(() => 'test'),
18+
}));
19+
20+
vi.mock('../../lib/validation/validate-mdx-content.ts', () => ({
21+
validateMdxContent: vi.fn(() => ({ valid: true })),
22+
}));
23+
24+
// Import the functions under test AFTER mocking
25+
import { convertSlugsToNumericIds, validateCrossLinks } from './deployment.ts';
26+
27+
// ---------------------------------------------------------------------------
28+
// convertSlugsToNumericIds
29+
// ---------------------------------------------------------------------------
30+
31+
describe('convertSlugsToNumericIds', () => {
32+
it('leaves content unchanged when there are no slug-based EntityLinks', () => {
33+
const content = `---
34+
title: Test
35+
---
36+
37+
## Section
38+
39+
Some content without any entity links.
40+
`;
41+
const result = convertSlugsToNumericIds(content, '/fake/root');
42+
expect(result.content).toBe(content);
43+
expect(result.converted).toBe(0);
44+
});
45+
46+
it('leaves numeric EntityLink IDs unchanged (already E## format)', () => {
47+
const content = `<EntityLink id="E123">Some Entity</EntityLink>`;
48+
const result = convertSlugsToNumericIds(content, '/fake/root');
49+
expect(result.content).toBe(content);
50+
expect(result.converted).toBe(0);
51+
});
52+
53+
it('leaves slug-based EntityLinks unchanged when registry is empty (database.json not found)', () => {
54+
// The module caches the registry. Since database.json doesn't exist in the
55+
// test environment, the registry is empty and slugs are left as-is.
56+
const content = `<EntityLink id="open-philanthropy">Open Philanthropy</EntityLink>`;
57+
const result = convertSlugsToNumericIds(content, '/nonexistent/path');
58+
// Slug not in registry → leave as-is
59+
expect(result.content).toBe(content);
60+
expect(result.converted).toBe(0);
61+
});
62+
63+
it('handles content with no EntityLinks or DataInfoBox attributes', () => {
64+
const content = `## Section\n\nJust plain text with entityId mentioned inline.`;
65+
const result = convertSlugsToNumericIds(content, '/fake/root');
66+
expect(result.content).toBe(content);
67+
expect(result.converted).toBe(0);
68+
});
69+
70+
it('preserves multiple EntityLinks when none are in registry', () => {
71+
const content = [
72+
'<EntityLink id="org-a">Org A</EntityLink>',
73+
'and',
74+
'<EntityLink id="org-b">Org B</EntityLink>',
75+
].join(' ');
76+
const result = convertSlugsToNumericIds(content, '/fake/root');
77+
expect(result.content).toBe(content);
78+
expect(result.converted).toBe(0);
79+
});
80+
81+
it('does not modify numeric entityId attributes in DataInfoBox', () => {
82+
const content = `<DataInfoBox entityId="E456" />`;
83+
const result = convertSlugsToNumericIds(content, '/fake/root');
84+
expect(result.content).toBe(content);
85+
expect(result.converted).toBe(0);
86+
});
87+
88+
it('returns converted count of zero when no conversions occur', () => {
89+
const content = `<EntityLink id="some-slug">text</EntityLink>`;
90+
const result = convertSlugsToNumericIds(content, '/fake/root');
91+
expect(result.converted).toBe(0);
92+
});
93+
94+
it('rewrites slug EntityLinks and DataInfoBox entityId to E## when registry is seeded', async () => {
95+
// Use vi.resetModules() + dynamic import to get a fresh module instance
96+
// where _slugToNumeric is null (not yet cached as empty by earlier tests).
97+
const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = require('fs');
98+
const { join } = require('path');
99+
100+
const tmpRoot = mkdtempSync('/tmp/test-slug-convert-');
101+
const dataDir = join(tmpRoot, 'apps', 'web', 'src', 'data');
102+
mkdirSync(dataDir, { recursive: true });
103+
writeFileSync(
104+
join(dataDir, 'database.json'),
105+
JSON.stringify({ idRegistry: { byNumericId: { E123: 'open-philanthropy' } } })
106+
);
107+
108+
try {
109+
vi.resetModules();
110+
const { convertSlugsToNumericIds: convert } = await import('./deployment.ts');
111+
112+
// EntityLink id rewrite
113+
const r1 = convert(
114+
'<EntityLink id="open-philanthropy">Open Philanthropy</EntityLink>',
115+
tmpRoot
116+
);
117+
expect(r1.content).toBe('<EntityLink id="E123">Open Philanthropy</EntityLink>');
118+
expect(r1.converted).toBe(1);
119+
120+
// DataInfoBox entityId rewrite
121+
const r2 = convert('<DataInfoBox entityId="open-philanthropy" />', tmpRoot);
122+
expect(r2.content).toBe('<DataInfoBox entityId="E123" />');
123+
expect(r2.converted).toBe(1);
124+
} finally {
125+
rmSync(tmpRoot, { recursive: true });
126+
vi.resetModules();
127+
}
128+
});
129+
});
130+
131+
// ---------------------------------------------------------------------------
132+
// validateCrossLinks
133+
// ---------------------------------------------------------------------------
134+
135+
describe('validateCrossLinks', () => {
136+
it('warns when no EntityLinks are found', () => {
137+
// Use the module's internal fs.existsSync and readFileSync via a tmp file
138+
const { writeFileSync, mkdtempSync, rmSync } = require('fs');
139+
const { join } = require('path');
140+
const tmpDir = mkdtempSync('/tmp/test-crosslinks-');
141+
const filePath = join(tmpDir, 'test.mdx');
142+
143+
try {
144+
writeFileSync(filePath, '## Section\n\nNo entity links here.\n');
145+
const result = validateCrossLinks(filePath);
146+
expect(result.warnings.length).toBeGreaterThan(0);
147+
expect(result.warnings.some(w => w.includes('No EntityLinks'))).toBe(true);
148+
expect(result.outboundCount).toBe(0);
149+
expect(result.outboundIds).toHaveLength(0);
150+
} finally {
151+
rmSync(tmpDir, { recursive: true });
152+
}
153+
});
154+
155+
it('warns when only 1-2 EntityLinks are found (below threshold)', () => {
156+
const { writeFileSync, mkdtempSync, rmSync } = require('fs');
157+
const { join } = require('path');
158+
const tmpDir = mkdtempSync('/tmp/test-crosslinks-');
159+
const filePath = join(tmpDir, 'test.mdx');
160+
161+
try {
162+
writeFileSync(filePath, '<EntityLink id="E100">One</EntityLink>\n');
163+
const result = validateCrossLinks(filePath);
164+
expect(result.warnings.some(w => w.includes('Only'))).toBe(true);
165+
expect(result.outboundCount).toBe(1);
166+
expect(result.outboundIds).toContain('E100');
167+
} finally {
168+
rmSync(tmpDir, { recursive: true });
169+
}
170+
});
171+
172+
it('does not warn when 3+ EntityLinks are found', () => {
173+
const { writeFileSync, mkdtempSync, rmSync } = require('fs');
174+
const { join } = require('path');
175+
const tmpDir = mkdtempSync('/tmp/test-crosslinks-');
176+
const filePath = join(tmpDir, 'test.mdx');
177+
178+
try {
179+
const content = [
180+
'<EntityLink id="E100">One</EntityLink>',
181+
'<EntityLink id="E200">Two</EntityLink>',
182+
'<EntityLink id="E300">Three</EntityLink>',
183+
].join('\n');
184+
writeFileSync(filePath, content);
185+
const result = validateCrossLinks(filePath);
186+
// Should have no warnings about too few entity links
187+
expect(result.warnings.filter(w => w.includes('EntityLink')).length).toBe(0);
188+
expect(result.outboundCount).toBe(3);
189+
expect(result.outboundIds).toContain('E100');
190+
expect(result.outboundIds).toContain('E200');
191+
expect(result.outboundIds).toContain('E300');
192+
} finally {
193+
rmSync(tmpDir, { recursive: true });
194+
}
195+
});
196+
197+
it('deduplicates repeated EntityLink IDs in outboundIds', () => {
198+
const { writeFileSync, mkdtempSync, rmSync } = require('fs');
199+
const { join } = require('path');
200+
const tmpDir = mkdtempSync('/tmp/test-crosslinks-');
201+
const filePath = join(tmpDir, 'test.mdx');
202+
203+
try {
204+
const content = [
205+
'<EntityLink id="E100">Link 1</EntityLink>',
206+
'<EntityLink id="E100">Same entity again</EntityLink>',
207+
'<EntityLink id="E200">Other</EntityLink>',
208+
'<EntityLink id="E300">Another</EntityLink>',
209+
].join('\n');
210+
writeFileSync(filePath, content);
211+
const result = validateCrossLinks(filePath);
212+
// outboundIds should be deduplicated
213+
expect(result.outboundIds.filter(id => id === 'E100')).toHaveLength(1);
214+
expect(result.outboundCount).toBe(4); // total occurrences
215+
expect(result.outboundIds).toHaveLength(3); // unique IDs
216+
} finally {
217+
rmSync(tmpDir, { recursive: true });
218+
}
219+
});
220+
221+
it('warns when footnote references exceed definitions', () => {
222+
const { writeFileSync, mkdtempSync, rmSync } = require('fs');
223+
const { join } = require('path');
224+
const tmpDir = mkdtempSync('/tmp/test-crosslinks-');
225+
const filePath = join(tmpDir, 'test.mdx');
226+
227+
try {
228+
const content = [
229+
'<EntityLink id="E100">One</EntityLink>',
230+
'<EntityLink id="E200">Two</EntityLink>',
231+
'<EntityLink id="E300">Three</EntityLink>',
232+
// Footnote references
233+
'Claim here.[^1] Another claim.[^2]',
234+
// Only one definition
235+
'[^1]: Source definition.',
236+
// [^2] has no definition
237+
].join('\n');
238+
writeFileSync(filePath, content);
239+
const result = validateCrossLinks(filePath);
240+
expect(result.warnings.some(w => w.includes('footnote reference'))).toBe(true);
241+
} finally {
242+
rmSync(tmpDir, { recursive: true });
243+
}
244+
});
245+
246+
it('does not warn about footnotes when refs and defs are balanced', () => {
247+
const { writeFileSync, mkdtempSync, rmSync } = require('fs');
248+
const { join } = require('path');
249+
const tmpDir = mkdtempSync('/tmp/test-crosslinks-');
250+
const filePath = join(tmpDir, 'test.mdx');
251+
252+
try {
253+
const content = [
254+
'<EntityLink id="E100">One</EntityLink>',
255+
'<EntityLink id="E200">Two</EntityLink>',
256+
'<EntityLink id="E300">Three</EntityLink>',
257+
'Claim here.[^1] Another.[^2]',
258+
'[^1]: First source.',
259+
'[^2]: Second source.',
260+
].join('\n');
261+
writeFileSync(filePath, content);
262+
const result = validateCrossLinks(filePath);
263+
expect(result.warnings.filter(w => w.includes('footnote'))).toHaveLength(0);
264+
} finally {
265+
rmSync(tmpDir, { recursive: true });
266+
}
267+
});
268+
269+
it('returns warnings for missing file', () => {
270+
const result = validateCrossLinks('/nonexistent/path/file.mdx');
271+
expect(result.warnings).toContain('File not found');
272+
expect(result.outboundCount).toBe(0);
273+
expect(result.outboundIds).toHaveLength(0);
274+
});
275+
});

0 commit comments

Comments
 (0)