const { test } = require('node:test') const assert = require('node:assert/strict') const { ClilocFormatError, parseCliloc, parseClilocBinary, parseClilocText, isCompressedCliloc, displayText, isPlaceholderOnly, } = require('../src/utils/clilocParse') // These parsers are pure and fs-free precisely so this suite can run in CI, // where there is no UO client and no converted cliloc file. Every fixture below // is built from the real layout, and the strings are verbatim entries from a // real Cliloc.enu (123,490 entries) rather than invented ones. // ── Fixture builders ─────────────────────────────────────────────────────── /** Build a plain-format cliloc buffer: 6-byte header, then records. */ function buildBinary(entries, { header1 = 2, header2 = 1 } = {}) { const parts = [Buffer.alloc(6)] parts[0].writeInt32LE(header1, 0) parts[0].writeUInt16LE(header2, 4) for (const e of entries) { const text = Buffer.from(e.text, 'utf8') const head = Buffer.alloc(7) head.writeInt32LE(e.number, 0) head.writeUInt8(e.flag ?? 0, 4) head.writeUInt16LE(text.length, 5) parts.push(head, text) } return Buffer.concat(parts) } // ── Binary ───────────────────────────────────────────────────────────────── test('parseClilocBinary: reads a plain-format table', () => { const buf = buildBinary([ { number: 1015012, text: 'Greater Heal' }, { number: 1023721, text: 'quarter staff' }, { number: 1025913, flag: 1, text: 'bonnet' }, ]) assert.deepEqual(parseClilocBinary(buf), [ { number: 1015012, flag: 0, text: 'Greater Heal' }, { number: 1023721, flag: 0, text: 'quarter staff' }, { number: 1025913, flag: 1, text: 'bonnet' }, ]) }) test('parseClilocBinary: length is UNSIGNED 16-bit', () => { // ServUO's own SDK reads this field into a signed short, which turns any // string over 32 KB into a negative length. Real tables top out around 12 KB // so nothing is broken today, but the field is written unsigned and reading it // that way costs nothing. const text = 'x'.repeat(40000) const [entry] = parseClilocBinary(buildBinary([{ number: 1000000, text }])) assert.equal(entry.text.length, 40000) }) test('parseClilocBinary: multi-byte UTF-8 survives (length is in BYTES)', () => { const [entry] = parseClilocBinary(buildBinary([{ number: 1000000, text: 'Ilshenar — Ver Lor Reg' }])) assert.equal(entry.text, 'Ilshenar — Ver Lor Reg') }) test('parseClilocBinary: a truncated record body throws rather than importing short', () => { // The realistic corruption is a half-copied file. It must fail loudly: a // silently short table renders as "some items named, some not", which is // indistinguishable from having no table at all. const buf = buildBinary([{ number: 1023721, text: 'quarter staff' }]) const truncated = buf.subarray(0, buf.length - 4) assert.throws(() => parseClilocBinary(truncated), (err) => { assert.ok(err instanceof ClilocFormatError) assert.equal(err.code, 'TRUNCATED') return true }) }) test('parseClilocBinary: a truncated record HEADER throws too', () => { const buf = Buffer.concat([buildBinary([{ number: 1023721, text: 'quarter staff' }]), Buffer.alloc(3)]) assert.throws(() => parseClilocBinary(buf), (err) => err.code === 'TRUNCATED') }) test('parseClilocBinary: an empty table (header only) is valid', () => { assert.deepEqual(parseClilocBinary(buildBinary([])), []) }) // ── Compressed detection ─────────────────────────────────────────────────── test('isCompressedCliloc: recognises the Mythic marker', () => { // Every cliloc the client ships opens with a DWORD whose high byte is 0x8E. // Real first bytes of Cliloc.enu (e8 79 67 8e) and Cliloc.deu (99 5d 26 8e). assert.equal(isCompressedCliloc(Buffer.from([0xe8, 0x79, 0x67, 0x8e])), true) assert.equal(isCompressedCliloc(Buffer.from([0x99, 0x5d, 0x26, 0x8e])), true) assert.equal(isCompressedCliloc(buildBinary([])), false) }) test('parseCliloc: a compressed file is rejected by NAME, not parsed into nonsense', () => { // This is the whole reason the marker check exists. Without it the plain // parser reads compressed bytes as ~19k records of negative ids and 60 KB // "strings" before dying somewhere in the middle — and the resulting error // names truncation, which is the wrong problem to hand an operator. const compressed = Buffer.concat([Buffer.from([0xe8, 0x79, 0x67, 0x8e]), Buffer.alloc(64, 0x41)]) assert.throws(() => parseCliloc(compressed), (err) => { assert.equal(err.code, 'COMPRESSED') assert.match(err.message, /CLILOCS\.md/) return true }) }) // ── Text ─────────────────────────────────────────────────────────────────── test('parseClilocText: tab-delimited, skipping a header row', () => { const entries = parseClilocText('number\ttext\n1023721\tquarter staff\n1015012\tGreater Heal\n') assert.deepEqual(entries, [ { number: 1023721, flag: 0, text: 'quarter staff' }, { number: 1015012, flag: 0, text: 'Greater Heal' }, ]) }) test('parseClilocText: splits on the FIRST separator only', () => { // Cliloc text is full of commas. Splitting on all of them would truncate every // such entry at its first one. const [entry] = parseClilocText('1044000,a scroll of magery, unfinished\n') assert.equal(entry.text, 'a scroll of magery, unfinished') }) test('parseClilocText: unwraps quoted CSV fields and doubled quotes', () => { const [entry] = parseClilocText('1023721,"a ""quarter"" staff, plain"\n') assert.equal(entry.text, 'a "quarter" staff, plain') }) test('parseClilocText: reads an optional flag column', () => { const [entry] = parseClilocText('1025913\t1\tbonnet\n') assert.deepEqual(entry, { number: 1025913, flag: 1, text: 'bonnet' }) }) test('parseClilocText: text that is itself a number stays the text', () => { // `number,text` where text is "100" is indistinguishable from `number,flag` // with an empty text. Keeping it as the text is the safer miss — the other way // silently deletes a real entry. const [entry] = parseClilocText('1000000,100\n') assert.equal(entry.text, '100') }) test('parseClilocText: blank lines and # comments are ignored', () => { const entries = parseClilocText('# exported by hand\n\n1023721\tquarter staff\n\n') assert.equal(entries.length, 1) }) test('parseClilocText: a file with no entries is an error, not an empty table', () => { assert.throws(() => parseClilocText('nothing here\nnor here\n'), (err) => err.code === 'EMPTY') }) test('parseClilocText: an empty leading field is skipped, not imported as id 0', () => { // `Number('')` is 0, not NaN, so a line that merely starts with a separator // would otherwise become a bogus cliloc 0. assert.throws(() => parseClilocText('\tstray text\n,another\n'), (err) => err.code === 'EMPTY') }) test('parseClilocText: keeps entries whose text is EMPTY', () => { // About half of a real table is empty strings (unused ids). They must survive // parsing — the import layer decides whether to store them, and both input // formats have to agree on what the file contained. const entries = parseClilocText('1005008\t\n1023721\tquarter staff\n') assert.equal(entries.length, 2) assert.deepEqual(entries[0], { number: 1005008, flag: 0, text: '' }) }) // ── Sniffing ─────────────────────────────────────────────────────────────── test('parseCliloc: sniffs binary vs text from the header, not the extension', () => { assert.equal(parseCliloc(buildBinary([{ number: 1023721, text: 'quarter staff' }]))[0].text, 'quarter staff') assert.equal(parseCliloc(Buffer.from('1023721\tquarter staff\n'))[0].text, 'quarter staff') }) test('parseCliloc: a binary-looking header that is not 2/1 falls through to text', () => { // The recoverable guess: a mis-sniffed text file says "no entries found", // while a mis-sniffed binary yields plausible nonsense. assert.throws(() => parseCliloc(Buffer.from([9, 0, 0, 0, 9, 0, 65, 66])), (err) => err.code === 'EMPTY') }) // ── Display ──────────────────────────────────────────────────────────────── test('displayText: drops interpolated arguments we never receive', () => { // The bridge sends a cliloc id, never the property packet that carries the // arguments, so a name containing them has to be reduced to what is knowable. assert.equal(displayText('cold damage ~1_val~%'), 'cold damage') assert.equal(displayText('~1_NAME~ the ~2_TITLE~'), 'the') }) test('displayText: a string that is nothing but arguments resolves to nothing', () => { assert.equal(displayText('[~1_stuff~]'), '') assert.equal(isPlaceholderOnly('[~1_stuff~]'), true) assert.equal(isPlaceholderOnly('quarter staff'), false) }) test('displayText: a trailing % is only stripped when a placeholder was removed', () => { // "cold damage ~1_val~%" loses its % because that % was the unit belonging to // the number we never had. A string that genuinely ends in one keeps it. assert.equal(displayText('50%'), '50%') assert.equal(displayText('cold damage ~1_val~%'), 'cold damage') }) test('displayText: ordinary names pass through untouched', () => { assert.equal(displayText('quarter staff'), 'quarter staff') assert.equal(displayText('a scroll of magery, unfinished'), 'a scroll of magery, unfinished') assert.equal(displayText(' spiked collar '), 'spiked collar') }) test('displayText: null and undefined are empty, not "null"', () => { assert.equal(displayText(null), '') assert.equal(displayText(undefined), '') })