import { describe, expect, test } from "bun:test" import { valueChecks } from "./fidelity-values.mjs" import { buildContext } from "./gates-static.mjs" import { parseHtml } from "./html-lite.mjs" import { escapeHtml } from "./placeholder.mjs" import { F1, codes, filled, prose } from "./tests/value-mode-support" import { dimensionOf, normalizeUnit } from "./units.mjs" // One table per class of value-mode unit reading; every row is { input, expected } and a run lists every row that // disagrees. Korean text is written as \u escapes (romanized in the comments). type Verdict = "pass" | "unit_mismatch" | "unit_unbound" const verdictOf = (found: string[]): Verdict => (found.includes("unit_mismatch") ? "unit_mismatch" : found.includes("unit_unbound") ? "unit_unbound" : "pass") // Hand-written oracle: every alias units.mjs accepts, by canonical token (internal scale tokens such as e3 omitted). const ALIASES: [string, string[]][] = [ ["mm", ["mm", "millimeter", "millimeters", "millimetre", "millimetres", "\uBC00\uB9AC\uBBF8\uD130"]], ["cm", ["cm", "centimeter", "centimeters", "centimetre", "centimetres", "\uC13C\uD2F0\uBBF8\uD130"]], ["m", ["m", "meter", "meters", "metre", "metres", "\uBBF8\uD130"]], ["km", ["km", "kilometer", "kilometers", "kilometre", "kilometres", "\uD0AC\uB85C\uBBF8\uD130"]], ["g", ["g", "gram", "grams", "\uADF8\uB7A8"]], ["kg", ["kg", "kilogram", "kilograms", "\uD0AC\uB85C\uADF8\uB7A8"]], ["t", ["t", "ton", "tons", "tonne", "tonnes", "\uD1A4"]], ["ms", ["ms", "millisecond", "milliseconds", "\uBC00\uB9AC\uCD08"]], ["s", ["s", "sec", "secs", "second", "seconds", "\uCD08"]], ["min", ["min", "mins", "minute", "minutes", "\uBD84"]], ["h", ["h", "hr", "hrs", "hour", "hours", "\uC2DC\uAC04"]], ["B", ["B", "byte", "bytes", "\uBC14\uC774\uD2B8"]], ["KB", ["KB", "\uD0AC\uB85C\uBC14\uC774\uD2B8"]], ["MB", ["MB", "\uBA54\uAC00\uBC14\uC774\uD2B8"]], ["GB", ["GB", "\uAE30\uAC00\uBC14\uC774\uD2B8"]], ["TB", ["TB", "\uD14C\uB77C\uBC14\uC774\uD2B8"]], ["C", ["C", "\u00B0C", "\u2103", "celsius", "\uC12D\uC528"]], ["F", ["F", "\u00B0F", "\u2109", "fahrenheit", "\uD654\uC528"]], ["%", ["%", "percent", "\uD37C\uC13C\uD2B8", "\uD504\uB85C"]], ["\u2030", ["\u2030", "permille", "\uD37C\uBC00"]], ["%p", ["%p", "pp", "%pt", "percentage point", "percentage points", "percent point", "percent points", "% point", "% points", "\uD37C\uC13C\uD2B8\uD3EC\uC778\uD2B8", "%\uD3EC\uC778\uD2B8"]], ["x", ["x", "\u00D7", "times", "\uBC30"]], ["e3", ["k", "thousand", "\uCC9C"]], ["e4", ["\uB9CC"]], ["e6", ["M", "million", "\uBC31\uB9CC"]], ["e8", ["\uC5B5"]], ["e9", ["bn", "billion", "\uC2ED\uC5B5"]], ["e12", ["trillion", "\uC870"]], ["person", ["person", "persons", "people", "\uBA85"]], ["case", ["case", "cases", "\uAC74"]], ["KRW", ["KRW", "\uC6D0", "\u20A9", "won"]], ["KRW-e3", ["\uCC9C\uC6D0"]], ["KRW-e4", ["\uB9CC\uC6D0"]], ["KRW-e8", ["\uC5B5\uC6D0"]], ["KRW-e12", ["\uC870\uC6D0"]], ["USD", ["USD", "$", "US$", "dollar", "dollars", "\uB2EC\uB7EC"]], ["EUR", ["EUR", "\u20AC", "euro", "euros", "\uC720\uB85C"]], ["JPY", ["JPY", "\u00A5", "yen", "\uC5D4"]], ["GBP", ["GBP", "\u00A3", "pound", "pounds", "\uD30C\uC6B4\uB4DC"]], ] // i, ga, eun, neun, eul, reul, ui, gwa, wa, ro, euro, e, eseo, do, man, buteo, kkaji. const PARTICLES = ["\uC774", "\uAC00", "\uC740", "\uB294", "\uC744", "\uB97C", "\uC758", "\uACFC", "\uC640", "\uB85C", "\uC73C\uB85C", "\uC5D0", "\uC5D0\uC11C", "\uB3C4", "\uB9CC", "\uBD80\uD130", "\uAE4C\uC9C0"] // chogwa, isang, miman, iha, jeongdo, garyang, jjeum: written glued after a unit. const GLUED_QUALIFIERS = ["\uCD08\uACFC", "\uC774\uC0C1", "\uBBF8\uB9CC", "\uC774\uD558", "\uC815\uB3C4", "\uAC00\uB7C9", "\uCBE4"] // The same plus gwaban (majority) and yak (about): whole words standing alone after the value. const QUALIFIER_WORDS = [...GLUED_QUALIFIERS, "\uACFC\uBC18", "\uC57D"] const CHOGWA = "\uCD08\uACFC" const ORDINARY = new Set(["won", "second", "pound", "pounds"]) const element = (unit: string) => `320` /** valueChecks over a Findings section holding one value-mode element of a row in `unit`. */ function readHtml(html: string, unit: string): Verdict { const ledger = { rows: [{ ...F1, unit, raw: `320 ${unit}` }] } return verdictOf((valueChecks(buildContext(parseHtml(`
Up to ${element(unit)}${escapeHtml(after)} [S4].
`, unit) type Case = { input: string; unit: string; expected: Verdict } function tokenizationCases(): Case[] { const cases: Case[] = [] for (const [token, aliases] of ALIASES) { const other = dimensionOf(token) === "length" ? "KRW" : "m" for (const alias of aliases) { const suffixes = [...["", ...PARTICLES].flatMap((p) => [` ${alias}${p}`, `${alias}${p}`]), ...GLUED_QUALIFIERS.map((q) => ` ${alias}${q}`)] for (const input of suffixes) { const qualifier = input.trim() === CHOGWA cases.push({ input, unit: token, expected: qualifier ? "unit_unbound" : "pass" }) cases.push({ input, unit: other, expected: qualifier || ORDINARY.has(alias) ? "unit_unbound" : "unit_mismatch" }) } } } for (const word of QUALIFIER_WORDS) cases.push({ input: ` ${word}`, unit: "m", expected: "unit_unbound" }, { input: ` m ${word}`, unit: "m", expected: "pass" }, { input: ` mm ${word}`, unit: "m", expected: "unit_mismatch" }) // English words that collide with unit aliases or read like them; "3 bun-ui 1" is a fraction, not minutes. const words: [string, string, Verdict][] = [ [" in total", "m", "unit_unbound"], [" a year", "m", "unit_unbound"], [" or more", "m", "unit_unbound"], [" or less", "m", "unit_unbound"], [" min", "min", "pass"], [" min", "m", "unit_mismatch"], [" sec", "s", "pass"], [" sec", "m", "unit_mismatch"], [" SEC filings", "m", "unit_unbound"], [" second", "m", "unit_unbound"], [" second", "s", "pass"], [" won awards", "person", "unit_unbound"], [" won", "KRW", "pass"], [" pound", "m", "unit_unbound"], [" times", "x", "pass"], [" times", "m", "unit_mismatch"], ["\uBD84\uC758 1", "%", "unit_unbound"], ["\uBD84\uC758 1", "min", "unit_unbound"], [" (m)", "m", "pass"], [" (mm)", "m", "unit_mismatch"], [" (approx.)", "m", "unit_unbound"], ] for (const [input, unit, expected] of words) cases.push({ input, unit, expected }) return cases } describe("class 1: the unit token written after a value", () => { test("#given every alias with every particle and qualifier, and the colliding words #when read #then each row gets its expected verdict", () => { const cases = tokenizationCases() for (const [token, aliases] of ALIASES) for (const alias of aliases) expect([alias, normalizeUnit(alias)]).toEqual([alias, token]) const wrong = cases.map((row) => ({ ...row, actual: readAfter(row.input, row.unit) })).filter((row) => row.actual !== row.expected) expect(cases.length).toBeGreaterThan(9000) expect(wrong).toEqual([]) }, 120000) }) type Form = { name: string; format: "html" | "md"; body: string; at: (unit: string) => string; units: [same: string, known: string, unknown: string] } const m3: [string, string, string] = ["m", "mm", "ft"] const P = "{{F1:span_len:value}}" const head = (th: string) => `| ${th} |
|---|
| ${P} |
Up to ${escapeHtml(outside)}${escapeHtml(inside)} [S4].
` const ledger = { rows: [{ ...F1, unit, value, raw: `${value} ${unit}`, exact: { num: value, den: "1" } }] } return verdictOf((valueChecks(buildContext(parseHtml(html), {}), ledger, null, null) as { code: string }[]).map((d) => d.code)) } describe("class 2b: a currency sign outside and inside a value-mode element", () => { test("#given every EN and KO sign outside before, inside as prefix or suffix, both, or none #when read #then every bound sign must agree with the row and with each other", () => { const cases = signCases() const positions = ["outside-before only", "inside-prefix only", "inside-suffix only", "outside and inside agreeing", "outside vs inside conflicting", "outside disagreeing with the row", "no sign"] for (const position of positions) { const here = cases.filter((row) => row.position === position) if (position !== "no sign") expect([position, here.some((row) => KO.test(row.outside + row.inside)), here.some((row) => !KO.test(row.outside + row.inside) && row.outside + row.inside !== "320")]).toEqual([position, true, true]) else expect(here.length).toBe(SIGN_ROW_UNITS.length) } const wrong = cases.map((row) => ({ ...row, actual: signed(row.outside, row.inside, row.unit) })).filter((row) => row.actual !== row.expected) expect(wrong).toEqual([]) }, 120000) }) // Class 3: unit prefix shadowing. Every pair of aliases of different units where one (spaces removed) starts the other // ("%" / "%p", "m" / "mm" / "min", "k" / "km", "M" / "MB", cheon / cheon-won, peosenteu / peosenteu pointeu) and every alias that // starts a qualifier word (cho / chogwa). The longer unit is read whole, never as its prefix. const squashed = (alias: string) => alias.replace(/\s+/g, "") const LETTERS = /^[A-Za-z]+$/ const scaleOf = (unit: string) => /-(e\d+)$/.exec(unit)?.[1] ?? null type Pair = { short: string; shortUnit: string; long: string; longUnit: string } function prefixPairs(): Pair[] { const all = ALIASES.flatMap(([token, aliases]) => aliases.map((alias) => ({ alias, token }))) const longer = (short: string, long: string) => squashed(long) !== squashed(short) && squashed(long).startsWith(squashed(short)) return all.flatMap((s) => all.filter((l) => l.token !== s.token && longer(s.alias, l.alias)).map((l) => ({ short: s.alias, shortUnit: s.token, long: l.alias, longUnit: l.token }))) } function prefixCases(): Case[] { const cases: Case[] = [] const add = (input: string, unit: string, expected: Verdict) => cases.push({ input, unit, expected }) for (const { short, shortUnit, long, longUnit } of prefixPairs()) { // A scale word alone agrees with a scaled currency row (the currency may be a sign, "$12 million"): cheon under a cheon-won row. const scaleOnly = dimensionOf(shortUnit) === "scale" && scaleOf(longUnit) === shortUnit for (const gap of ["", " "]) { add(`${gap}${short}`, shortUnit, "pass") add(`${gap}${long}`, shortUnit, "unit_mismatch") add(`${gap}${short}`, longUnit, scaleOnly ? "pass" : "unit_mismatch") add(`${gap}${long}`, longUnit, "pass") } if (/\s/.test(long)) continue // Split at the prefix: English words stay apart ("m in" is m, then in); a symbol or Hangul joins ("% p", cheon won). const rest = squashed(long).slice(squashed(short).length) const english = LETTERS.test(short) && LETTERS.test(rest) add(` ${short} ${rest}`, shortUnit, english ? "pass" : "unit_mismatch") add(` ${short} ${rest}`, longUnit, english ? "unit_mismatch" : "pass") } for (const [token, aliases] of ALIASES) for (const alias of aliases) for (const word of [...QUALIFIER_WORDS, "\uB0B4\uC678", "\uAE4C\uC9C0"]) { if (word !== alias && word.startsWith(alias)) for (const gap of ["", " "]) add(`${gap}${word}`, token, "unit_unbound") } return cases } // An ordinary word that is also a unit ("pounds", "won", "second") is read when a sign or header binds a unit of its kind. const SIGNED: [html: string, unit: string, expected: Verdict][] = [ ["Up to ${V} pounds [S4].
", "USD", "unit_mismatch"], ["Up to ${V} pound [S4].
", "USD", "unit_mismatch"], ["Up to ${V} won [S4].
", "USD", "unit_mismatch"], ["Up to \u20AC{V} pounds [S4].
", "EUR", "unit_mismatch"], ["Up to \u20A9{V} pounds [S4].
", "KRW", "unit_mismatch"], ["Up to \u00A3{V} pounds [S4].
", "GBP", "pass"], ["Up to ${V} SEC filings [S4].
", "USD", "pass"], ["Up to ${V} second [S4].
", "USD", "pass"], ["Up to {V} pounds [S4].
", "USD", "unit_unbound"], ["| Cost ($) |
|---|
| {V} pounds |
| Time (min) |
|---|
| {V} second |
| Span (m) |
|---|
| {V} second |