diff options
| -rw-r--r-- | tests/unit/utils/assert.test.ts | 46 | ||||
| -rw-r--r-- | tests/unit/utils/equal.test.ts | 147 | ||||
| -rw-r--r-- | tests/unit/utils/result.test.ts | 83 | ||||
| -rw-r--r-- | utils/assert.ts | 36 | ||||
| -rw-r--r-- | utils/equal.ts | 55 | ||||
| -rw-r--r-- | utils/index.ts | 21 | ||||
| -rw-r--r-- | utils/log.ts | 35 | ||||
| -rw-r--r-- | utils/result.ts | 53 | ||||
| -rw-r--r-- | utils/types.ts | 37 |
9 files changed, 513 insertions, 0 deletions
diff --git a/tests/unit/utils/assert.test.ts b/tests/unit/utils/assert.test.ts new file mode 100644 index 0000000..c1fc02d --- /dev/null +++ b/tests/unit/utils/assert.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'vitest' +import { assert, assertDefined, assertNever } from 'utils/assert' + +describe('assert', () => { + test('passes on truthy condition', () => { + expect(() => assert(true)).not.toThrow() + expect(() => assert(1)).not.toThrow() + expect(() => assert('non-empty')).not.toThrow() + }) + + test('throws on falsy condition', () => { + expect(() => assert(false)).toThrow(/Assertion failed/) + expect(() => assert(0)).toThrow() + expect(() => assert(null)).toThrow() + expect(() => assert(undefined)).toThrow() + expect(() => assert('')).toThrow() + }) + + test('uses supplied message', () => { + expect(() => assert(false, 'custom reason')).toThrow(/custom reason/) + }) +}) + +describe('assertDefined', () => { + test('returns the value when defined', () => { + expect(assertDefined(5)).toBe(5) + expect(assertDefined('')).toBe('') + expect(assertDefined(false)).toBe(false) + expect(assertDefined(0)).toBe(0) + }) + + test('throws on null / undefined', () => { + expect(() => assertDefined(null)).toThrow() + expect(() => assertDefined(undefined)).toThrow() + }) + + test('uses supplied message', () => { + expect(() => assertDefined(null, 'nope')).toThrow(/nope/) + }) +}) + +describe('assertNever', () => { + test('always throws', () => { + expect(() => assertNever('x' as never)).toThrow() + }) +}) diff --git a/tests/unit/utils/equal.test.ts b/tests/unit/utils/equal.test.ts new file mode 100644 index 0000000..8f07996 --- /dev/null +++ b/tests/unit/utils/equal.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from 'vitest' +import { deepEqual } from 'utils/equal' + +describe('deepEqual — primitives', () => { + test('identical primitives', () => { + expect(deepEqual(1, 1)).toBe(true) + expect(deepEqual('a', 'a')).toBe(true) + expect(deepEqual(true, true)).toBe(true) + expect(deepEqual(null, null)).toBe(true) + expect(deepEqual(undefined, undefined)).toBe(true) + }) + + test('different primitives', () => { + expect(deepEqual(1, 2)).toBe(false) + expect(deepEqual('a', 'b')).toBe(false) + expect(deepEqual(null, undefined)).toBe(false) + }) + + test('NaN is treated as equal to NaN (Object.is semantics)', () => { + expect(deepEqual(NaN, NaN)).toBe(true) + }) + + test('+0 and -0 are distinct (Object.is semantics)', () => { + expect(deepEqual(0, -0)).toBe(false) + }) +}) + +describe('deepEqual — arrays', () => { + test('same-length equal arrays', () => { + expect(deepEqual([1, 2, 3], [1, 2, 3])).toBe(true) + }) + + test('different-length arrays', () => { + expect(deepEqual([1, 2], [1, 2, 3])).toBe(false) + }) + + test('different elements', () => { + expect(deepEqual([1, 2, 3], [1, 2, 4])).toBe(false) + }) + + test('nested arrays', () => { + expect( + deepEqual( + [ + [1, 2], + [3, 4] + ], + [ + [1, 2], + [3, 4] + ] + ) + ).toBe(true) + }) + + test('array vs non-array', () => { + expect(deepEqual([1], { 0: 1, length: 1 })).toBe(false) + }) +}) + +describe('deepEqual — objects', () => { + test('same-shape objects', () => { + expect(deepEqual({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true) + }) + + test('order of keys does not matter', () => { + expect(deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true) + }) + + test('missing key on one side', () => { + expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false) + }) + + test('nested objects', () => { + expect(deepEqual({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } })).toBe(true) + expect(deepEqual({ a: { b: { c: 1 } } }, { a: { b: { c: 2 } } })).toBe(false) + }) +}) + +describe('deepEqual — Map', () => { + test('equal maps', () => { + expect( + deepEqual( + new Map([ + ['a', 1], + ['b', 2] + ]), + new Map([ + ['a', 1], + ['b', 2] + ]) + ) + ).toBe(true) + }) + + test('different sizes', () => { + expect(deepEqual(new Map([['a', 1]]), new Map([['a', 1], ['b', 2]]))).toBe(false) + }) + + test('different values', () => { + expect(deepEqual(new Map([['a', 1]]), new Map([['a', 2]]))).toBe(false) + }) +}) + +describe('deepEqual — Set', () => { + test('equal sets', () => { + expect(deepEqual(new Set([1, 2, 3]), new Set([3, 2, 1]))).toBe(true) + }) + + test('different sets', () => { + expect(deepEqual(new Set([1, 2]), new Set([1, 2, 3]))).toBe(false) + }) +}) + +describe('deepEqual — Date', () => { + test('same timestamp is equal', () => { + expect(deepEqual(new Date(1000), new Date(1000))).toBe(true) + }) + + test('different timestamps', () => { + expect(deepEqual(new Date(1000), new Date(2000))).toBe(false) + }) + + test('Date vs non-Date', () => { + expect(deepEqual(new Date(0), 0)).toBe(false) + }) +}) + +describe('deepEqual — mixed', () => { + test('deep structure typical of simulation state', () => { + const a = { + id: 'p1', + name: { given: 'Meera', surname: 'Sarwath' }, + traits: new Map([['openness', 72]]), + friends: new Set(['p2', 'p3']), + events: [{ t: 10, kind: 'birth' }] + } + const b = { + id: 'p1', + name: { given: 'Meera', surname: 'Sarwath' }, + traits: new Map([['openness', 72]]), + friends: new Set(['p3', 'p2']), + events: [{ t: 10, kind: 'birth' }] + } + expect(deepEqual(a, b)).toBe(true) + }) +}) diff --git a/tests/unit/utils/result.test.ts b/tests/unit/utils/result.test.ts new file mode 100644 index 0000000..ef33e74 --- /dev/null +++ b/tests/unit/utils/result.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'vitest' +import { + err, + isErr, + isOk, + mapErr, + mapResult, + ok, + unwrap, + unwrapOr, + type Result +} from 'utils/result' + +describe('Result constructors and predicates', () => { + test('ok wraps a value', () => { + const r = ok(42) + expect(r).toEqual({ ok: true, value: 42 }) + expect(isOk(r)).toBe(true) + expect(isErr(r)).toBe(false) + }) + + test('err wraps an error', () => { + const e = new Error('boom') + const r = err(e) + expect(r).toEqual({ ok: false, error: e }) + expect(isErr(r)).toBe(true) + expect(isOk(r)).toBe(false) + }) +}) + +describe('mapResult / mapErr', () => { + test('mapResult transforms the value of an ok', () => { + const r: Result<number> = ok(3) + expect(mapResult(r, (n) => n * 2)).toEqual(ok(6)) + }) + + test('mapResult leaves an error untouched', () => { + const e = new Error('x') + const r: Result<number> = err(e) + expect(mapResult(r, (n: number) => n * 2)).toEqual(err(e)) + }) + + test('mapErr transforms the error of a failure', () => { + const r: Result<number, string> = err('oops') + expect(mapErr(r, (s: string) => s.length)).toEqual(err(4)) + }) + + test('mapErr leaves an ok untouched', () => { + const r: Result<number, string> = ok(5) + expect(mapErr(r, (s: string) => s.length)).toEqual(ok(5)) + }) +}) + +describe('unwrap / unwrapOr', () => { + test('unwrap returns the value when ok', () => { + expect(unwrap(ok('hello'))).toBe('hello') + }) + + test('unwrap throws when error', () => { + expect(() => unwrap(err(new Error('bad')))).toThrow(/bad/) + }) + + test('unwrapOr returns value when ok', () => { + expect(unwrapOr(ok(1), 0)).toBe(1) + }) + + test('unwrapOr returns fallback when error', () => { + expect(unwrapOr(err<Error>(new Error('e')), 0)).toBe(0) + }) +}) + +describe('type-narrowing', () => { + test('isOk narrows to Ok<T>', () => { + const r: Result<number, string> = ok(10) + if (isOk(r)) { + // Type-level check — if isOk doesn't narrow, this line fails to compile. + const n: number = r.value + expect(n).toBe(10) + } else { + expect.fail('isOk should narrow to ok branch') + } + }) +}) diff --git a/utils/assert.ts b/utils/assert.ts new file mode 100644 index 0000000..05de651 --- /dev/null +++ b/utils/assert.ts @@ -0,0 +1,36 @@ +/** + * assert — throw if a condition is false, narrow the type otherwise. + * For programmer errors (invariants that should never fail); use + * Result<T, E> for recoverable failures. + */ +export function assert(condition: unknown, message?: string): asserts condition { + if (!condition) { + throw new Error(message ?? 'Assertion failed') + } +} + +/** + * Mark a branch as unreachable in an exhaustive switch. If the compiler + * ever allows this to be reached, `value` stops being `never` and the + * call site fails to type-check. + * + * Usage: + * switch (kind) { + * case 'a': ... + * case 'b': ... + * default: assertNever(kind) + * } + */ +export function assertNever(value: never, message?: string): never { + throw new Error(message ?? `Unhandled case: ${String(value)}`) +} + +/** + * Throw if a value is null or undefined; return the narrowed value. + */ +export function assertDefined<T>(value: T, message?: string): NonNullable<T> { + if (value === null || value === undefined) { + throw new Error(message ?? 'Expected value to be defined') + } + return value as NonNullable<T> +} diff --git a/utils/equal.ts b/utils/equal.ts new file mode 100644 index 0000000..8e12216 --- /dev/null +++ b/utils/equal.ts @@ -0,0 +1,55 @@ +/** + * Structural deep equality, used by determinism tests to compare + * simulation snapshots. Handles plain objects, arrays, Maps, Sets, Dates, + * and primitives. Does not handle class instances with custom equality + * or cyclic object graphs — simulation state is a tree, not a graph. + */ +export function deepEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true + + if (typeof a !== typeof b) return false + if (a === null || b === null) return false + if (typeof a !== 'object') return false + + if (a instanceof Date || b instanceof Date) { + return a instanceof Date && b instanceof Date && a.getTime() === b.getTime() + } + + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b)) return false + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) return false + } + return true + } + + if (a instanceof Map || b instanceof Map) { + if (!(a instanceof Map) || !(b instanceof Map)) return false + if (a.size !== b.size) return false + for (const [key, value] of a) { + if (!b.has(key) || !deepEqual(value, b.get(key))) return false + } + return true + } + + if (a instanceof Set || b instanceof Set) { + if (!(a instanceof Set) || !(b instanceof Set)) return false + if (a.size !== b.size) return false + for (const value of a) { + if (!b.has(value)) return false + } + return true + } + + const aObj = a as Record<string, unknown> + const bObj = b as Record<string, unknown> + const aKeys = Object.keys(aObj) + const bKeys = Object.keys(bObj) + if (aKeys.length !== bKeys.length) return false + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(bObj, key)) return false + if (!deepEqual(aObj[key], bObj[key])) return false + } + return true +} diff --git a/utils/index.ts b/utils/index.ts new file mode 100644 index 0000000..4449e3e --- /dev/null +++ b/utils/index.ts @@ -0,0 +1,21 @@ +export { + err, + isErr, + isOk, + mapErr, + mapResult, + ok, + unwrap, + unwrapOr, + type Err, + type Ok, + type Result +} from 'utils/result' + +export { assert, assertDefined, assertNever } from 'utils/assert' + +export { deepEqual } from 'utils/equal' + +export type { Brand, DeepReadonly, ElementOf, JsonValue, NonEmptyArray } from 'utils/types' + +export { log } from 'utils/log' diff --git a/utils/log.ts b/utils/log.ts new file mode 100644 index 0000000..01e23f3 --- /dev/null +++ b/utils/log.ts @@ -0,0 +1,35 @@ +/** + * Structured logging. Uses console internally but carries a consistent + * message-plus-context shape so downstream sinks can filter and format. + * The game ships with no runtime telemetry — logs are developer-facing + * only, visible in the browser devtools. + */ + +type LogLevel = 'debug' | 'info' | 'warn' | 'error' + +function emit(level: LogLevel, message: string, context?: Record<string, unknown>): void { + const payload = context ? `${message} ${JSON.stringify(context)}` : message + switch (level) { + case 'debug': + console.log(payload) + return + case 'info': + console.info(payload) + return + case 'warn': + console.warn(payload) + return + case 'error': + console.error(payload) + return + } +} + +export const log = { + debug: (message: string, context?: Record<string, unknown>): void => + emit('debug', message, context), + info: (message: string, context?: Record<string, unknown>): void => emit('info', message, context), + warn: (message: string, context?: Record<string, unknown>): void => emit('warn', message, context), + error: (message: string, context?: Record<string, unknown>): void => + emit('error', message, context) +} as const diff --git a/utils/result.ts b/utils/result.ts new file mode 100644 index 0000000..cfe487f --- /dev/null +++ b/utils/result.ts @@ -0,0 +1,53 @@ +/** + * Result<T, E> — a recoverable-error return type for functions that can + * fail without throwing. Used at system boundaries (save/load, content + * validation, manifest fetch) where the caller needs to decide whether a + * failure is fatal. Internal pure logic uses plain returns and throws + * Error for programmer errors (rules/01-code-style.md). + */ + +export type Ok<T> = { readonly ok: true; readonly value: T } +export type Err<E> = { readonly ok: false; readonly error: E } +export type Result<T, E = Error> = Ok<T> | Err<E> + +export function ok<T>(value: T): Ok<T> { + return { ok: true, value } +} + +export function err<E>(error: E): Err<E> { + return { ok: false, error } +} + +export function isOk<T, E>(r: Result<T, E>): r is Ok<T> { + return r.ok +} + +export function isErr<T, E>(r: Result<T, E>): r is Err<E> { + return !r.ok +} + +/** Map the value of an ok result; pass errors through unchanged. */ +export function mapResult<T, U, E>(r: Result<T, E>, f: (value: T) => U): Result<U, E> { + return r.ok ? ok(f(r.value)) : r +} + +/** Map the error of a failed result; pass values through unchanged. */ +export function mapErr<T, E, F>(r: Result<T, E>, f: (error: E) => F): Result<T, F> { + return r.ok ? r : err(f(r.error)) +} + +/** + * Extract the value from an ok result; throw on error. Reserve for tests + * and assertion contexts — in production code prefer branching on isOk. + */ +export function unwrap<T, E>(r: Result<T, E>): T { + if (!r.ok) { + throw new Error(`unwrap() called on error result: ${String(r.error)}`) + } + return r.value +} + +/** Extract the value or return a default. */ +export function unwrapOr<T, E>(r: Result<T, E>, fallback: T): T { + return r.ok ? r.value : fallback +} diff --git a/utils/types.ts b/utils/types.ts new file mode 100644 index 0000000..a9629c8 --- /dev/null +++ b/utils/types.ts @@ -0,0 +1,37 @@ +/** A non-empty readonly array; the type system guarantees index 0 exists. */ +export type NonEmptyArray<T> = readonly [T, ...T[]] + +/** Values that round-trip through JSON.stringify / JSON.parse. */ +export type JsonValue = + | string + | number + | boolean + | null + | readonly JsonValue[] + | { readonly [key: string]: JsonValue | undefined } + +/** Element type of a readonly array (e.g., `ElementOf<typeof MONTH_NAMES>`). */ +export type ElementOf<T extends readonly unknown[]> = T extends readonly (infer E)[] ? E : never + +/** Deep-readonly version of a structural type. */ +export type DeepReadonly<T> = T extends (infer U)[] + ? ReadonlyArray<DeepReadonly<U>> + : T extends Map<infer K, infer V> + ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>> + : T extends Set<infer U> + ? ReadonlySet<DeepReadonly<U>> + : T extends object + ? { readonly [K in keyof T]: DeepReadonly<T[K]> } + : T + +/** + * Nominal / branded type: compile-time-only tag to distinguish otherwise + * structurally-equal types. + * + * type PersonId = Brand<string, 'PersonId'> + * type RelId = Brand<string, 'RelationshipId'> + * + * PersonId and RelId are still strings at runtime but aren't assignable + * to each other without an explicit cast. + */ +export type Brand<T, B extends string> = T & { readonly __brand: B } |
