cosmonarchy-bw-prerelease/tools/tbl/test/Tbl.test.ts
Kenny Udovic 36cc26b464 25 August 2026 -- tbl encoder decoder and image.tbl.txt loader
Ported from cosmonarchy-bw-release fe42f19. The .tbl.txt sources were
regenerated from this repo's own .tbl binaries rather than copied, so
images.tbl.txt and unitnames.tbl.txt carry the prerelease strings
(Askosi Sons of Ash, Faction Selection Askosi, Terran Fleshthief,
Forerunner Lumen) instead of the release ones.

tools/tbl is committed as real files here; in the release repo it is a
bare gitlink with no .gitmodules, so it comes out empty on clone.
2026-08-25 20:08:20 -04:00

132 lines
5.4 KiB
TypeScript

import { describe, expect, it } from "bun:test"
import * as Tbl from "../src/Tbl.ts"
import { entry, failWith, show, succeed } from "./helpers.ts"
/** Hand-assemble a .tbl so the tests do not lean on the encoder. */
const build = (blocks: Array<string>) => {
const header = 2 + blocks.length * 2
const data = blocks.join("")
const bytes = new Uint8Array(header + data.length)
const view = new DataView(bytes.buffer)
view.setUint16(0, blocks.length, true)
let cursor = header
blocks.forEach((block, i) => {
view.setUint16(2 + i * 2, cursor, true)
for (let j = 0; j < block.length; j++) bytes[cursor + j] = block.charCodeAt(j)
cursor += block.length
})
return bytes
}
/** Assemble a .tbl from an explicit offset table plus raw string data. */
const buildWithOffsets = (offsets: Array<number>, data: string) => {
const header = 2 + offsets.length * 2
const bytes = new Uint8Array(header + data.length)
const view = new DataView(bytes.buffer)
view.setUint16(0, offsets.length, true)
offsets.forEach((offset, i) => view.setUint16(2 + i * 2, offset, true))
for (let j = 0; j < data.length; j++) bytes[header + j] = data.charCodeAt(j)
return bytes
}
describe("decode", () => {
it("reads a simple table", async () => {
const table = await succeed(Tbl.decode(build(["one\0", "two\0"])))
expect(show(table)).toEqual([["one"], ["two"]])
})
it("keeps the NUL-separated parts of a multi-part entry together", async () => {
const table = await succeed(Tbl.decode(build(["Maverick\0*\0Ground Units\0", "Egg\0"])))
expect(show(table)).toEqual([["Maverick", "*", "Ground Units"], ["Egg"]])
})
it("gives the last entry everything up to the end of the file", async () => {
const table = await succeed(Tbl.decode(build(["a\0", "hotkey\0button text\0"])))
expect(show(table)).toEqual([["a"], ["hotkey", "button text"]])
})
it("reads an empty string entry", async () => {
expect(show(await succeed(Tbl.decode(build(["\0", "x\0"]))))).toEqual([[""], ["x"]])
})
it("returns an empty table when the count is zero", async () => {
expect(await succeed(Tbl.decode(Uint8Array.from([0, 0])))).toEqual([])
})
it("resolves shared offsets to the same entry", async () => {
// Entries 0 and 1 point at one copy of "one", as a deduped table would.
const bytes = buildWithOffsets([8, 8, 12], "one\0three\0")
expect(show(await succeed(Tbl.decode(bytes)))).toEqual([["one"], ["one"], ["three"]])
})
it("keeps trailing bytes no offset points at, as stat_txt.tbl relies on", async () => {
const bytes = buildWithOffsets([4], "a\0button text\0")
expect(show(await succeed(Tbl.decode(bytes)))).toEqual([["a", "button text"]])
})
it("rejects a file too short to hold a header", async () => {
const error = await failWith(Tbl.decode(Uint8Array.from([1])))
expect(error.message).toContain("truncated header")
})
it("rejects a truncated offset table", async () => {
const error = await failWith(Tbl.decode(Uint8Array.from([9, 0, 0, 0])))
expect(error.message).toContain("truncated offset table")
})
it("rejects an offset that points outside the string data", async () => {
const bytes = build(["one\0"])
new DataView(bytes.buffer).setUint16(2, 999, true)
const error = await failWith(Tbl.decode(bytes))
expect(error.message).toContain("outside the string data")
expect(error.offset).toBe(999)
})
it("rejects an entry that is not NUL-terminated", async () => {
const error = await failWith(Tbl.decode(build(["one", "two\0"])))
expect(error.message).toContain("not NUL-terminated")
})
})
describe("encode", () => {
it("reproduces the exact bytes it decoded", async () => {
const bytes = build(["Maverick\0*\0Ground Units\0", "\0", "Egg\0"])
expect(await succeed(Tbl.encode(await succeed(Tbl.decode(bytes))))).toEqual(bytes)
})
it("writes a well-formed header", async () => {
const bytes = await succeed(Tbl.encode([entry("ab"), entry("c")]))
const view = new DataView(bytes.buffer)
expect(view.getUint16(0, true)).toBe(2)
expect(view.getUint16(2, true)).toBe(6)
expect(view.getUint16(4, true)).toBe(9) // 6 + "ab\0"
expect(bytes.length).toBe(11) // 6 header + "ab\0" + "c\0"
})
it("treats an entry with no parts as a single empty string", async () => {
expect(show(await succeed(Tbl.decode(await succeed(Tbl.encode([[]])))))).toEqual([[""]])
})
it("shares identical entries when asked, and still decodes the same", async () => {
const table = Array.from({ length: 64 }, () => entry("Ground Units"))
const plain = await succeed(Tbl.encode(table))
const shared = await succeed(Tbl.encode(table, { dedupe: true }))
expect(shared.length).toBeLessThan(plain.length)
expect(show(await succeed(Tbl.decode(shared)))).toEqual(show(table))
})
it("refuses to write an offset a u16 cannot address", async () => {
const table = Array.from({ length: 100 }, (_, i) => entry(`${i}`.padEnd(1000, "x")))
const error = await failWith(Tbl.encode(table))
expect(error.message).toContain("past the 65535")
expect(error.message).toContain("--dedupe")
expect(error.offset).toBeGreaterThan(Tbl.MAX_OFFSET)
})
it("refuses to write more entries than the count field holds", async () => {
const table = Array.from({ length: Tbl.MAX_ENTRIES + 1 }, () => entry(""))
const error = await failWith(Tbl.encode(table))
expect(error.message).toContain("exceeds the 65535")
})
})