validation.uk-modulus-table@1.0.0
impl/typescript.ts
4,104 bytes · the TypeScript implementation · view raw
import {
type UkModulusAlgorithm,
type UkModulusRow,
type UkModulusTable,
type UkSortCodeSubstitution,
} from "./validation_uk_modulus_table_types.ts";
const SORT_CODE = /^[0-9]{6}$/;
const WEIGHT = /^-?[0-9]{1,4}$/;
const EXCEPTION = /^[0-9]{1,2}$/;
const ALGORITHMS: readonly string[] = ["MOD10", "MOD11", "DBLAL"];
/**
* The fields of each non-blank line. Only ASCII spaces and tabs separate
* fields, and a trailing carriage return is dropped, so a file saved with
* Windows line endings reads the same and every language splits alike.
*/
function lines(text: string): { number: number; fields: string[] }[] {
const out: { number: number; fields: string[] }[] = [];
const body = text.startsWith("") ? text.slice(1) : text;
body.split("\n").forEach((raw, i) => {
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
const fields = line.split(/[ \t]+/).filter((f) => f !== "");
if (fields.length > 0) out.push({ number: i + 1, fields });
});
return out;
}
function sortCode(file: string, line: number, value: string): string {
if (!SORT_CODE.test(value)) throw new RangeError(`${file} line ${line}: "${value}" is not a six-digit sort code`);
return value;
}
function parseRow(line: number, fields: string[]): UkModulusRow {
const where = `VALACDOS.txt line ${line}`;
if (fields.length !== 17 && fields.length !== 18) {
throw new RangeError(
`${where}: expected 17 or 18 fields (start, end, algorithm, 14 weights, optional exception), found ${fields.length}`,
);
}
const start = sortCode("VALACDOS.txt", line, fields[0]);
const end = sortCode("VALACDOS.txt", line, fields[1]);
if (end < start) throw new RangeError(`${where}: range ${start} to ${end} ends before it starts`);
const algorithm = fields[2];
if (!ALGORITHMS.includes(algorithm)) {
throw new RangeError(`${where}: unknown algorithm "${algorithm}"; expected MOD10, MOD11 or DBLAL`);
}
const weights = fields.slice(3, 17).map((w) => {
if (!WEIGHT.test(w)) throw new RangeError(`${where}: weight "${w}" is not a whole number`);
const n = Number(w);
return n === 0 ? 0 : n; // "-0" is 0, not JavaScript's negative zero
});
// A digit sum of a negative product means different things in different
// languages' remainder rules; the specification never needs one.
if (algorithm === "DBLAL" && weights.some((w) => w < 0)) {
throw new RangeError(`${where}: a DBLAL row cannot have a negative weight`);
}
let exception: number | null = null;
if (fields.length === 18) {
const e = fields[17];
if (!EXCEPTION.test(e) || Number(e) < 1 || Number(e) > 14) {
throw new RangeError(`${where}: exception "${e}" is not a number from 1 to 14`);
}
exception = Number(e);
}
return { start, end, algorithm: algorithm as UkModulusAlgorithm, weights, exception };
}
function parseSubstitution(line: number, fields: string[]): UkSortCodeSubstitution {
if (fields.length !== 2) {
throw new RangeError(`SCSUBTAB.txt line ${line}: expected two sort codes, found ${fields.length} fields`);
}
return {
original: sortCode("SCSUBTAB.txt", line, fields[0]),
substitute: sortCode("SCSUBTAB.txt", line, fields[1]),
};
}
/**
* Parse the text of Vocalink's VALACDOS.txt (the modulus weight table) and
* SCSUBTAB.txt (exception 5's sort code substitutions) into the table
* `validateUkSortCodeAccount` checks against.
*
* Rows keep their file order, which matters: where a sort code falls in two
* rows, the first is the first check. Blank lines are skipped; anything else
* malformed is an error naming the file and the line, because a silently
* dropped row turns a checkable sort code into an unchecked one.
*/
export function parseUkModulusTable(valacdos: string, scsubtab: string): UkModulusTable {
const rows = lines(valacdos).map(({ number, fields }) => parseRow(number, fields));
if (rows.length === 0) throw new RangeError("VALACDOS.txt has no rows");
const substitutions = lines(scsubtab).map(({ number, fields }) => parseSubstitution(number, fields));
return { rows, substitutions };
}