// Character identity / merge rules. Run: npm run test:unit // Fixtures are shaped like the real book-02/03 records found broken on 2026-10-03 // (see architecture/character-extraction.md). import test from 'node:test'; import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; import { readFileSync } from 'node:fs'; import vm from 'node:vm'; const ci = createRequire(import.meta.url)('../static/js/character-identity.js'); const BOOK = 'Das Jahr des Greifen 03'; function loadBrowserFiles(files, extra = {}) { const el = new Proxy(function () {}, { get: (t, k) => (k === Symbol.toPrimitive ? () => '' : el), apply: () => el }); const sandbox = { console, setTimeout, clearTimeout, URL, Promise, Map, Set, JSON, document: { querySelectorAll: () => [], querySelector: () => null, getElementById: () => null, addEventListener() {}, createElement: () => el, body: el }, $: () => null, toast() {}, indexedDB: { open: () => ({}) }, ...extra, }; sandbox.window = sandbox; sandbox.globalThis = sandbox; vm.createContext(sandbox); for (const f of files) vm.runInContext(readFileSync(new URL(`../static/js/${f}`, import.meta.url), 'utf8'), sandbox, { filename: f }); return sandbox; } test('generic labels are not names', () => { for (const t of ['Zwerg', 'der Zwerg', 'Mann', 'Fremder', 'Fremde', 'Alter Soldat', 'Ork-General', 'Bürgerwehroffizier', 'der Tunneltreiber', 'Kriegshäuptling', 'Erster Offizier', 'junges Mädchen', 'the Executioner']) { assert.equal(ci.isGenericIdentityToken(t), true, t); } for (const t of ['Arthag', 'Kolon', 'Sharraz Garthai', 'Admiral Sanin', 'Schlange Gamba', 'Gutfolg', 'Marcus the Executioner']) { assert.equal(ci.isGenericIdentityToken(t), false, t); } assert.deepEqual(ci.sharedIdentityEvidence(['arthag', 'der zwerg', 'zwerg'], ['kolon', 'zwerg', 'der zwerg']), []); assert.deepEqual(ci.sharedIdentityEvidence(['marcian', 'inquisitor'], ['inquisitor', 'marcian']), ['marcian']); }); // Book 03: Arthag's saved aliases contained "der Zwerg, Zwerg, ..., Kolon" and the // cast roster listed Zwerg and Kolon before Arthag. The roster canonicaliser // picked the first roster name among ALL tokens, renaming Arthag's sheet, and // the save created Zwerg/Kolon as full copies of Arthag. test('a sheet keeps its own cast name instead of a roster name found in its aliases', () => { const sb = loadBrowserFiles(['character-identity.js', 'character-sheets.js']); const { csRosterCanonicalName } = sb.TTVC.characterSheets._identity; const arthag = { name: 'Arthag', aliases: 'der Zwerg, Zwerg, Schreiber, Begleiter, Kolon, der Tunneltreiber' }; assert.equal(csRosterCanonicalName(arthag, ['Zwerg', 'Kolon', 'Arthag']), 'Arthag'); // A legacy record whose name is not cast maps only through a real name, never "Zwerg". const legacy = { name: 'Amboßzwerg', aliases: 'Zwerg, der Zwerg' }; assert.equal(csRosterCanonicalName(legacy, ['Zwerg', 'Kolon']), ''); assert.equal(csRosterCanonicalName({ name: 'Murlok', aliases: 'Zerwas, Vampir' }, ['Vampir', 'Zerwas']), 'Zerwas'); }); test('discovery merge does not fold two characters together over a generic alias', () => { const sb = loadBrowserFiles(['character-identity.js', 'character-sheets.js']); const { csMerge } = sb.TTVC.characterSheets._identity; const map = new Map(); csMerge(map, [{ name: 'Arthag', aliases: 'der Zwerg, Zwerg', race_species: 'Zwerg', relationships: 'Nyrilla (Gefährtin)', sources: [] }]); csMerge(map, [{ name: 'Kolon', aliases: 'Zwerg, Ork-General', race_species: 'Mensch (oder Ork-Verbündeter, Kontext unklar ...)', relationships: 'Führt die Orks; dient Sharraz Garthai; arbeitet mit Drasdech', sources: [] }]); assert.deepEqual([...map.keys()].sort(), ['arthag', 'kolon']); assert.equal(map.get('arthag').race_species, 'Zwerg'); assert.equal(map.get('arthag').relationships, 'Nyrilla (Gefährtin)'); // A real shared name still merges (the alias proves the identity). csMerge(map, [{ name: 'Murlok von Mengbilla', aliases: 'Arthag', notes: 'x', sources: [] }]); assert.deepEqual([...map.keys()].sort(), ['arthag', 'kolon']); }); test('consolidation ignores a generic alias that happens to be another entry name', () => { const sb = loadBrowserFiles(['character-identity.js', 'character-sheets.js']); const { csConsolidateAliasDuplicates } = sb.TTVC.characterSheets._identity; const map = new Map([ ['mann', { name: 'Mann', aliases: 'Fremde, Offizier, Fremder, Gutfolg', physical: 'rothaarig', sources: [], inventory: [] }], ['fremder', { name: 'Fremder', aliases: '', physical: 'Kapuze', sources: [], inventory: [] }], ]); csConsolidateAliasDuplicates(map, new Set(), new Map(), []); assert.deepEqual([...map.keys()].sort(), ['fremder', 'mann']); assert.equal(map.get('fremder').physical, 'Kapuze'); }); function libraryWithStore(records) { const store = new Map(records.map(r => [r.id, structuredClone(r)])); const writes = []; const fetch = async (url, opts = {}) => { const u = String(url); const json = (body, status = 200) => ({ ok: status < 400, status, json: async () => body }); if (u === '/api/characters' || u.startsWith('/api/characters?')) return json({ characters: [...store.values()] }); const id = decodeURIComponent(u.replace('/api/characters/', '')); if (!opts.method || opts.method === 'GET') return store.has(id) ? json(store.get(id)) : json({}, 404); if (opts.method === 'PUT') { const rec = JSON.parse(opts.body); store.set(id, rec); writes.push(id); return json(rec); } return json({ ok: true }); }; const sb = loadBrowserFiles(['character-identity.js', 'characters-library.js'], { fetch }); return { sb, store, writes }; } // Book 02: Kolon's extracted aliases named "Sharraz" (a separate cast record). // The upsert scanned all records for ANY shared token before looking at the // record that already carries the name, so Kolon's sheet could land in Sharraz. test('library upsert writes to the record with the exact name, not an alias-linked one', async () => { const { sb, store, writes } = libraryWithStore([ { id: `${BOOK}::sharraz`.toLowerCase(), book: BOOK, name: 'Sharraz', sheet: { name: 'Sharraz', race_species: 'Ork' } }, { id: `${BOOK}::kolon`.toLowerCase(), book: BOOK, name: 'Kolon', sheet: { name: 'Kolon', race_species: 'Zwerg' } }, ]); await sb.clUpsert(BOOK, { name: 'Kolon', aliases: 'Sharraz, Ork-General', physical: 'weißer Bart' }); assert.deepEqual(writes, [`${BOOK}::kolon`.toLowerCase()]); assert.equal(store.get(`${BOOK}::sharraz`.toLowerCase()).sheet.physical, undefined); }); test('library upsert does not redirect a new character into a record sharing only "Zwerg"', async () => { const { sb, store, writes } = libraryWithStore([ { id: `${BOOK}::arthag`.toLowerCase(), book: BOOK, name: 'Arthag', sheet: { name: 'Arthag', aliases: 'der Zwerg, Zwerg', race_species: 'Zwerg' } }, ]); await sb.clUpsert(BOOK, { name: 'Kolon', aliases: 'Zwerg, der Zwerg', race_species: 'Zwerg', relationships: 'Sharraz Garthai' }); assert.deepEqual(writes, [`${BOOK}::kolon`.toLowerCase()]); assert.equal(store.get(`${BOOK}::arthag`.toLowerCase()).sheet.relationships, undefined); // A real shared name still finds the existing record (dedupe of a renamed sheet). await sb.clUpsert(BOOK, { name: 'Amboßzwerg Arthag', aliases: 'Arthag', notes: 'Schreiber' }); assert.equal(writes.at(-1), `${BOOK}::arthag`.toLowerCase()); });