Real-library audit (270 records) traced the data damage to:
- identity matching on ANY shared token ("Zwerg", "der Zwerg", "Mann") with
"longer text wins" merging (Arthag b02 = Kolon's species/relationships), and
- csRosterCanonicalName renaming a sheet to the first cast name among its
aliases, which saved 16 whole-sheet copies (Zwerg/Kolon = Arthag b03,
Fremder = Mann, Alchimist = Promos ...).
Fixes:
- static/js/character-identity.js + core/character_records.py: generic labels
are never identity evidence; csFindMergeKey, csConsolidateAliasDuplicates,
csRosterCanonicalName, clSameIdentity use it; own name always wins; clUpsert
writes to the exact-name record before any alias scan; no seed rename onto a
name that has its own record.
- _charsheets_parse: in cast-target mode drop other cast members' names from
aliases / first / last name (Kolon: "Sharraz Garthai"), strip reasoning
leakage ("Warte:", "Der Text sagt ...") from every text field.
- _character_scene_evidence: only a quote naming the character is used,
otherwise its own mannerisms (16 panels showed another character's scene).
- _character_age_category: Junge/Mädchen/boy/girl, number words, Sommer/
Winter ages, evidence clause, age-tagged quotes; "15 Jahre älter" is no age.
- scripts/audit_character_records.py (read-only) and
scripts/clean_character_records.py (dry run by default, --apply backs up).
Tests: tests/test_character_record_hygiene.py, tests/character-identity.test.mjs.
Docs: architecture/character-extraction.md, Manual, CHANGELOG.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
124 lines
7.3 KiB
JavaScript
124 lines
7.3 KiB
JavaScript
// 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());
|
|
});
|