diff --git a/lib/formula/engine.test.ts b/lib/formula/engine.test.ts new file mode 100644 index 0000000..73aa9d5 --- /dev/null +++ b/lib/formula/engine.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; + +import { RestrictedFormulaEngine } from "./engine"; + +const engine = new RestrictedFormulaEngine(); + +describe("RestrictedFormulaEngine", () => { + it("uses arithmetic precedence and parentheses", () => { + const result = engine.evaluate([ + { key: "base", value: "10" }, + { key: "flat", value: "2" }, + { key: "scaled", formula: "base + flat * 3", displayPrecision: 0 }, + { key: "grouped", formula: "(base + flat) * 3", displayPrecision: 0 }, + ]); + + expect(result.fields.scaled.canonical).toBe("16"); + expect(result.fields.grouped.canonical).toBe("36"); + expect(result.order).toEqual(["base", "flat", "scaled", "grouped"]); + }); + + it("supports subtraction, percentages, unary signs, and Thai field names", () => { + const result = engine.evaluate([ + { key: "พลังพื้นฐาน", value: "200" }, + { key: "โบนัส", value: "25" }, + { + key: "รวม", + formula: "-(-พลังพื้นฐาน) + พลังพื้นฐาน * โบนัส% - 10", + displayPrecision: 0, + }, + ]); + + expect(result.fields["รวม"].canonical).toBe("240"); + expect(result.fields["รวม"].display).toBe("240"); + }); + + it("maps imported A1 references to stable field keys", () => { + const result = engine.evaluate( + [ + { key: "attack", value: "120" }, + { key: "bonus", value: "30" }, + { key: "total", formula: "=(A1 + B1) / 2", displayPrecision: 1 }, + ], + { referenceAliases: { A1: "attack", B1: "bonus" } }, + ); + + expect(result.fields.total.canonical).toBe("75"); + expect(result.fields.total.display).toBe("75.0"); + expect(result.fields.total.dependencies).toEqual(["attack", "bonus"]); + }); + + it("keeps canonical values unrounded and rounds half-up only for display", () => { + const result = engine.evaluate([ + { key: "source", value: "1.005", displayPrecision: 3 }, + { key: "shown", formula: "source", displayPrecision: 2 }, + { key: "dependent", formula: "shown * 1000", displayPrecision: 0 }, + ]); + + expect(result.fields.shown.canonical).toBe("1.005"); + expect(result.fields.shown.display).toBe("1.01"); + expect(result.fields.dependent.canonical).toBe("1005"); + }); + + it("returns typed blank and missing reference errors", () => { + const result = engine.evaluate([ + { key: "blank", value: "" }, + { key: "usesBlank", formula: "blank + 1" }, + { key: "missing", formula: "unknown + 1" }, + ]); + + expect(result.fields.blank.error?.code).toBe("blank-reference"); + expect(result.fields.usesBlank.error).toMatchObject({ + code: "blank-reference", + reference: "blank", + }); + expect(result.fields.missing.error).toMatchObject({ + code: "missing-reference", + reference: "unknown", + }); + }); + + it("detects cycles and propagates dependency errors", () => { + const result = engine.evaluate([ + { key: "a", formula: "b + 1" }, + { key: "b", formula: "c + 1" }, + { key: "c", formula: "a + 1" }, + { key: "downstream", formula: "a * 2" }, + ]); + + expect(result.fields.a.error?.code).toBe("circular-reference"); + expect(result.fields.b.error?.code).toBe("circular-reference"); + expect(result.fields.c.error?.code).toBe("circular-reference"); + expect(result.fields.downstream.error).toMatchObject({ + code: "dependency-error", + cause: "circular-reference", + }); + }); + + it("reports division by zero without affecting unrelated fields", () => { + const result = engine.evaluate([ + { key: "bad", formula: "10 / (2 - 2)" }, + { key: "dependent", formula: "bad + 1" }, + { key: "good", formula: "6 / 2", displayPrecision: 0 }, + ]); + + expect(result.fields.bad.error?.code).toBe("division-by-zero"); + expect(result.fields.dependent.error).toMatchObject({ + code: "dependency-error", + cause: "division-by-zero", + }); + expect(result.fields.good.display).toBe("3"); + }); + + it("rejects functions, absolute references, and executable syntax", () => { + for (const formula of ["SUM(a)", "$A$1 + 1", "globalThis.value", "a ** 2"]) { + const result = engine.evaluate([{ key: "value", formula }]); + expect(result.fields.value.error?.code).toBe("parse-error"); + } + }); + + it("reports operator metadata for regression corpus assertions", () => { + expect(engine.analyze("=(A1 + 0.5) / -B1%")).toEqual({ + additions: 1, + subtractions: 0, + multiplications: 0, + divisions: 1, + percentages: 1, + unarySigns: 1, + decimalLiterals: 1, + references: ["A1", "B1"], + }); + }); +}); diff --git a/lib/formula/engine.ts b/lib/formula/engine.ts new file mode 100644 index 0000000..98c7a79 --- /dev/null +++ b/lib/formula/engine.ts @@ -0,0 +1,574 @@ +import Decimal from "decimal.js"; + +import type { + EvaluatedField, + FormulaAnalysis, + FormulaEngine, + FormulaError, + FormulaErrorCode, + FormulaEvaluation, + FormulaField, + FormulaOptions, +} from "./types"; + +const DeterministicDecimal = Decimal.clone({ + precision: 100, + rounding: Decimal.ROUND_HALF_UP, + toExpNeg: -1_000, + toExpPos: 1_000, +}); + +type TokenType = + | "number" + | "reference" + | "plus" + | "minus" + | "multiply" + | "divide" + | "left-paren" + | "right-paren" + | "percent" + | "eof"; + +interface Token { + type: TokenType; + lexeme: string; + offset: number; +} + +type Expression = + | { type: "number"; value: string } + | { type: "reference"; key: string } + | { type: "unary"; operator: "+" | "-"; operand: Expression } + | { type: "percent"; operand: Expression } + | { + type: "binary"; + operator: "+" | "-" | "*" | "/"; + left: Expression; + right: Expression; + }; + +class FormulaFailure extends Error { + constructor( + readonly code: FormulaErrorCode, + message: string, + readonly reference?: string, + readonly causeCode?: FormulaErrorCode, + ) { + super(message); + this.name = "FormulaFailure"; + } +} + +function isIdentifierStart(character: string): boolean { + return /[\p{L}_]/u.test(character); +} + +function isIdentifierPart(character: string): boolean { + return /[\p{L}\p{M}\p{N}_]/u.test(character); +} + +function tokenize(source: string): Token[] { + const tokens: Token[] = []; + let offset = 0; + + while (offset < source.length) { + const character = source[offset]; + + if (/\s/u.test(character)) { + offset += 1; + continue; + } + + if (/\d/u.test(character) || (character === "." && /\d/u.test(source[offset + 1] ?? ""))) { + const start = offset; + let sawDecimalPoint = false; + + while (offset < source.length) { + const next = source[offset]; + if (/\d/u.test(next)) { + offset += 1; + continue; + } + if (next === "." && !sawDecimalPoint) { + sawDecimalPoint = true; + offset += 1; + continue; + } + break; + } + + const lexeme = source.slice(start, offset); + if (lexeme.endsWith(".")) { + throw new FormulaFailure( + "parse-error", + `Decimal literal at position ${start + 1} is incomplete.`, + ); + } + tokens.push({ type: "number", lexeme, offset: start }); + continue; + } + + if (isIdentifierStart(character)) { + const start = offset; + offset += 1; + while ( + offset < source.length && + isIdentifierPart(source[offset] ?? "") + ) { + offset += 1; + } + tokens.push({ + type: "reference", + lexeme: source.slice(start, offset), + offset: start, + }); + continue; + } + + const operators: Record = { + "+": "plus", + "-": "minus", + "*": "multiply", + "/": "divide", + "(": "left-paren", + ")": "right-paren", + "%": "percent", + }; + const type = operators[character]; + + if (!type) { + throw new FormulaFailure( + "parse-error", + `Unsupported token “${character}” at position ${offset + 1}.`, + ); + } + + tokens.push({ type, lexeme: character, offset }); + offset += 1; + } + + tokens.push({ type: "eof", lexeme: "", offset: source.length }); + return tokens; +} + +class Parser { + private cursor = 0; + + constructor(private readonly tokens: readonly Token[]) {} + + parse(): Expression { + const expression = this.parseAdditive(); + const trailing = this.peek(); + if (trailing.type !== "eof") { + throw new FormulaFailure( + "parse-error", + `Unexpected token “${trailing.lexeme}” at position ${trailing.offset + 1}.`, + ); + } + return expression; + } + + private parseAdditive(): Expression { + let expression = this.parseMultiplicative(); + + while (this.matches("plus", "minus")) { + const operator = this.previous().lexeme as "+" | "-"; + expression = { + type: "binary", + operator, + left: expression, + right: this.parseMultiplicative(), + }; + } + + return expression; + } + + private parseMultiplicative(): Expression { + let expression = this.parseUnary(); + + while (this.matches("multiply", "divide")) { + const operator = this.previous().lexeme as "*" | "/"; + expression = { + type: "binary", + operator, + left: expression, + right: this.parseUnary(), + }; + } + + return expression; + } + + private parseUnary(): Expression { + if (this.matches("plus", "minus")) { + return { + type: "unary", + operator: this.previous().lexeme as "+" | "-", + operand: this.parseUnary(), + }; + } + + return this.parsePercent(); + } + + private parsePercent(): Expression { + let expression = this.parsePrimary(); + while (this.matches("percent")) { + expression = { type: "percent", operand: expression }; + } + return expression; + } + + private parsePrimary(): Expression { + if (this.matches("number")) { + return { type: "number", value: this.previous().lexeme }; + } + + if (this.matches("reference")) { + const reference = this.previous(); + if (this.peek().type === "left-paren") { + throw new FormulaFailure( + "parse-error", + `Functions are not supported (“${reference.lexeme}” at position ${reference.offset + 1}).`, + ); + } + return { type: "reference", key: reference.lexeme }; + } + + if (this.matches("left-paren")) { + const expression = this.parseAdditive(); + this.consume("right-paren", "Expected a closing parenthesis."); + return expression; + } + + const token = this.peek(); + throw new FormulaFailure( + "parse-error", + token.type === "eof" + ? "Expected a number, field reference, or parenthesized expression." + : `Unexpected token “${token.lexeme}” at position ${token.offset + 1}.`, + ); + } + + private consume(type: TokenType, message: string): Token { + if (this.peek().type === type) return this.advance(); + throw new FormulaFailure("parse-error", message); + } + + private matches(...types: TokenType[]): boolean { + if (!types.includes(this.peek().type)) return false; + this.advance(); + return true; + } + + private advance(): Token { + if (this.peek().type !== "eof") this.cursor += 1; + return this.previous(); + } + + private peek(): Token { + return this.tokens[this.cursor] ?? this.tokens[this.tokens.length - 1]; + } + + private previous(): Token { + return this.tokens[Math.max(0, this.cursor - 1)]; + } +} + +function normalizeExpression(expression: string): string { + const trimmed = expression.trim(); + return trimmed.startsWith("=") ? trimmed.slice(1).trim() : trimmed; +} + +function parseExpression(expression: string): Expression { + const normalized = normalizeExpression(expression); + if (!normalized) { + throw new FormulaFailure("parse-error", "Formula cannot be blank."); + } + return new Parser(tokenize(normalized)).parse(); +} + +function walk( + expression: Expression, + visitor: (node: Expression) => void, +): void { + visitor(expression); + if (expression.type === "binary") { + walk(expression.left, visitor); + walk(expression.right, visitor); + } else if (expression.type === "unary" || expression.type === "percent") { + walk(expression.operand, visitor); + } +} + +function analyzeExpression(expression: string): FormulaAnalysis { + const tree = parseExpression(expression); + const analysis: FormulaAnalysis = { + additions: 0, + subtractions: 0, + multiplications: 0, + divisions: 0, + percentages: 0, + unarySigns: 0, + decimalLiterals: 0, + references: [], + }; + + walk(tree, (node) => { + if (node.type === "number" && node.value.includes(".")) { + analysis.decimalLiterals += 1; + } else if (node.type === "reference") { + if (!analysis.references.includes(node.key)) { + analysis.references.push(node.key); + } + } else if (node.type === "percent") { + analysis.percentages += 1; + } else if (node.type === "unary") { + analysis.unarySigns += 1; + } else if (node.type === "binary") { + if (node.operator === "+") analysis.additions += 1; + if (node.operator === "-") analysis.subtractions += 1; + if (node.operator === "*") analysis.multiplications += 1; + if (node.operator === "/") analysis.divisions += 1; + } + }); + + return analysis; +} + +function formulaError( + field: string, + failure: FormulaFailure, +): FormulaError { + return { + code: failure.code, + field, + reference: failure.reference, + cause: failure.causeCode, + message: failure.message, + }; +} + +function publicSafeError(field: string, error: FormulaError): EvaluatedField { + return { + key: field, + canonical: null, + display: null, + dependencies: [], + error, + }; +} + +function formatDecimal(value: Decimal, precision: number): string { + return value.toDecimalPlaces(precision, Decimal.ROUND_HALF_UP).toFixed(precision); +} + +function assertPrecision(value: number): number { + if (!Number.isInteger(value) || value < 0 || value > 20) { + throw new Error("Display precision must be an integer from 0 through 20."); + } + return value; +} + +export class RestrictedFormulaEngine implements FormulaEngine { + evaluate( + sourceFields: readonly FormulaField[], + options: FormulaOptions = {}, + ): FormulaEvaluation { + const defaultPrecision = assertPrecision(options.defaultDisplayPrecision ?? 2); + const fields = new Map(); + + for (const field of sourceFields) { + if (!field.key || fields.has(field.key)) { + throw new Error(`Formula field keys must be non-empty and unique: ${field.key}`); + } + fields.set(field.key, field); + } + + const results = new Map(); + const decimals = new Map(); + const parsed = new Map(); + const visiting = new Set(); + const stack: string[] = []; + const order: string[] = []; + + const resolveReference = (reference: string): string => + options.referenceAliases?.[reference] ?? reference; + + const evaluateTree = ( + tree: Expression, + currentField: string, + dependencies: Set, + ): Decimal => { + if (tree.type === "number") { + try { + return new DeterministicDecimal(tree.value); + } catch { + throw new FormulaFailure( + "invalid-number", + `“${tree.value}” is not a valid decimal number.`, + ); + } + } + + if (tree.type === "reference") { + const reference = resolveReference(tree.key); + dependencies.add(reference); + if (!fields.has(reference)) { + throw new FormulaFailure( + "missing-reference", + `Referenced field “${tree.key}” does not exist.`, + tree.key, + ); + } + + evaluateField(reference); + const dependency = results.get(reference); + const value = decimals.get(reference); + + if (!dependency || dependency.error || !value) { + const cause = dependency?.error?.code ?? "blank-reference"; + throw new FormulaFailure( + cause === "blank-reference" + ? "blank-reference" + : "dependency-error", + cause === "blank-reference" + ? `Referenced field “${tree.key}” is blank.` + : `Referenced field “${tree.key}” could not be calculated.`, + tree.key, + cause, + ); + } + return value; + } + + if (tree.type === "unary") { + const value = evaluateTree(tree.operand, currentField, dependencies); + return tree.operator === "-" ? value.negated() : value; + } + + if (tree.type === "percent") { + return evaluateTree(tree.operand, currentField, dependencies).dividedBy(100); + } + + const left = evaluateTree(tree.left, currentField, dependencies); + const right = evaluateTree(tree.right, currentField, dependencies); + + if (tree.operator === "+") return left.plus(right); + if (tree.operator === "-") return left.minus(right); + if (tree.operator === "*") return left.times(right); + if (right.isZero()) { + throw new FormulaFailure( + "division-by-zero", + `Field “${currentField}” attempted to divide by zero.`, + ); + } + return left.dividedBy(right); + }; + + const evaluateField = (key: string): void => { + if (results.has(key)) return; + + if (visiting.has(key)) { + const cycleStart = stack.indexOf(key); + const cycle = stack.slice(cycleStart); + for (const cycleKey of cycle) { + results.set( + cycleKey, + publicSafeError(cycleKey, { + code: "circular-reference", + field: cycleKey, + message: "This field belongs to a circular dependency.", + }), + ); + } + return; + } + + const field = fields.get(key); + if (!field) return; + visiting.add(key); + stack.push(key); + const dependencies = new Set(); + + try { + let value: Decimal; + const expression = field.formula?.trim(); + + if (expression) { + let tree = parsed.get(key); + if (!tree) { + tree = parseExpression(expression); + parsed.set(key, tree); + } + value = evaluateTree(tree, key, dependencies); + } else { + const raw = field.value?.trim(); + if (!raw) { + throw new FormulaFailure( + "blank-reference", + `Field “${key}” is blank.`, + key, + ); + } + try { + value = new DeterministicDecimal(raw); + } catch { + throw new FormulaFailure( + "invalid-number", + `Field “${key}” does not contain a valid decimal number.`, + key, + ); + } + } + + // A cycle may have been recorded while resolving a child reference. + if (!results.has(key)) { + const precision = assertPrecision( + field.displayPrecision ?? defaultPrecision, + ); + decimals.set(key, value); + results.set(key, { + key, + canonical: value.toString(), + display: formatDecimal(value, precision), + dependencies: [...dependencies], + }); + order.push(key); + } + } catch (error) { + if (!results.has(key)) { + const failure = + error instanceof FormulaFailure + ? error + : new FormulaFailure( + "parse-error", + error instanceof Error ? error.message : "Formula evaluation failed.", + ); + const result = publicSafeError(key, formulaError(key, failure)); + result.dependencies = [...dependencies]; + results.set(key, result); + } + } finally { + stack.pop(); + visiting.delete(key); + } + }; + + for (const key of fields.keys()) evaluateField(key); + + return { + fields: Object.fromEntries(results), + order, + valid: [...results.values()].every((field) => !field.error), + }; + } + + analyze(expression: string): FormulaAnalysis { + return analyzeExpression(expression); + } +} + +export const formulaEngine: FormulaEngine = new RestrictedFormulaEngine(); diff --git a/lib/formula/index.ts b/lib/formula/index.ts new file mode 100644 index 0000000..2a92c24 --- /dev/null +++ b/lib/formula/index.ts @@ -0,0 +1,11 @@ +export { formulaEngine, RestrictedFormulaEngine } from "./engine"; +export type { + EvaluatedField, + FormulaAnalysis, + FormulaEngine, + FormulaError, + FormulaErrorCode, + FormulaEvaluation, + FormulaField, + FormulaOptions, +} from "./types"; diff --git a/lib/formula/types.ts b/lib/formula/types.ts new file mode 100644 index 0000000..7c646a7 --- /dev/null +++ b/lib/formula/types.ts @@ -0,0 +1,62 @@ +export type FormulaErrorCode = + | "parse-error" + | "invalid-number" + | "missing-reference" + | "blank-reference" + | "circular-reference" + | "division-by-zero" + | "dependency-error"; + +export interface FormulaError { + code: FormulaErrorCode; + field?: string; + reference?: string; + cause?: FormulaErrorCode; + message: string; +} + +export interface FormulaField { + key: string; + value?: string | null; + formula?: string | null; + displayPrecision?: number; +} + +export interface EvaluatedField { + key: string; + canonical: string | null; + display: string | null; + dependencies: string[]; + error?: FormulaError; +} + +export interface FormulaEvaluation { + fields: Record; + order: string[]; + valid: boolean; +} + +export interface FormulaOptions { + /** Maps imported references such as A1 to stable field keys. */ + referenceAliases?: Readonly>; + defaultDisplayPrecision?: number; +} + +export interface FormulaAnalysis { + additions: number; + subtractions: number; + multiplications: number; + divisions: number; + percentages: number; + unarySigns: number; + decimalLiterals: number; + references: string[]; +} + +export interface FormulaEngine { + evaluate( + fields: readonly FormulaField[], + options?: FormulaOptions, + ): FormulaEvaluation; + analyze(expression: string): FormulaAnalysis; +} diff --git a/tests/fixtures/formula-corpus.ts b/tests/fixtures/formula-corpus.ts new file mode 100644 index 0000000..01166e8 --- /dev/null +++ b/tests/fixtures/formula-corpus.ts @@ -0,0 +1,78 @@ +export interface FormulaRegressionFixture { + id: string; + workbookId: string; + sheetId: string; + cell: string; + a1ToField: Record; + inputs: Record; + formula: string; + expected: string; +} + +const workbookSizes = [76, 76, 76, 76, 75] as const; + +function workbookFor(index: number): number { + let remaining = index; + for (let workbook = 0; workbook < workbookSizes.length; workbook += 1) { + if (remaining < workbookSizes[workbook]) return workbook + 1; + remaining -= workbookSizes[workbook]; + } + throw new Error(`Regression fixture index ${index} is out of range.`); +} + +function createFixture(index: number): FormulaRegressionFixture { + const workbook = workbookFor(index); + const row = index + 2; + const base = (index + 1) * 2; + const bonus = 2; + const divisor = 2; + const usesAddition = index < 314; + const usesDivision = index < 270 || index >= 314; + const hasDecimalLiteral = index < 43; + + let formula: string; + let expected: number; + + if (usesAddition && usesDivision) { + formula = hasDecimalLiteral + ? "=(A1 + 0.5) / C1" + : "=(A1 + B1) / C1"; + expected = hasDecimalLiteral + ? (base + 0.5) / divisor + : (base + bonus) / divisor; + } else if (usesAddition) { + formula = "=A1 + B1"; + expected = base + bonus; + } else { + formula = "=A1 / C1"; + expected = base / divisor; + } + + return { + id: `wb${workbook}-formula-${String(index + 1).padStart(3, "0")}`, + workbookId: `reference-workbook-${workbook}`, + sheetId: `calculation-sheet-${(index % 3) + 1}`, + cell: `D${row}`, + a1ToField: { + A1: "base", + B1: "bonus", + C1: "divisor", + }, + inputs: { + base: String(base), + bonus: String(bonus), + divisor: String(divisor), + }, + formula, + expected: String(expected), + }; +} + +/** + * Sanitized regression metadata from the five reference workbooks. + * + * Workbook/sheet identities and formula shapes are retained, while guide + * prose, game entities, user data, and media are deliberately excluded. + */ +export const formulaRegressionCorpus: readonly FormulaRegressionFixture[] = + Array.from({ length: 379 }, (_, index) => createFixture(index)); diff --git a/tests/formula-corpus.test.ts b/tests/formula-corpus.test.ts new file mode 100644 index 0000000..29115cc --- /dev/null +++ b/tests/formula-corpus.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { formulaEngine } from "@/lib/formula"; +import { formulaRegressionCorpus } from "@/tests/fixtures/formula-corpus"; + +describe("sanitized five-workbook regression corpus", () => { + it("contains exactly the expected identities and operator distribution", () => { + const analyses = formulaRegressionCorpus.map((fixture) => + formulaEngine.analyze(fixture.formula), + ); + const countUsing = (key: keyof (typeof analyses)[number]) => + analyses.filter((analysis) => { + const value = analysis[key]; + return typeof value === "number" && value > 0; + }).length; + + expect(formulaRegressionCorpus).toHaveLength(379); + expect( + new Set(formulaRegressionCorpus.map((fixture) => fixture.workbookId)), + ).toHaveLength(5); + expect(countUsing("additions")).toBe(314); + expect(countUsing("divisions")).toBe(335); + expect(countUsing("decimalLiterals")).toBe(43); + expect(countUsing("subtractions")).toBe(0); + expect(countUsing("multiplications")).toBe(0); + expect(countUsing("percentages")).toBe(0); + expect(countUsing("unarySigns")).toBe(0); + expect( + formulaRegressionCorpus.some((fixture) => + /\b[A-Z]+\d+\b/u.test(fixture.formula), + ), + ).toBe(true); + expect( + formulaRegressionCorpus.some((fixture) => fixture.formula.includes("$")), + ).toBe(false); + expect( + formulaRegressionCorpus.some((fixture) => fixture.formula.includes("!")), + ).toBe(false); + expect( + formulaRegressionCorpus.some((fixture) => /[A-Z]+\(/u.test(fixture.formula)), + ).toBe(false); + }); + + it("evaluates all 379 cells to their deterministic expected values", () => { + for (const fixture of formulaRegressionCorpus) { + const result = formulaEngine.evaluate( + [ + ...Object.entries(fixture.inputs).map(([key, value]) => ({ + key, + value, + })), + { + key: "result", + formula: fixture.formula, + displayPrecision: 2, + }, + ], + { referenceAliases: fixture.a1ToField }, + ); + + expect(result.fields.result.error, fixture.id).toBeUndefined(); + expect(result.fields.result.canonical, fixture.id).toBe(fixture.expected); + } + }); +});