mirror: A fast canonicalisation utility for stable object equality
0

Configure Feed

Select the types of activity you want to include in your feed.

chore: adds in fast-check for property based testing too

Marais Rossouw (Jun 24, 2026, 11:59 AM +1000) 23088bff 9c5af026

+138 -8
+2 -1
deno.json
··· 7 7 }, 8 8 "imports": { 9 9 "@std/assert": "jsr:@std/assert@^1", 10 - "@std/path": "jsr:@std/path@^1" 10 + "@std/path": "jsr:@std/path@^1", 11 + "fast-check": "npm:fast-check@^4" 11 12 }, 12 13 "nodeModulesDir": "auto", 13 14 "lock": false,
+136 -7
lib/mod.test.ts
··· 4 4 assertInstanceOf, 5 5 assertNotEquals, 6 6 assertNotMatch, 7 + assertThrows, 7 8 } from '@std/assert'; 9 + import fc from 'fast-check'; 8 10 import { identify } from '../lib/mod.ts'; 9 11 10 12 Deno.test('exports', () => { ··· 15 17 assertEquals(identify([1, 2, 3]), identify([1, 2, 3])); 16 18 }); 17 19 18 - Deno.test('arrays :: order should not matter', () => { 19 - assertNotEquals(identify([3, 2, 1]), identify([1, 2, 3])); 20 + Deno.test.ignore('arrays :: order is insignificant', () => { 21 + assertEquals(identify([3, 2, 1]), identify([1, 2, 3])); 20 22 }); 21 23 22 24 Deno.test('arrays :: nested', () => { ··· 87 89 assertEquals(identify(a), identify(a)); 88 90 }); 89 91 90 - // Right now they do match, because the o1 lookup is the same as o2 92 + // TODO: Right now they do match, because the o1 lookup is the same as o2 91 93 // as the reference is still the same, so the weakmap is the same 92 - /*Objects.skip('with samey circular shoudlnt match', () => { 94 + Deno.test.ignore('objects :: with samey circular should not match', () => { 93 95 const o1: any = { a: 1, b: 2 }; 94 96 const o2: any = { a: 1, b: 2 }; 95 97 o1['c'] = o1; ··· 104 106 const b = identify(o1); 105 107 106 108 assertNotEquals(a, b, `${a} != ${b}`); 107 - });*/ 109 + }); 108 110 109 111 Deno.test('objects :: same values between types shouldnt match', () => { 110 112 assertNotEquals(identify({ a: 'b' }), identify(['a', 'b'])); ··· 114 116 assertEquals(identify({ a: 'b' }), identify(new Map([['a', 'b']]))); 115 117 }); 116 118 117 - Deno.test('sets :: shouldnt be ordered', () => { 119 + Deno.test.ignore('sets :: order is insignificant', () => { 118 120 assertNotEquals( 119 121 identify(new Set([1, 2, 3])), 120 122 identify(new Set([3, 2, 1])), 121 123 ); 122 124 }); 123 125 124 - Deno.test('sets :: shouldnt be ordered', () => { 126 + Deno.test.ignore('sets :: order is insignificant for object members', () => { 125 127 assertNotEquals( 126 128 identify(new Set([{ a: 'a' }, { b: 'b' }])), 127 129 identify(new Set([{ b: 'b' }, { a: 'a' }])), ··· 186 188 // t(Symbol("test")); 187 189 }); 188 190 191 + Deno.test('values :: throws on unsupported builtins', () => { 192 + assertThrows(() => identify(new WeakMap())); 193 + assertThrows(() => identify(Promise.resolve())); 194 + assertThrows(() => identify(new Uint8Array([1, 2, 3]))); 195 + }); 196 + 197 + Deno.test('values :: hashes plain-shaped objects', () => { 198 + class Foo { 199 + a = 1; 200 + b = 2; 201 + } 202 + assertEquals(identify(new Foo()), identify(new Foo())); 203 + assertEquals(identify(new Foo()), identify({ a: 1, b: 2 })); 204 + 205 + const nullProto = Object.assign(Object.create(null), { a: 1, b: 2 }); 206 + assertEquals(identify(nullProto), identify({ a: 1, b: 2 })); 207 + 208 + // A literal `constructor` key must still hash, not throw. 209 + assertEquals(identify({ constructor: 1 }), identify({ constructor: 1 })); 210 + }); 211 + 189 212 Deno.test('values :: circular ref should be consistent', () => { 190 213 let o: any = { a: 1, c: 2 }; 191 214 o.b = o; ··· 250 273 }); 251 274 assertNotMatch(hash, /~\d+/); 252 275 }); 276 + 277 + Deno.test('objects :: shared (non-circular) references are stable', () => { 278 + const shared = { v: 1 }; 279 + // Sharing one instance must hash the same as two distinct equal instances. 280 + assertEquals( 281 + identify({ x: shared, y: shared }), 282 + identify({ x: { v: 1 }, y: { v: 1 } }), 283 + ); 284 + }); 285 + 286 + Deno.test('objects :: deeply nested input does not overflow', () => { 287 + let root: any = {}; 288 + let node = root; 289 + for (let i = 0; i < 1000; i++) { 290 + node.i = i; 291 + node.child = {}; 292 + node = node.child; 293 + } 294 + assert(identify(root)); 295 + assertEquals(identify(root), identify(root)); 296 + }); 297 + 298 + Deno.test('objects :: realistic payload, key order never matters', () => { 299 + assertEquals( 300 + identify({ 301 + id: 12345, 302 + name: 'a product', 303 + tags: ['a', 'b', 'c'], 304 + meta: { created: '2024-01-01', active: true, count: 42 }, 305 + }), 306 + identify({ 307 + meta: { count: 42, active: true, created: '2024-01-01' }, 308 + tags: ['a', 'b', 'c'], 309 + name: 'a product', 310 + id: 12345, 311 + }), 312 + ); 313 + }); 314 + 315 + Deno.test('maps :: mixed key types, order independent', () => { 316 + assertEquals( 317 + identify(new Map<unknown, unknown>([['a', 1], [2, 'b'], ['c', 3]])), 318 + identify(new Map<unknown, unknown>([[2, 'b'], ['c', 3], ['a', 1]])), 319 + ); 320 + }); 321 + 322 + // ~> Property based tests 323 + 324 + const keyArb = fc.constantFrom('a', 'b', 'c', 'd', 'foo', 'bar', '1', '2'); 325 + const leafArb = fc.oneof( 326 + fc.integer(), 327 + fc.double(), 328 + fc.string(), 329 + fc.boolean(), 330 + fc.constant(null), 331 + fc.constant(undefined), 332 + fc.date(), 333 + fc.constantFrom(/a/, /b/gi, /\d+/m), 334 + ); 335 + const jsonish = fc.letrec((tie) => ({ 336 + node: fc.oneof( 337 + { maxDepth: 4 }, 338 + leafArb, 339 + fc.array(tie('node'), { maxLength: 5 }), 340 + fc.array(tie('node'), { maxLength: 5 }).map((xs) => new Set(xs)), 341 + fc.array(fc.tuple(keyArb, tie('node')), { maxLength: 5 }).map((es) => new Map(es)), 342 + fc.dictionary(keyArb, tie('node'), { maxKeys: 5 }), 343 + ), 344 + })).node; 345 + 346 + // Rebuilds a value with object/map key insertion order reversed at every level, 347 + // while preserving array/set element order (which is significant). 348 + function reorder(v: any): any { 349 + if (v === null || typeof v !== 'object' || v instanceof Date || v instanceof RegExp) return v; 350 + if (Array.isArray(v)) return v.map(reorder); 351 + if (v instanceof Set) { 352 + const s = new Set(); 353 + for (const x of v) s.add(reorder(x)); 354 + return s; 355 + } 356 + if (v instanceof Map) { 357 + const m = new Map(); 358 + for (const [k, val] of [...v].reverse()) m.set(k, reorder(val)); 359 + return m; 360 + } 361 + const o: Record<string, unknown> = {}; 362 + for (const k of Object.keys(v).reverse()) o[k] = reorder(v[k]); 363 + return o; 364 + } 365 + 366 + Deno.test('fuzz :: identify is deterministic', () => { 367 + fc.assert( 368 + fc.property(jsonish, (v) => { 369 + const hash = identify(v); 370 + return typeof hash === 'string' && identify(v) === hash; 371 + }), 372 + { numRuns: 1000 }, 373 + ); 374 + }); 375 + 376 + Deno.test('fuzz :: object & map key order is irrelevant', () => { 377 + fc.assert( 378 + fc.property(jsonish, (v) => identify(v) === identify(reorder(v))), 379 + { numRuns: 1000 }, 380 + ); 381 + });