Files

268 lines
21 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { checkMapping, classify, LibraryError } from '../src/index.mjs';
import { mapping, mappingEntry } from './helpers.mjs';
const refuses = (change, code, pattern) => assert.throws(() => checkMapping(change(mappingEntry())),
e => e instanceof LibraryError && e.code === code && (!pattern || pattern.test(e.message)));
test('the fixture mapping checks and comes back frozen', () => {
const m = mapping();
assert.equal(m.universes.harbor.playground[0], 'Book One/Playground/');
assert.ok(Object.isFrozen(m.universes.harbor.books));
assert.throws(() => { m.types.Extra = 'place'; });
});
const draft = (playground = ['Drafts/New World/']) => ({ kind: 'draft', name: 'New World', playground });
test('C2-01c: a prefix inside or holding another entry\'s book refuses library-playground-other-universe', () => {
refuses(e => { e.universes.harbor.playground = ['Second World/Playground/']; return e; },
'library-playground-other-universe', /universes.harbor.playground "Second World\/Playground\/" lies inside book folder "Second World" of universes.second/);
refuses(e => { e.universes.second.books = ['Worlds/Second']; e.universes.harbor.playground = ['Worlds/']; return e; },
'library-playground-other-universe', /"Worlds\/" holds book folder "Worlds\/Second" of universes.second/);
// A draft has no books, so a draft prefix inside a book is always inside another entry's.
refuses(e => { e.universes.idea = draft(['Book One/New World/']); return e; },
'library-playground-other-universe', /universes.idea.playground "Book One\/New World\/" lies inside book folder "Book One" of universes.harbor/);
});
test('C2-01c: a prefix equal to a book folder, or holding its own, refuses library-mapping-invalid', () => {
// The whole book as its playground would leave no canon.
refuses(e => { e.universes.harbor.playground = ['Book One/']; return e; }, 'library-mapping-invalid', /is the whole book folder "Book One"/);
refuses(e => { e.universes.harbor.playground = ['Second World/']; return e; }, 'library-mapping-invalid', /is the whole book folder "Second World"/);
refuses(e => { e.universes.harbor.books = ['Shelf/Book One']; e.universes.harbor.playground = ['Shelf/']; return e; },
'library-mapping-invalid', /"Shelf\/" holds its own book folder "Shelf\/Book One"/);
});
test('C2-01c: universe-level prefixes lie outside every book, in a plain universe and in a hybrid with no books', () => {
const m = mapping(e => {
e.universes.harbor.playground = ['Book One/Playground/', 'Harbor Ideas/'];
e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: [], playground: ['Crossover Ideas/'] };
return e;
});
assert.deepEqual(m.universes.harbor.playground, ['Book One/Playground/', 'Harbor Ideas/']);
assert.deepEqual([m.universes.cross.books, m.universes.cross.playground], [[], ['Crossover Ideas/']]);
});
test('C2-01c: a draft takes kind, name and playground only, and comes back frozen with books []', () => {
const m = mapping(e => { e.universes.idea = draft(); return e; });
assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'], supporting: [] });
assert.ok(Object.isFrozen(m.universes.idea) && Object.isFrozen(m.universes.idea.books) && Object.isFrozen(m.universes.idea.playground));
refuses(e => { e.universes.idea = { ...draft(), books: ['New World'] }; return e; }, 'library-mapping-invalid', /universes.idea is a draft and takes no books/);
refuses(e => { e.universes.idea = { ...draft(), books: [] }; return e; }, 'library-mapping-invalid', /takes no books/);
refuses(e => { e.universes.idea = draft([]); return e; }, 'library-mapping-invalid', /universes.idea.playground must name at least one prefix/);
refuses(e => { e.universes.idea = { kind: 'draft', name: 'New World' }; return e; }, 'library-mapping-invalid', /at least one prefix/);
refuses(e => { e.universes.idea = { ...draft(), notes: 'x' }; return e; }, 'library-mapping-invalid', /unknown key "notes"/);
for (const kind of ['library', 'Draft', 'plain', null, 1])
refuses(e => { e.universes.harbor.kind = kind; return e; }, 'library-mapping-invalid', /universes.harbor.kind must be "hybrid" or "draft"/);
});
test('C2-01d: supporting prefixes at book level, universe level, in a hybrid and in a draft, [] when absent', () => {
const m = mapping(e => {
e.universes.harbor.supporting = ['Book One/Notes/', 'Harbor Notes/'];
e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: [], supporting: ['Crossover Notes/'] };
e.universes.idea = { ...draft(), supporting: ['Drafts/New World Notes/'] };
return e;
});
assert.deepEqual(m.universes.harbor.supporting, ['Book One/Notes/', 'Harbor Notes/']);
assert.deepEqual(m.universes.cross.supporting, ['Crossover Notes/']);
assert.deepEqual(m.universes.idea.supporting, ['Drafts/New World Notes/']);
assert.deepEqual(m.universes.second.supporting, []);
assert.ok(Object.isFrozen(m.universes.harbor.supporting) && Object.isFrozen(m.universes.second.supporting));
assert.deepEqual(mapping().universes.harbor.supporting, []);
});
test('C2-01d: each supporting prefix rule refuses with its code', () => {
const invalid = (change, pattern) => refuses(change, 'library-mapping-invalid', pattern);
invalid(e => { e.universes.harbor.supporting = ['Book One/Notes']; return e; }, /universes.harbor.supporting holds "Book One\/Notes", which is not a folder path ending in \//);
invalid(e => { e.universes.harbor.supporting = ['Book One/../Notes/']; return e; }, /which is not a folder path ending in \//);
invalid(e => { e.universes.harbor.supporting = 'Book One/Notes/'; return e; }, /universes.harbor.supporting must be a list/);
invalid(e => { e.universes.harbor.supporting = ['Book One/Notes/', 'Book One/Notes/Deep/']; return e; },
/supporting "Book One\/Notes\/" holds supporting "Book One\/Notes\/Deep\/"/);
invalid(e => { e.universes.harbor.supporting = ['Ideas/']; e.universes.idea = { ...draft(), supporting: ['Ideas/'] }; return e; },
/a supporting prefix is listed twice/);
invalid(e => { e.universes.harbor.supporting = ['Book One/']; return e; }, /universes.harbor.supporting "Book One\/" is the whole book folder "Book One"/);
invalid(e => { e.universes.harbor.books = ['Shelf/Book One']; e.universes.harbor.playground = []; e.universes.harbor.supporting = ['Shelf/']; return e; },
/universes.harbor.supporting "Shelf\/" holds its own book folder "Shelf\/Book One"/);
// Supporting is checked first in scope, so a supporting prefix holding a playground would hide it.
invalid(e => { e.universes.harbor.supporting = ['Book One/Workshop/']; e.universes.harbor.playground = ['Book One/Workshop/Playground/']; return e; },
/universes.harbor.supporting "Book One\/Workshop\/" holds playground "Book One\/Workshop\/Playground\/"/);
// Across entries any overlap would give one path two universes.
for (const [supporting, playground] of [['Ideas/', 'Ideas/'], ['Ideas/Deep/', 'Ideas/'], ['Ideas/', 'Ideas/Deep/']])
invalid(e => { e.universes.harbor.supporting = [supporting]; e.universes.idea = draft([playground]); return e; },
new RegExp(`universes.harbor.supporting "${supporting}" overlaps playground "${playground}" of universes.idea`));
refuses(e => { e.universes.harbor.supporting = ['Second World/Notes/']; return e; },
'library-supporting-other-universe', /universes.harbor.supporting "Second World\/Notes\/" lies inside book folder "Second World" of universes.second/);
refuses(e => { e.universes.second.books = ['Worlds/Second']; e.universes.harbor.supporting = ['Worlds/']; return e; },
'library-supporting-other-universe', /universes.harbor.supporting "Worlds\/" holds book folder "Worlds\/Second" of universes.second/);
refuses(e => { e.universes.idea = { ...draft(), supporting: ['Book One/Idea Notes/'] }; return e; },
'library-supporting-other-universe', /universes.idea.supporting "Book One\/Idea Notes\/" lies inside book folder "Book One" of universes.harbor/);
});
test('C2-01d: between the lists, a supporting prefix may equal its own playground, and a playground may hold its own supporting', () => {
const same = mapping(e => { e.universes.harbor.supporting = ['Book One/Playground/']; return e; });
assert.deepEqual([same.universes.harbor.playground, same.universes.harbor.supporting], [['Book One/Playground/'], ['Book One/Playground/']]);
const nested = mapping(e => { e.universes.harbor.supporting = ['Book One/Playground/Notes/']; return e; });
assert.deepEqual(nested.universes.harbor.supporting, ['Book One/Playground/Notes/']);
});
test('C2-01d: editor is optional, kept when given, and refuses anything but a plain name and email', () => {
assert.equal(Object.hasOwn(mapping(), 'editor'), false);
const editor = { name: 'Her Name', email: '[email protected]' };
const m = mapping(e => ({ ...e, editor }));
assert.deepEqual(m.editor, editor);
assert.notEqual(m.editor, editor);
assert.ok(Object.isFrozen(m.editor));
const invalid = (value, pattern) => refuses(e => ({ ...e, editor: value }), 'library-mapping-invalid', pattern);
for (const value of [null, 'Her Name', 7, ['Her Name', '[email protected]']]) invalid(value, /editor must be an object/);
invalid({ ...editor, signingKey: 'x' }, /editor has unknown key "signingKey"/);
// sage-2925: an optional Gitea login for her save, checked as the entry's user is.
assert.deepEqual(mapping(e => ({ ...e, editor: { ...editor, user: 'her.login-1' } })).editor, { ...editor, user: 'her.login-1' });
for (const user of ['', 'her login', 'her@host', 'a'.repeat(101), 7, null])
invalid({ ...editor, user }, /editor.user must be a plain account name/);
invalid({ name: 'Her Name' }, /editor.email must be non-empty text with no control character, < or >/);
invalid({ email: '[email protected]' }, /editor.name must be non-empty text/);
for (const bad of ['', ' ', 7, null, 'a\u0000b', 'a\nb', 'a\u001fb', 'a\u007fb', 'a\u0085b', 'a\u009fb', '<her>', 'a<b', 'a>b']) {
invalid({ ...editor, name: bad }, /editor.name must be non-empty text with no control character, < or >/);
invalid({ ...editor, email: bad }, /editor.email must be non-empty text with no control character, < or >/);
}
// sage-3601, sage-3606: git's crud() strips whitespace and , : ; < > " \ ' from both ends of an
// ident, so a value starting or ending with one refuses. Inner < > and controls refuse above.
const ends = [' ', '\u00a0', '\u3000', ',', ':', ';', '"', "'", '\\'];
for (const c of ends) for (const padded of [`${c}Her Name`, `Her Name${c}`]) {
invalid({ ...editor, name: padded }, /editor.name must not start or end with whitespace or any of , : ; " ' \\/);
invalid({ ...editor, email: padded }, /editor.email must not start or end with whitespace or any of , : ; " ' \\/);
}
for (const c of ['<', '>', '\n', '\t']) for (const padded of [`${c}Her Name`, `Her Name${c}`, `Her${c}Name`])
invalid({ ...editor, name: padded }, /editor.name must be non-empty text with no control character, < or >/);
assert.equal(mapping(e => ({ ...e, editor: { name: 'Her Middle Name', email: '[email protected]' } })).editor.name, 'Her Middle Name');
for (const name of ['Doe, Jane Q.', 'Jane: "Q" Doe', "O'Neil", 'Back\\slash Name'])
assert.equal(mapping(e => ({ ...e, editor: { name, email: '[email protected]' } })).editor.name, name);
});
// sage-3606: an accepted editor commits under exactly the saved ident, as author and committer. A
// future git that strips more from an ident fails here.
test('C2-01d: an accepted editor round-trips through a real git commit unchanged', t => {
const editor = mapping(e => ({ ...e, editor: { name: 'Doe, Jane Q.', email: '[email protected].' } })).editor;
const root = mkdtempSync(join(tmpdir(), 'library-ident-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
// An ident in the environment would win over -c, so the commit runs without one. GIT_DIR and its
// family would beat -C (inside a hook, say), and git reads no global or system config (webby-0236 E5).
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^GIT_(AUTHOR|COMMITTER)_/.test(k)));
for (const k of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE']) delete env[k];
Object.assign(env, { GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' });
const git = (...args) => execFileSync('git', ['-c', `user.name=${editor.name}`, '-c', `user.email=${editor.email}`,
'-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', '-C', root, ...args], { encoding: 'utf8', env });
git('init', '-q');
git('commit', '-q', '--allow-empty', '-m', 'ident');
assert.equal(git('log', '-1', '--format=%an%n%ae%n%cn%n%ce'), `${editor.name}\n${editor.email}\n${editor.name}\n${editor.email}\n`);
});
test('no playground prefix holds another, and no book folder holds another', () => {
refuses(e => { e.universes.harbor.playground = ['Book One/Playground/', 'Book One/Playground/Deep/']; return e; },
'library-mapping-invalid', /holds playground/);
// The rule covers every level and every entry (C2-01c).
refuses(e => { e.universes.harbor.playground = ['Ideas/']; e.universes.idea = draft(['Ideas/New World/']); return e; },
'library-mapping-invalid', /playground "Ideas\/" holds playground "Ideas\/New World\/"/);
refuses(e => { e.universes.harbor.playground = ['Ideas/']; e.universes.idea = draft(['Ideas/']); return e; },
'library-mapping-invalid', /listed twice/);
refuses(e => { e.universes.second.books = ['Second World', 'Book One/Part Two']; return e; }, 'library-mapping-invalid', /holds book folder/);
refuses(e => { e.universes.second.books = ['Book One']; return e; }, 'library-mapping-invalid', /in two universes/);
});
test('shape refusals: kind, remote, branch, unknown keys, types, manuscript, folder names', () => {
refuses(e => ({ ...e, kind: 'code' }), 'library-mapping-invalid', /kind/);
refuses(e => ({ ...e, remote: 'http://git.example.invalid/x.git' }), 'library-mapping-invalid', /https/);
refuses(e => ({ ...e, remote: 'https://user:[email protected]/x.git' }), 'library-mapping-invalid', /credentials/);
refuses(e => ({ ...e, remote: '[email protected]:x.git' }), 'library-mapping-invalid', /https/);
refuses(e => ({ ...e, branch: 'main..x' }), 'library-mapping-invalid', /branch/);
refuses(e => ({ ...e, root: '/tmp' }), 'library-mapping-invalid', /unknown key "root"/);
refuses(e => { e.universes.harbor.extra = 1; return e; }, 'library-mapping-invalid', /unknown key/);
refuses(e => { e.types.Characters = 'villain'; return e; }, 'library-mapping-invalid', /types.Characters/);
refuses(e => { e.types['A/B'] = 'place'; return e; }, 'library-mapping-invalid', /one folder name/);
refuses(e => ({ ...e, manuscript: ['Characters'] }), 'library-mapping-invalid', /also a type folder/);
refuses(e => { e.universes.harbor.books = ['../Book One']; return e; }, 'library-mapping-invalid', /folder path/);
refuses(e => ({ ...e, vaultPrefix: 'a/b' }), 'library-mapping-invalid', /vaultPrefix/);
refuses(e => ({ ...e, universes: {} }), 'library-mapping-invalid', /at least one universe/);
refuses(e => { e.universes['bad id'] = e.universes.second; delete e.universes.second; return e; }, 'library-mapping-invalid', /plain id/);
});
test('C2-03a sage-2021: user is optional, kept when given, and must be a plain account name', () => {
assert.equal(Object.hasOwn(mapping(), 'user'), false);
assert.equal(mapping(e => ({ ...e, user: 'books-bot' })).user, 'books-bot');
assert.equal(mapping(e => ({ ...e, user: 'A_b.c-9'.padEnd(100, 'x') })).user.length, 100);
for (const user of ['', 'a'.repeat(101), 'books bot', 'bot@host', 'bot:pw', 'bot/x', 'bøt', 'bot\n', 7, null])
refuses(e => ({ ...e, user }), 'library-mapping-invalid', /user must be a plain account name/);
});
test('a hybrid universe is a shape the query code can take (1.4), with sources that must exist', () => {
const hybrid = e => { e.universes.cross = { kind: 'hybrid', name: 'Crossover', sources: ['harbor', 'second'], books: ['Crossover'],
constraints: [{ exclude: 'second/Characters' }, { pin: { entity: 'Book One/Characters/Mara Vell.md', field: 'eyes', value: 'grey' } }],
notes: 'Set after book one.' }; return e; };
const m = checkMapping(hybrid(mappingEntry()));
assert.deepEqual(m.universes.cross.sources, ['harbor', 'second']);
refuses(e => { hybrid(e); e.universes.cross.sources = ['harbor', 'nowhere']; return e; }, 'library-mapping-invalid', /not a plain universe/);
// A draft has no canon to draw on (C2-01c).
refuses(e => { hybrid(e); e.universes.idea = draft(); e.universes.cross.sources = ['harbor', 'idea']; return e; },
'library-mapping-invalid', /sources names "idea", which is not a plain universe/);
refuses(e => { hybrid(e); e.universes.cross.sources = ['harbor']; return e; }, 'library-mapping-invalid', /two or more/);
refuses(e => { hybrid(e); e.universes.cross.constraints = [{ exclude: 'x', pin: {} }]; return e; }, 'library-mapping-invalid', /exactly one/);
});
test('C2-15: each exclude and pin is checked against the hybrid\'s sources', () => {
const cross = (...constraints) => e => {
e.universes.cross = { kind: 'hybrid', name: 'Crossover', sources: ['harbor', 'second'], books: ['Crossover'], constraints };
return e;
};
const pin = entity => ({ pin: { entity, field: 'eyes', value: 'grey' } });
// Both forms, a whole source book and a pin in a source book check.
assert.equal(mapping(cross({ exclude: 'second/Characters' }, { exclude: 'Book One/Research' }, { exclude: 'Second World' },
pin('Second World/Characters/Ilse.md'))).universes.cross.constraints.length, 4);
// Both readings fit: second's book folder is also named "second".
refuses(e => { cross({ exclude: 'second/Characters' })(e); e.universes.second.books = ['second']; return e; },
'library-mapping-invalid', /constraints\[0\]\.exclude "second\/Characters" reads as both a source\/type and a source path/);
// Neither fits: an unmapped path, the hybrid's own book, a type that isn't a key, a non-source universe.
for (const exclude of ['Elsewhere/Characters', 'Crossover/Characters', 'second/Ghosts', 'harbor', 'Book'])
refuses(cross({ exclude }), 'library-mapping-invalid', /\.exclude ".*" lies inside no book of the hybrid's sources/);
refuses(e => { cross({ exclude: 'third/Characters' })(e); e.universes.third = { name: 'Third', books: ['Third World'] }; return e; },
'library-mapping-invalid', /lies inside no book of the hybrid's sources/);
// Under a source's playground, as the prefix itself or inside it.
for (const exclude of ['Book One/Playground', 'Book One/Playground/Ideas'])
refuses(cross({ exclude }), 'library-mapping-invalid', /\.exclude ".*" lies under a playground of universes\.harbor, which the hybrid never reads/);
refuses(cross(pin('Book One/Playground/What If.md')), 'library-mapping-invalid',
/\.pin\.entity "Book One\/Playground\/What If\.md" lies under a playground of universes\.harbor/);
refuses(cross(pin('Crossover/Characters/Mara Vell.md')), 'library-mapping-invalid', /\.pin\.entity .* lies inside no book of the hybrid's sources/);
// Q5: a pinned entity is one .md note.
for (const entity of ['Book One/Characters/Mara Vell', 'Book One/Characters', 'Book One/Board.canvas'])
refuses(cross(pin(entity)), 'library-mapping-invalid', /\.pin\.entity ".*" is not a \.md file/);
});
test('a caller-supplied fail receives the code, as business file loading will pass it (C2-04)', () => {
const seen = [];
const fail = (code, message) => { seen.push(code); throw new Error(message); };
const entry = mappingEntry();
entry.universes.harbor.playground = ['Second World/Playground/'];
assert.throws(() => checkMapping(entry, 'businesses/acme.json projects.books', { fail }), /businesses\/acme.json projects.books/);
assert.deepEqual(seen, ['library-playground-other-universe']);
});
test('scope comes from the path alone (design 1.2)', () => {
const m = mapping();
assert.deepEqual(classify(m, 'Book One/Characters/Mara Vell.md'),
{ scope: 'canon', universe: 'harbor', book: 'Book One', type: 'character', folder: 'Characters' });
assert.deepEqual(classify(m, 'Book One/Chapters/Chapter 01.md'),
{ scope: 'canon', universe: 'harbor', book: 'Book One', type: 'manuscript', folder: 'Chapters' });
assert.deepEqual(classify(m, 'Book One/Playground/What If.md'),
{ scope: 'playground', universe: 'harbor', book: 'Book One', type: null, folder: null });
assert.deepEqual(classify(m, 'Book One/Playground/Characters/New.md'),
{ scope: 'playground', universe: 'harbor', book: 'Book One', type: 'character', folder: 'Characters' });
assert.deepEqual(classify(m, 'Book One/Notes.md'), { scope: 'canon', universe: 'harbor', book: 'Book One', type: null, folder: null });
assert.equal(classify(m, 'Second World/Chapters/01.md').universe, 'second');
for (const path of ['Inbox Note.md', 'Book One/Board.canvas', 'Book One/Playground/Sketch.canvas', 'Book One/Board.base',
'Book One/cover.png', 'Book One/Upper.MD', 'Book Onex/a.md', 'book one/a.md', 'Elsewhere/Book One/a.md'])
assert.deepEqual(classify(m, path), { scope: 'unmapped' }, path);
});