467 lines
27 KiB
JavaScript
467 lines
27 KiB
JavaScript
// Query tests for design 3 (C2-05a): universe isolation, scope labels, unmapped files, hybrids, paging
|
|
// under the reply cap, refusals and manuscript paging. Fixtures are made up and built under the scratch area.
|
|
import { test } from 'node:test';
|
|
import { writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import assert from 'node:assert/strict';
|
|
import { REPLY_CAP } from '../../harness/src/tools.mjs';
|
|
import { find, get, search, manuscript, universeInfo, QUERY_REPLY_CAP, LOOKUP_LIMIT, MANUSCRIPT_LINES } from '../src/query.mjs';
|
|
import { mapping, MARKER, git } from './helpers.mjs';
|
|
import { scratchRepo, note, EXCLUSION, EXCLUDED_PATHS, SUPPORT_FILES, withSupporting } from './query-fixture.mjs';
|
|
|
|
// A hybrid over harbor and second. Its one constraint is EXCLUSION (query-fixture.mjs) and it declares no
|
|
// books or playground.
|
|
const WITH_BLEND = e => ({
|
|
...e,
|
|
universes: {
|
|
...e.universes,
|
|
blend: {
|
|
kind: 'hybrid', name: 'Blend', sources: ['harbor', 'second'], books: [], playground: [],
|
|
constraints: [EXCLUSION], notes: 'made-up hybrid',
|
|
},
|
|
},
|
|
});
|
|
|
|
// PRD 1.14 levels (C2-01c): harbor gets a universe-level playground prefix outside every book, and a draft
|
|
// universe has only playground prefixes. Files there have book null.
|
|
const WITH_LEVELS = e => ({
|
|
...WITH_BLEND(e),
|
|
universes: {
|
|
...WITH_BLEND(e).universes,
|
|
harbor: { ...e.universes.harbor, playground: ['Book One/Playground/', 'Notes/Harbor/'] },
|
|
idea: { kind: 'draft', name: 'New World', playground: ['Drafts/New World/'] },
|
|
},
|
|
});
|
|
const LEVEL_FILES = {
|
|
'Notes/Harbor/Scraps.md': note('summary', 'Scraps', `${MARKER} loose`),
|
|
'Notes/Harbor/Chapters/Idea Chapter.md': `# Idea\n${MARKER} unbooked chapter\n`,
|
|
'Drafts/New World/Characters/Rin.md': note('character', 'Rin', `${MARKER} draft`),
|
|
'Drafts/New World/Summary.md': note('summary', 'Summary', `${MARKER} draft`),
|
|
};
|
|
|
|
const FILES = {
|
|
'Book One/Characters/Mara Vell.md': note('character', 'Mara Vell', `Grey eyes. ${MARKER}`,
|
|
['appears_in: "[[Book One/Chapters/Chapter 01]]"', 'relationships:', ' - "[[Tomas]]"']),
|
|
'Book One/Characters/Tomas.md': note('character', 'Tomas', MARKER),
|
|
'Book One/Chapters/Chapter 01.md': `# One\n${MARKER} [[Mara Vell]] met [[Book One/Places/Harbor Town|the harbor]].\n`,
|
|
'Book One/Chapters/Chapter 02.md': 'Quiet. [[Ferry Logs]] [[Ilse]]\n',
|
|
'Book One/Places/Harbor Town.md': note('place', 'Harbor Town', `Salt. ${MARKER}`),
|
|
'Book One/Research/Ferry Logs.md': note('research', 'Ferry Logs', `Timetable. ${MARKER}`),
|
|
'Book One/Playground/What If.md': note('summary', 'What If', `${MARKER} [[Mara Vell]]`),
|
|
'Second World/Characters/Ilse.md': note('character', 'Ilse', `${MARKER} Ilse.`),
|
|
'Second World/Chapters/01.md': '# Elsewhere\n[[Ilse]] waits.\n',
|
|
'Inbox Note.md': `${MARKER}\n`,
|
|
'Book One/Board.canvas': '{"nodes":[]}\n',
|
|
};
|
|
|
|
const src = (ref, map = mapping()) => ({ repo: ref.repo, commit: ref.commit, mapping: map });
|
|
const paths = reply => reply.results.map(r => r.path);
|
|
|
|
test('the query reply cap equals the harness reply cap', () => {
|
|
assert.equal(QUERY_REPLY_CAP, REPLY_CAP);
|
|
});
|
|
|
|
test('find is bound to its universe and refuses a call without one', async t => {
|
|
const s = src(scratchRepo(t, FILES));
|
|
const names = paths(await find(s, { universe: 'harbor', query: '' }));
|
|
assert.ok(names.length > 0);
|
|
assert.ok(names.every(p => p.startsWith('Book One/')));
|
|
assert.ok(!names.includes('Second World/Characters/Ilse.md'));
|
|
await assert.rejects(find(s, { query: '' }), { code: 'query-argument-invalid' });
|
|
await assert.rejects(find(s, { universe: 'nowhere', query: '' }), { code: 'query-universe-unknown' });
|
|
});
|
|
|
|
test('every result carries its scope, and playground appears only when asked for', async t => {
|
|
const s = src(scratchRepo(t, FILES));
|
|
const canon = (await find(s, { universe: 'harbor', query: '' })).results;
|
|
assert.ok(canon.every(r => r.scope === 'canon'));
|
|
assert.ok(!canon.some(r => r.path.includes('/Playground/')));
|
|
const play = (await find(s, { universe: 'harbor', query: '', scope: 'playground' })).results;
|
|
assert.deepEqual(play.map(r => r.path), ['Book One/Playground/What If.md']);
|
|
assert.ok(play.every(r => r.scope === 'playground'));
|
|
});
|
|
|
|
test('unmapped files never appear in any reply', async t => {
|
|
const s = src(scratchRepo(t, FILES));
|
|
assert.deepEqual((await find(s, { universe: 'harbor', query: 'Inbox' })).results, []);
|
|
assert.deepEqual((await find(s, { universe: 'harbor', query: 'Board' })).results, []);
|
|
const hits = (await search(s, { universe: 'harbor', text: MARKER })).results;
|
|
assert.ok(hits.length > 0);
|
|
assert.ok(hits.every(r => r.path !== 'Inbox Note.md' && r.path !== 'Book One/Board.canvas'));
|
|
});
|
|
|
|
test('a hybrid sees its sources canon, minus exclusions, and no playground', async t => {
|
|
const s = src(scratchRepo(t, FILES), mapping(WITH_BLEND));
|
|
const reply = await find(s, { universe: 'blend', query: '' });
|
|
assert.deepEqual(paths(reply).sort(), [
|
|
'Book One/Chapters/Chapter 01.md', 'Book One/Chapters/Chapter 02.md',
|
|
'Book One/Characters/Mara Vell.md', 'Book One/Characters/Tomas.md', 'Book One/Places/Harbor Town.md',
|
|
'Second World/Chapters/01.md', 'Second World/Characters/Ilse.md',
|
|
]);
|
|
assert.ok(reply.results.every(r => r.scope === 'canon' && ['harbor', 'second'].includes(r.universe)));
|
|
assert.deepEqual((await find(s, { universe: 'blend', query: '', scope: 'playground' })).results, []);
|
|
assert.deepEqual((await find(s, { universe: 'blend', query: 'Ferry' })).results, []);
|
|
});
|
|
|
|
test('a hybrid gets a source file it can see, and not an excluded or foreign one', async t => {
|
|
const ref = scratchRepo(t, FILES);
|
|
const hybrid = src(ref, mapping(WITH_BLEND));
|
|
const ilse = await get(hybrid, { universe: 'blend', path: 'Second World/Characters/Ilse.md' });
|
|
assert.equal(ilse.scope, 'canon');
|
|
assert.ok(ilse.links_in.some(l => l.path === 'Second World/Chapters/01.md'));
|
|
assert.equal(await get(hybrid, { universe: 'blend', path: 'Book One/Research/Ferry Logs.md' }), null);
|
|
assert.equal(await get(src(ref), { universe: 'harbor', path: 'Second World/Characters/Ilse.md' }), null);
|
|
});
|
|
|
|
test('get lists visible links both ways and drops a target another universe cannot see', async t => {
|
|
const ref = scratchRepo(t, FILES);
|
|
const harbor = src(ref);
|
|
const ch1 = await get(harbor, { universe: 'harbor', path: 'Book One/Chapters/Chapter 01.md' });
|
|
assert.ok(ch1.links_out.some(l => l.path === 'Book One/Characters/Mara Vell.md' && l.line === 2));
|
|
assert.ok(ch1.links_out.some(l => l.path === 'Book One/Places/Harbor Town.md'));
|
|
const ch2 = await get(harbor, { universe: 'harbor', path: 'Book One/Chapters/Chapter 02.md' });
|
|
assert.ok(ch2.links_out.some(l => l.path === 'Book One/Research/Ferry Logs.md'));
|
|
assert.ok(!ch2.links_out.some(l => l.path === 'Second World/Characters/Ilse.md'));
|
|
const mara = await get(harbor, { universe: 'harbor', path: 'Book One/Characters/Mara Vell.md' });
|
|
assert.ok(mara.links_in.some(l => l.path === 'Book One/Chapters/Chapter 01.md'));
|
|
assert.ok(mara.links_in.some(l => l.path === 'Book One/Playground/What If.md'));
|
|
assert.ok(mara.links_out.some(l => l.path === 'Book One/Characters/Tomas.md' && l.field === 'relationships'));
|
|
assert.ok(mara.links_out.some(l => l.path === 'Book One/Chapters/Chapter 01.md' && l.field === 'appears_in'));
|
|
const blendCh2 = await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path: 'Book One/Chapters/Chapter 02.md' });
|
|
assert.ok(blendCh2.links_out.some(l => l.path === 'Second World/Characters/Ilse.md'));
|
|
});
|
|
|
|
test('a long list pages by position, never cuts an item, and returns 50 per page', async t => {
|
|
const files = { ...FILES };
|
|
for (let i = 1; i <= 120; i++) files[`Book One/Places/Note ${i}.md`] = note('place', `Note ${i}`);
|
|
const s = src(scratchRepo(t, files));
|
|
const seen = [];
|
|
let offset = 0;
|
|
for (let pages = 0; pages < 10; pages++) {
|
|
const reply = await find(s, { universe: 'harbor', query: 'Note', offset });
|
|
assert.ok(JSON.stringify(reply).length <= QUERY_REPLY_CAP);
|
|
assert.ok(reply.results.length <= LOOKUP_LIMIT);
|
|
if (pages === 0) assert.equal(reply.results.length, LOOKUP_LIMIT);
|
|
seen.push(...paths(reply));
|
|
if (reply.next === null) break;
|
|
offset = reply.next;
|
|
}
|
|
assert.equal(seen.length, 120);
|
|
assert.equal(new Set(seen).size, 120);
|
|
});
|
|
|
|
test('a page stays under the reply cap when items are large', async t => {
|
|
const big = 'x'.repeat(2000);
|
|
const files = { ...FILES };
|
|
for (let i = 1; i <= 40; i++) files[`Book One/Places/Big ${i}.md`] = note('place', `Big ${i}`, '', [`appears_in: "${big}"`]);
|
|
const s = src(scratchRepo(t, files));
|
|
const first = await find(s, { universe: 'harbor', query: 'Big' });
|
|
assert.ok(JSON.stringify(first).length <= QUERY_REPLY_CAP);
|
|
assert.ok(first.results.length > 0 && first.results.length < 40);
|
|
assert.notEqual(first.next, null);
|
|
const seen = [];
|
|
let offset = 0;
|
|
for (let pages = 0; pages < 10; pages++) {
|
|
const reply = await find(s, { universe: 'harbor', query: 'Big', offset });
|
|
assert.ok(JSON.stringify(reply).length <= QUERY_REPLY_CAP);
|
|
seen.push(...paths(reply));
|
|
if (reply.next === null) break;
|
|
offset = reply.next;
|
|
}
|
|
assert.equal(new Set(seen).size, 40);
|
|
});
|
|
|
|
test('a search line too long to fit alone comes back marked, with no excerpt, and the other hits still return', async t => {
|
|
const huge = 'y'.repeat(QUERY_REPLY_CAP + 1000);
|
|
const s = src(scratchRepo(t, { ...FILES, 'Book One/Places/Wall.md': note('place', 'Wall', `${huge} ${MARKER}`) }));
|
|
const reply = await search(s, { universe: 'harbor', text: 'yyyyyyyy' });
|
|
assert.deepEqual(reply.results, [{ path: 'Book One/Places/Wall.md', line: 5, excerpt: null, excerpt_too_long: true, scope: 'canon', universe: 'harbor' }]);
|
|
const mixed = await search(s, { universe: 'harbor', text: MARKER.toLowerCase() });
|
|
assert.ok(mixed.results.some(r => r.excerpt_too_long === true));
|
|
assert.ok(mixed.results.some(r => typeof r.excerpt === 'string' && !('excerpt_too_long' in r)));
|
|
assert.ok(JSON.stringify(mixed).length <= QUERY_REPLY_CAP);
|
|
});
|
|
|
|
test('a find item that cannot fit alone is refused, not cut', async t => {
|
|
const huge = 'y'.repeat(QUERY_REPLY_CAP + 1000);
|
|
const s = src(scratchRepo(t, { ...FILES, 'Book One/Places/Wall.md': note('place', 'Wall', '', [`appears_in: "${huge}"`]) }));
|
|
await assert.rejects(find(s, { universe: 'harbor', query: 'Wall' }), { code: 'query-item-too-large' });
|
|
});
|
|
|
|
test('get pages its lines, and the next line picks up where the page stopped', async t => {
|
|
const body = `${Array.from({ length: 500 }, (_, i) => `Line ${i + 1} ${'z'.repeat(120)}`).join('\n')}\n`;
|
|
const s = src(scratchRepo(t, { ...FILES, 'Book One/Chapters/Long.md': body }));
|
|
const first = await get(s, { universe: 'harbor', path: 'Book One/Chapters/Long.md' });
|
|
assert.equal(first.lines[0][0], 1);
|
|
assert.ok(first.lines.length < 501);
|
|
assert.notEqual(first.next, null);
|
|
let from = 1, count = 0;
|
|
for (let pages = 0; pages < 10; pages++) {
|
|
const page = await get(s, { universe: 'harbor', path: 'Book One/Chapters/Long.md', from });
|
|
count += page.lines.length;
|
|
if (page.next === null) break;
|
|
from = page.next;
|
|
}
|
|
assert.equal(count, 501);
|
|
});
|
|
|
|
test('get refuses a file whose front matter alone is over the cap, and a line alone is over the cap', async t => {
|
|
const huge = 'q'.repeat(QUERY_REPLY_CAP + 1000);
|
|
const s = src(scratchRepo(t, {
|
|
...FILES,
|
|
'Book One/Places/Heavy.md': note('place', 'Heavy', '', [`appears_in: "${huge}"`]),
|
|
'Book One/Chapters/Wide.md': `${huge}\n`,
|
|
}));
|
|
await assert.rejects(get(s, { universe: 'harbor', path: 'Book One/Places/Heavy.md' }), { code: 'query-item-too-large' });
|
|
await assert.rejects(get(s, { universe: 'harbor', path: 'Book One/Chapters/Wide.md' }), { code: 'query-item-too-large' });
|
|
});
|
|
|
|
test('get pages the links of a hub note, every page carries the front matter, and the pages add up', async t => {
|
|
const files = { ...FILES };
|
|
for (let i = 1; i <= 900; i++) files[`Book One/Chapters/Mention ${i}.md`] = `[[Tomas]] ${'w'.repeat(60)}\n`;
|
|
const s = src(scratchRepo(t, files));
|
|
const seen = [];
|
|
const PAST_END = 1000;
|
|
let linksFrom = 0, pages = 0, lines = 0, from = 1;
|
|
for (; pages < 40; pages++) {
|
|
const page = await get(s, { universe: 'harbor', path: 'Book One/Characters/Tomas.md', from, links_from: linksFrom });
|
|
assert.ok(JSON.stringify(page).length <= QUERY_REPLY_CAP);
|
|
assert.equal(page.frontmatter.name, 'Tomas');
|
|
seen.push(...page.links_in.map(l => l.path));
|
|
if (from !== PAST_END) lines += page.lines.length;
|
|
// Once the lines are read, ask past the last one: the page then carries links alone.
|
|
from = page.next ?? PAST_END;
|
|
if (page.links_next === null && page.next === null) break;
|
|
if (page.links_next !== null) linksFrom = page.links_next;
|
|
}
|
|
assert.ok(pages > 1, 'the hub needed more than one page');
|
|
assert.ok(pages < 40);
|
|
assert.equal(seen.filter(p => p.startsWith('Book One/Chapters/Mention ')).length, 900);
|
|
assert.equal(new Set(seen).size, seen.length, 'no link comes back twice');
|
|
assert.equal(lines, 6, 'the five lines of the note and the empty one after its last newline');
|
|
const tail = await get(s, { universe: 'harbor', path: 'Book One/Characters/Tomas.md', from: PAST_END });
|
|
assert.deepEqual(tail.lines, []);
|
|
assert.equal(tail.next, null);
|
|
});
|
|
|
|
test('get rejects a bad links_from', async t => {
|
|
const s = src(scratchRepo(t, FILES));
|
|
await assert.rejects(get(s, { universe: 'harbor', path: 'Book One/Characters/Tomas.md', links_from: -1 }), { code: 'query-argument-invalid' });
|
|
const past = await get(s, { universe: 'harbor', path: 'Book One/Characters/Tomas.md', links_from: 99 });
|
|
assert.deepEqual([past.links_out, past.links_in, past.links_next], [[], [], null]);
|
|
});
|
|
|
|
test('search returns scoped line hits, case-insensitively, and refuses empty text', async t => {
|
|
const s = src(scratchRepo(t, FILES));
|
|
const reply = await search(s, { universe: 'harbor', text: 'marker' });
|
|
assert.ok(reply.results.length > 0);
|
|
assert.ok(reply.results.every(r => r.scope === 'canon' && r.path.startsWith('Book One/') && Number.isInteger(r.line)));
|
|
assert.ok(reply.results.some(r => r.path === 'Book One/Chapters/Chapter 01.md' && r.line === 2));
|
|
await assert.rejects(search(s, { universe: 'harbor', text: '' }), { code: 'query-argument-invalid' });
|
|
});
|
|
|
|
test('a manuscript call returns one chapter, at most 400 lines, and names its next line', async t => {
|
|
const long = `${Array.from({ length: 900 }, (_, i) => `Scene ${i + 1}`).join('\n')}\n`;
|
|
const s = src(scratchRepo(t, { ...FILES, 'Book One/Chapters/Long Chapter.md': long }));
|
|
const first = await manuscript(s, { universe: 'harbor', book: 'Book One', chapter: 'Long Chapter' });
|
|
assert.equal(first.lines.length, MANUSCRIPT_LINES);
|
|
assert.equal(first.next, MANUSCRIPT_LINES + 1);
|
|
const second = await manuscript(s, { universe: 'harbor', book: 'Book One', chapter: 'Long Chapter', from: first.next });
|
|
assert.equal(second.lines[0][0], MANUSCRIPT_LINES + 1);
|
|
});
|
|
|
|
test('a manuscript call without a chapter returns the first chapter in path order and names the next', async t => {
|
|
const s = src(scratchRepo(t, FILES));
|
|
const first = await manuscript(s, { universe: 'harbor', book: 'Book One' });
|
|
assert.equal(first.path, 'Book One/Chapters/Chapter 01.md');
|
|
assert.equal(first.next_chapter, 'Chapter 02');
|
|
const last = await manuscript(s, { universe: 'harbor', book: 'Book One', chapter: 'Chapter 02' });
|
|
assert.equal(last.path, 'Book One/Chapters/Chapter 02.md');
|
|
assert.equal(last.next_chapter, null);
|
|
assert.equal(await manuscript(s, { universe: 'harbor', book: 'Book One', chapter: 'Nope' }), null);
|
|
});
|
|
|
|
test('a chapter name that matches two visible files is refused', async t => {
|
|
const s = src(scratchRepo(t, { ...FILES, 'Book One/Chapters/Part/Chapter 01.md': 'Again.\n' }));
|
|
await assert.rejects(manuscript(s, { universe: 'harbor', book: 'Book One', chapter: 'Chapter 01' }), { code: 'query-chapter-ambiguous' });
|
|
});
|
|
|
|
test('a hybrid reads a source chapter through its declared sources only', async t => {
|
|
const ref = scratchRepo(t, FILES);
|
|
const hybrid = await manuscript(src(ref, mapping(WITH_BLEND)), { universe: 'blend', book: 'Second World', chapter: '01' });
|
|
assert.equal(hybrid.path, 'Second World/Chapters/01.md');
|
|
assert.equal(hybrid.scope, 'canon');
|
|
assert.equal(await manuscript(src(ref), { universe: 'harbor', book: 'Second World', chapter: '01' }), null);
|
|
});
|
|
|
|
test('universe info reports a hybrid sources, constraints and notes, and refuses an unknown universe', async t => {
|
|
const s = src(scratchRepo(t, FILES), mapping(WITH_BLEND));
|
|
const blend = await universeInfo(s, { universe: 'blend' });
|
|
assert.equal(blend.kind, 'hybrid');
|
|
assert.deepEqual(blend.sources, ['harbor', 'second']);
|
|
assert.deepEqual(blend.constraints, [EXCLUSION]);
|
|
assert.equal(blend.notes, 'made-up hybrid');
|
|
const harbor = await universeInfo(s, { universe: 'harbor' });
|
|
assert.equal(harbor.kind, 'universe');
|
|
assert.equal(harbor.sources, undefined);
|
|
await assert.rejects(universeInfo(s, { universe: 'ghost' }), { code: 'query-universe-unknown' });
|
|
});
|
|
|
|
test('an exclusion hides what it names from a hybrid and from nothing else', async t => {
|
|
const ref = scratchRepo(t, FILES);
|
|
const hybrid = src(ref, mapping(WITH_BLEND));
|
|
const seen = paths(await find(hybrid, { universe: 'blend', query: '' }));
|
|
for (const path of EXCLUDED_PATHS) assert.ok(!seen.includes(path), `${path} stays hidden`);
|
|
const own = paths(await find(src(ref), { universe: 'harbor', query: '' }));
|
|
for (const path of EXCLUDED_PATHS) assert.ok(own.includes(path), `${path} stays visible to its own universe`);
|
|
});
|
|
|
|
test('PRD 1.14: a lookup that names a book leaves book-null files out', async t => {
|
|
const s = src(scratchRepo(t, { ...FILES, ...LEVEL_FILES }), mapping(WITH_LEVELS));
|
|
const all = paths(await find(s, { universe: 'harbor', query: '', scope: 'playground' }));
|
|
assert.deepEqual(all.sort(), ['Book One/Playground/What If.md', 'Notes/Harbor/Chapters/Idea Chapter.md', 'Notes/Harbor/Scraps.md']);
|
|
const booked = await find(s, { universe: 'harbor', query: '', scope: 'playground', book: 'Book One' });
|
|
assert.deepEqual(paths(booked), ['Book One/Playground/What If.md']);
|
|
assert.ok(booked.results.every(r => r.book === 'Book One'));
|
|
const hits = (await search(s, { universe: 'harbor', text: 'unbooked', scope: 'playground' })).results;
|
|
assert.deepEqual(hits.map(r => r.path), ['Notes/Harbor/Chapters/Idea Chapter.md']);
|
|
assert.deepEqual((await search(s, { universe: 'harbor', text: 'unbooked', scope: 'playground', book: 'Book One' })).results, []);
|
|
});
|
|
|
|
test('PRD 1.14: library_manuscript never reads a book-null file', async t => {
|
|
const s = src(scratchRepo(t, { ...FILES, ...LEVEL_FILES }), mapping(WITH_LEVELS));
|
|
// The unbooked file sits under a manuscript folder, so it has type manuscript and book null.
|
|
const listed = await find(s, { universe: 'harbor', query: 'Idea', scope: 'playground' });
|
|
assert.deepEqual(listed.results.map(r => [r.path, r.type, r.book]), [['Notes/Harbor/Chapters/Idea Chapter.md', 'manuscript', null]]);
|
|
assert.equal(await manuscript(s, { universe: 'harbor', book: 'Book One', scope: 'playground', chapter: 'Idea Chapter' }), null);
|
|
assert.equal(await manuscript(s, { universe: 'harbor', book: 'Book One', scope: 'playground' }), null);
|
|
assert.equal(await manuscript(s, { universe: 'harbor', book: 'Notes', scope: 'playground', chapter: 'Idea Chapter' }), null);
|
|
});
|
|
|
|
test('PRD 1.14: a draft universe has no canon, so its lookups return files only with scope playground', async t => {
|
|
const s = src(scratchRepo(t, { ...FILES, ...LEVEL_FILES }), mapping(WITH_LEVELS));
|
|
assert.deepEqual((await find(s, { universe: 'idea', query: '' })).results, []);
|
|
assert.deepEqual((await search(s, { universe: 'idea', text: MARKER })).results, []);
|
|
const found = await find(s, { universe: 'idea', query: '', scope: 'playground' });
|
|
assert.deepEqual(paths(found).sort(), ['Drafts/New World/Characters/Rin.md', 'Drafts/New World/Summary.md']);
|
|
assert.ok(found.results.every(r => r.scope === 'playground' && r.universe === 'idea' && r.book === null));
|
|
const hits = (await search(s, { universe: 'idea', text: 'draft', scope: 'playground' })).results;
|
|
assert.equal(hits.length, 2);
|
|
const rin = await get(s, { universe: 'idea', path: 'Drafts/New World/Characters/Rin.md', scope: 'playground' });
|
|
assert.equal(rin.scope, 'playground');
|
|
assert.equal(await get(s, { universe: 'harbor', path: 'Drafts/New World/Characters/Rin.md', scope: 'playground' }), null);
|
|
});
|
|
|
|
test('PRD 1.14: library_universe on a draft returns its kind, name and prefixes', async t => {
|
|
const s = src(scratchRepo(t, { ...FILES, ...LEVEL_FILES }), mapping(WITH_LEVELS));
|
|
const draft = await universeInfo(s, { universe: 'idea' });
|
|
assert.equal(draft.kind, 'draft');
|
|
assert.equal(draft.name, 'New World');
|
|
assert.deepEqual(draft.playground, ['Drafts/New World/']);
|
|
assert.deepEqual(draft.books, []);
|
|
assert.equal(draft.sources, undefined);
|
|
});
|
|
|
|
test('a hybrid gets a source playground file as null, while the source gets it with scope playground', async t => {
|
|
const ref = scratchRepo(t, FILES);
|
|
const path = 'Book One/Playground/What If.md';
|
|
assert.equal(await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path, scope: 'playground' }), null);
|
|
const own = await get(src(ref), { universe: 'harbor', path, scope: 'playground' });
|
|
assert.equal(own.scope, 'playground');
|
|
});
|
|
|
|
// mos-1330 item3: the new get contract defaults to canon even for an explicit playground path.
|
|
test('C2-05c get without scope returns null for a playground path', async t => {
|
|
const s = src(scratchRepo(t, FILES));
|
|
assert.equal(await get(s, { universe: 'harbor', path: 'Book One/Playground/What If.md' }), null);
|
|
});
|
|
|
|
// Row284: scope is explicit for get as well as lists; blob and lines share a served snapshot.
|
|
test('C2-05c supporting find/search/get are opt-in at book, universe and draft level', async t => {
|
|
const s = src(scratchRepo(t, SUPPORT_FILES), mapping(withSupporting));
|
|
const expected = ['Book One/Support/Notes/Helper.md', 'Notes/HarborSupport/Loose.md'];
|
|
assert.deepEqual(paths(await find(s, { universe: 'harbor', query: '', scope: 'supporting' })), expected);
|
|
assert.deepEqual((await search(s, { universe: 'harbor', text: 'Scope fixture', scope: 'supporting' })).results.map(x => x.path), expected);
|
|
assert.deepEqual(paths(await find(s, { universe: 'harbor', query: '' })), ['Book One/Characters/Canon.md']);
|
|
assert.deepEqual((await search(s, { universe: 'harbor', text: 'Scope fixture' })).results.map(x => x.path), ['Book One/Characters/Canon.md']);
|
|
for (const path of expected) {
|
|
assert.equal(await get(s, { universe: 'harbor', path }), null);
|
|
assert.equal(await get(s, { universe: 'harbor', path, scope: 'playground' }), null);
|
|
assert.equal((await get(s, { universe: 'harbor', path, scope: 'supporting' })).scope, 'supporting');
|
|
assert.equal(await get(s, { universe: 'second', path, scope: 'supporting' }), null);
|
|
}
|
|
const draftPath = 'Drafts/Idea/Support/Seed.md';
|
|
assert.deepEqual(paths(await find(s, { universe: 'idea', query: '', scope: 'supporting' })), [draftPath]);
|
|
assert.equal((await get(s, { universe: 'idea', path: draftPath, scope: 'supporting' })).scope, 'supporting');
|
|
assert.equal(await get(s, { universe: 'idea', path: draftPath }), null);
|
|
assert.equal((await find(s, { universe: 'harbor', query: '', book: 'Book One', scope: 'supporting' })).results.length, 1);
|
|
});
|
|
|
|
test('C2-05c an absent supporting prefix returns nothing without refusal', async t => {
|
|
const s = src(scratchRepo(t, SUPPORT_FILES));
|
|
assert.deepEqual((await find(s, { universe: 'harbor', query: '', scope: 'supporting' })).results, []);
|
|
assert.deepEqual((await search(s, { universe: 'harbor', text: 'Scope fixture', scope: 'supporting' })).results, []);
|
|
assert.equal(await get(s, { universe: 'harbor', path: 'Book One/Characters/Canon.md', scope: 'supporting' }), null);
|
|
assert.equal(await get(s, { universe: 'harbor', path: 'Book One/Support/Notes/Helper.md', scope: 'supporting' }), null);
|
|
});
|
|
|
|
test('C2-05c each get blob equals its served tree object, even after source HEAD advances', async t => {
|
|
const ref = scratchRepo(t, SUPPORT_FILES), s = src(ref, mapping(withSupporting));
|
|
const canon = 'Book One/Characters/Canon.md';
|
|
const priorBlob = git(ref.repo, 'rev-parse', `${ref.commit}:${canon}`);
|
|
writeFileSync(join(ref.repo, canon), note('character', 'Canon changed', 'New HEAD must not be read.'));
|
|
git(ref.repo, 'add', '-A'); git(ref.repo, 'commit', '-qm', 'advance head fixture');
|
|
assert.notEqual(git(ref.repo, 'rev-parse', `HEAD:${canon}`), priorBlob);
|
|
for (const [scope, path] of [['canon', canon], ['playground', 'Book One/Playground/Plan.md'], ['supporting', 'Book One/Support/Notes/Helper.md']]) {
|
|
const result = await get(s, { universe: 'harbor', path, scope });
|
|
assert.equal(result.commit, ref.commit);
|
|
assert.equal(result.blob, git(ref.repo, 'rev-parse', `${ref.commit}:${path}`));
|
|
assert.ok(!JSON.stringify(result.lines).includes('New HEAD must not be read'));
|
|
}
|
|
});
|
|
|
|
test('C2-05c full outgoing and incoming links stay in scope order across pages without losing cross-scope links', async t => {
|
|
const files = {}, groups = { supporting: [], playground: [], canon: [] };
|
|
for (const [scope, folder] of [['canon', 'Book One/Characters'], ['playground', 'Book One/Playground'], ['supporting', 'Book One/Support']]) {
|
|
for (let n = 0; n < 100; n++) {
|
|
const path = `${folder}/${scope}-${String(n).padStart(3, '0')}-${'x'.repeat(80)}.md`;
|
|
groups[scope].push(path);
|
|
files[path] = `[[Book One/Characters/Hub]]\n`;
|
|
}
|
|
}
|
|
const all = [...groups.canon, ...groups.playground, ...groups.supporting];
|
|
files['Book One/Characters/Hub.md'] = all.map(path => `[[${path.slice(0, -3)}]]`).join('\n');
|
|
// Other-universe links are never returned, regardless of rank.
|
|
files['Second World/Other.md'] = '[[Book One/Characters/Hub]]\n';
|
|
const s = src(scratchRepo(t, files), mapping(withSupporting));
|
|
const outs = [], ins = [];
|
|
let offset = 0, pages = 0;
|
|
for (; pages < 20; pages++) {
|
|
const page = await get(s, { universe: 'harbor', path: 'Book One/Characters/Hub.md', from: 999_999, links_from: offset });
|
|
assert.ok(JSON.stringify(page).length <= QUERY_REPLY_CAP);
|
|
outs.push(...page.links_out.map(x => x.path)); ins.push(...page.links_in.map(x => x.path));
|
|
if (page.links_next === null) break;
|
|
offset = page.links_next;
|
|
}
|
|
const expected = [...groups.supporting, ...groups.playground, ...groups.canon];
|
|
assert.ok(pages > 0 && pages < 20, 'links occupy multiple bounded pages');
|
|
assert.deepEqual(outs, expected);
|
|
assert.deepEqual(ins, expected);
|
|
assert.equal(new Set(outs).size, 300);
|
|
assert.equal(new Set(ins).size, 300);
|
|
});
|
|
|
|
test('C2-05c manuscript still refuses supporting rather than silently widening its scope', async t => {
|
|
const s = src(scratchRepo(t, SUPPORT_FILES), mapping(withSupporting));
|
|
await assert.rejects(manuscript(s, { universe: 'harbor', book: 'Book One', scope: 'supporting' }), { code: 'query-argument-invalid' });
|
|
});
|
|
|
|
test('C2-05c scope expansion keeps hybrid sources canon-only and refuses all/array scopes', async t => {
|
|
const s = src(scratchRepo(t, SUPPORT_FILES), mapping(entry => withSupporting(WITH_BLEND(entry))));
|
|
assert.deepEqual((await find(s, { universe: 'blend', query: '', scope: 'supporting' })).results, []);
|
|
assert.equal(await get(s, { universe: 'blend', path: 'Book One/Support/Notes/Helper.md', scope: 'supporting' }), null);
|
|
assert.equal((await get(s, { universe: 'blend', path: 'Book One/Characters/Canon.md' })).scope, 'canon');
|
|
for (const scope of ['all', ['canon', 'supporting']])
|
|
await assert.rejects(get(s, { universe: 'harbor', path: 'Book One/Characters/Canon.md', scope }), { code: 'query-argument-invalid' });
|
|
});
|