// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. import { assert, assertAlmostEquals, assertEquals, assertRejects, } from "@std/assert"; import { memoize } from "./memoize.ts"; import { LruCache } from "./lru_cache.ts"; import { FakeTime } from "@std/testing/time"; Deno.test( "memoize() memoizes nullary function (lazy/singleton)", async (t) => { await t.step("async function", async () => { let numTimesCalled = 0; const db = { connect() { ++numTimesCalled; return Promise.resolve({}); }, }; const getConn = memoize(async () => await db.connect()); const conn = await getConn(); assertEquals(numTimesCalled, 1); const conn2 = await getConn(); // equal by reference assert(conn2 === conn); assertEquals(numTimesCalled, 1); }); await t.step("sync function", async () => { using time = new FakeTime(); const firstHitDate = memoize(() => new Date()); const date = firstHitDate(); await time.tickAsync(10); const date2 = firstHitDate(); assertEquals(date, date2); }); }, ); Deno.test("memoize() allows simple memoization with primitive arg", () => { let numTimesCalled = 0; const fn = memoize((n: number) => { ++numTimesCalled; return 0 - n; }); assertEquals(fn(42), -42); assertEquals(numTimesCalled, 1); assertEquals(fn(42), -42); assertEquals(numTimesCalled, 1); assertEquals(fn(888), -888); assertEquals(numTimesCalled, 2); }); Deno.test("memoize() is performant for expensive fibonacci function", () => { // Typically should take a maximum of a couple of milliseconds, but this // test is really just a smoke test to make sure the memoization is working // correctly, so we don't set a deadline to make sure it isn't flaky // dependent on hardware, test runner, etc. const fib = memoize((n: bigint): bigint => { return n <= 2n ? 1n : fib(n - 1n) + fib(n - 2n); }); assertEquals(fib(100n), 354224848179261915075n); }); Deno.test("memoize() allows multiple primitive args", () => { let numTimesCalled = 0; const fn = memoize((a: number, b: number) => { ++numTimesCalled; return a + b; }); assertEquals(fn(7, 8), 15); assertEquals(numTimesCalled, 1); assertEquals(fn(7, 8), 15); assertEquals(numTimesCalled, 1); assertEquals(fn(7, 9), 16); assertEquals(numTimesCalled, 2); assertEquals(fn(8, 7), 15); assertEquals(numTimesCalled, 3); }); Deno.test("memoize() allows ...spread primitive args", () => { let numTimesCalled = 0; const fn = memoize((...ns: number[]) => { ++numTimesCalled; return ns.reduce((total, val) => total + val, 0); }); assertEquals(fn(), 0); assertEquals(fn(), 0); assertEquals(numTimesCalled, 1); assertEquals(fn(7), 7); assertEquals(fn(7), 7); assertEquals(numTimesCalled, 2); assertEquals(fn(7, 8), 15); assertEquals(fn(7, 8), 15); assertEquals(numTimesCalled, 3); assertEquals(fn(7, 8, 9), 24); assertEquals(fn(7, 8, 9), 24); assertEquals(numTimesCalled, 4); }); Deno.test( "memoize() caches unary function by all passed args by default (implicit extra args as array callback)", () => { let numTimesCalled = 0; const fn = memoize((n: number) => { ++numTimesCalled; return 0 - n; }); assertEquals([1, 1, 2, 2].map(fn), [-1, -1, -2, -2]); assertEquals(numTimesCalled, 4); }, ); Deno.test("memoize() preserves `this` binding`", () => { class X { readonly key = "CONSTANT"; timesCalled = 0; #method() { return 1; } method() { ++this.timesCalled; return this.#method(); } } const x = new X(); const method = x.method.bind(x); const fn = memoize(method); assertEquals(fn(), 1); const fn2 = memoize(x.method).bind(x); assertEquals(fn2(), 1); }); // based on https://github.com/lodash/lodash/blob/4.17.15/test/test.js#L14704-L14716 Deno.test("memoize() uses `this` binding of function for `getKey`", () => { type Obj = { b: number; c: number; memoized: (a: number) => number }; let numTimesCalled = 0; const fn = function (this: Obj, a: number) { ++numTimesCalled; return a + this.b + this.c; }; const getKey = function (this: Obj, a: number) { return JSON.stringify([a, this.b, this.c]); }; const memoized = memoize(fn, { getKey }); const obj: Obj = { memoized, "b": 2, "c": 3 }; assertEquals(obj.memoized(1), 6); assertEquals(numTimesCalled, 1); assertEquals(obj.memoized(1), 6); assertEquals(numTimesCalled, 1); obj.b = 3; obj.c = 5; assertEquals(obj.memoized(1), 9); assertEquals(numTimesCalled, 2); }); Deno.test("memoize() allows reference arg with default caching", () => { let numTimesCalled = 0; const fn = memoize((sym: symbol) => { ++numTimesCalled; return sym; }); const sym1 = Symbol(); const sym2 = Symbol(); fn(sym1); assertEquals(numTimesCalled, 1); fn(sym1); assertEquals(numTimesCalled, 1); fn(sym2); assertEquals(numTimesCalled, 2); }); Deno.test("memoize() allows multiple reference args with default caching", () => { let numTimesCalled = 0; const fn = memoize((obj1: unknown, obj2: unknown) => { ++numTimesCalled; return { obj1, obj2 }; }); const obj1 = {}; const obj2 = {}; fn(obj1, obj1); assertEquals(numTimesCalled, 1); fn(obj1, obj1); assertEquals(numTimesCalled, 1); fn(obj1, obj2); assertEquals(numTimesCalled, 2); fn(obj2, obj2); assertEquals(numTimesCalled, 3); fn(obj2, obj1); assertEquals(numTimesCalled, 4); }); Deno.test("memoize() allows non-primitive arg with `getKey`", () => { let numTimesCalled = 0; const fn = memoize((d: Date) => { ++numTimesCalled; return new Date(0 - d.valueOf()); }, { getKey: (n) => n.valueOf() }); const date1 = new Date(42); const date2 = new Date(888); assertEquals(fn(date1), new Date(-42)); assertEquals(numTimesCalled, 1); assertEquals(fn(date1), new Date(-42)); assertEquals(numTimesCalled, 1); assertEquals(fn(date2), new Date(-888)); assertEquals(numTimesCalled, 2); }); Deno.test("memoize() allows non-primitive arg with `getKey`", () => { const fn = memoize(({ value }: { cacheKey: number; value: number }) => { return value; }, { getKey: ({ cacheKey }) => cacheKey }); assertEquals(fn({ cacheKey: 1, value: 2 }), 2); assertEquals(fn({ cacheKey: 1, value: 99 }), 2); assertEquals(fn({ cacheKey: 2, value: 99 }), 99); }); Deno.test( "memoize() allows multiple non-primitive args with `getKey` returning primitive", () => { let numTimesCalled = 0; const fn = memoize((...args: { val: number }[]) => { ++numTimesCalled; return args.reduce((total, { val }) => total + val, 0); }, { getKey: (...args) => JSON.stringify(args) }); assertEquals(fn({ val: 1 }, { val: 2 }), 3); assertEquals(numTimesCalled, 1); assertEquals(fn({ val: 1 }, { val: 2 }), 3); assertEquals(numTimesCalled, 1); assertEquals(fn({ val: 2 }, { val: 1 }), 3); assertEquals(numTimesCalled, 2); }, ); Deno.test( "memoize() allows multiple non-primitive args with `getKey` returning stringified array of primitives", () => { let numTimesCalled = 0; const fn = memoize((...args: { val: number }[]) => { ++numTimesCalled; return args.reduce((total, { val }) => total + val, 0); }, { getKey: (...args) => JSON.stringify(args.map((arg) => arg.val)) }); assertEquals(fn({ val: 1 }, { val: 2 }), 3); assertEquals(numTimesCalled, 1); assertEquals(fn({ val: 1 }, { val: 2 }), 3); assertEquals(numTimesCalled, 1); assertEquals(fn({ val: 2 }, { val: 1 }), 3); assertEquals(numTimesCalled, 2); }, ); Deno.test( "memoize() allows multiple non-primitive args of different types, `getKey` returning custom string from props", () => { let numTimesCalled = 0; const fn = memoize((one: { one: number }, two: { two: number }) => { ++numTimesCalled; return one.one + two.two; }, { getKey: (one, two) => `${one.one},${two.two}` }); assertEquals(fn({ one: 1 }, { two: 2 }), 3); assertEquals(numTimesCalled, 1); assertEquals(fn({ one: 1 }, { two: 2 }), 3); assertEquals(numTimesCalled, 1); assertEquals(fn({ one: 2 }, { two: 1 }), 3); assertEquals(numTimesCalled, 2); }, ); Deno.test("memoize() allows primitive arg with `getKey`", () => { let numTimesCalled = 0; const fn = memoize((arg: string | number | boolean) => { ++numTimesCalled; try { return JSON.parse(String(arg)) as string | number | boolean; } catch { return arg; } }, { getKey: (arg) => String(arg) }); assertEquals(fn("true"), true); assertEquals(numTimesCalled, 1); assertEquals(fn(true), true); assertEquals(numTimesCalled, 1); assertEquals(fn("42"), 42); assertEquals(numTimesCalled, 2); assertEquals(fn(42), 42); assertEquals(numTimesCalled, 2); }); Deno.test("memoize() works with async functions", async () => { using time = new FakeTime(); // wait time per call of the original (un-memoized) function const DELAY_MS = 100; const startTime = Date.now(); const fn = memoize(async (n: number) => { await time.tickAsync(DELAY_MS); return 0 - n; }); const nums = [42, 888, 42, 42, 42, 42, 888, 888, 888, 888]; const expected = [-42, -888, -42, -42, -42, -42, -888, -888, -888, -888]; const results: number[] = []; // call in serial to test time elapsed for (const num of nums) { results.push(await fn(num)); } assertEquals(results, expected); const numUnique = new Set(nums).size; assertAlmostEquals( Date.now() - startTime, numUnique * DELAY_MS, nums.length, ); }); Deno.test( "memoize() doesn’t cache rejected promises for future function calls", async () => { let rejectNext = true; const fn = memoize(async (n: number) => { await Promise.resolve(); const thisCallWillReject = rejectNext; rejectNext = !rejectNext; if (thisCallWillReject) { throw new Error(); } return 0 - n; }); // first call rejects await assertRejects(() => fn(42)); // second call succeeds (rejected response is discarded) assertEquals(await fn(42), -42); // subsequent calls also succeed (successful response from cache is used) assertEquals(await fn(42), -42); }, ); Deno.test( "memoize() causes async functions called in parallel to return the same promise (even if rejected)", async () => { let rejectNext = true; const fn = memoize(async (n: number) => { await Promise.resolve(); if (rejectNext) { rejectNext = false; throw new Error(`Rejected ${n}`); } return 0 - n; }); const promises = [42, 42, 888, 888].map((x) => fn(x)); const results = await Promise.allSettled(promises); assert(promises[1] === promises[0]); assert(results[1]!.status === "rejected"); assert(results[1]!.reason.message === "Rejected 42"); assert(promises[3] === promises[2]); assert(results[3]!.status === "fulfilled"); assert(results[3]!.value === -888); }, ); Deno.test("memoize() allows passing a `Map` as a cache", () => { let numTimesCalled = 0; const cache = new Map(); const fn = memoize((n: number) => { ++numTimesCalled; return 0 - n; }, { cache }); assertEquals(fn(42), -42); assertEquals(numTimesCalled, 1); assertEquals(fn(42), -42); assertEquals(numTimesCalled, 1); }); Deno.test("memoize() allows passing a custom cache object", () => { let numTimesCalled = 0; const uselessCache = { has: () => false, get: () => { throw new Error("`has` is always false, so `get` is never called"); }, set: () => {}, delete: () => {}, keys: () => [], }; const fn = memoize((n: number) => { ++numTimesCalled; return 0 - n; }, { cache: uselessCache }); assertEquals(fn(42), -42); assertEquals(numTimesCalled, 1); assertEquals(fn(42), -42); assertEquals(numTimesCalled, 2); }); Deno.test("memoize() deletes stale entries of passed `LruCache`", () => { let numTimesCalled = 0; const MAX_SIZE = 5; const fn = memoize((n: number) => { ++numTimesCalled; return 0 - n; }, { cache: new LruCache(MAX_SIZE) }); assertEquals(fn(0), 0); assertEquals(fn(0), 0); assertEquals(numTimesCalled, 1); for (let i = 1; i < MAX_SIZE; ++i) { assertEquals(fn(i), 0 - i); assertEquals(fn(i), 0 - i); assertEquals(numTimesCalled, i + 1); } assertEquals(fn(MAX_SIZE), 0 - MAX_SIZE); assertEquals(fn(MAX_SIZE), 0 - MAX_SIZE); assertEquals(numTimesCalled, MAX_SIZE + 1); assertEquals(fn(0), 0); assertEquals(fn(0), 0); assertEquals(numTimesCalled, MAX_SIZE + 2); }); Deno.test("memoize() only caches single latest result with a `LruCache` of maxSize=1", () => { let numTimesCalled = 0; const fn = memoize((n: number) => { ++numTimesCalled; return 0 - n; }, { cache: new LruCache(1) }); assertEquals(fn(0), 0); assertEquals(fn(0), 0); assertEquals(numTimesCalled, 1); assertEquals(fn(1), -1); assertEquals(numTimesCalled, 2); }); Deno.test("memoize() preserves function length", () => { assertEquals(memoize.length, 2); assertEquals(memoize(() => {}).length, 0); assertEquals(memoize((_arg) => {}).length, 1); assertEquals(memoize((_1, _2) => {}).length, 2); assertEquals(memoize((..._args) => {}).length, 0); assertEquals(memoize((_1, ..._args) => {}).length, 1); }); Deno.test("memoize() preserves function name", () => { assertEquals(memoize.name, "memoize"); const fn1 = () => {}; function fn2() {} const obj = { ["!"]: () => {} }; assertEquals(memoize(() => {}).name, ""); assertEquals(memoize(fn1).name, "fn1"); assertEquals(memoize(fn1.bind({})).name, "bound fn1"); assertEquals(memoize(fn2).name, "fn2"); assertEquals(memoize(function fn3() {}).name, "fn3"); assertEquals(memoize(obj["!"]).name, "!"); }); Deno.test("memoize() has correct TS types", async (t) => { await t.step("simple types", () => { // no need to run, only for type checking void (() => { const fn: (this: number, x: number) => number = (_) => 1; const memoized = memoize(fn); const _fn2: typeof fn = memoized; const _fn3: Omit = fn; const _t1: ThisParameterType = 1; // @ts-expect-error Type 'string' is not assignable to type 'number'. const _t2: ThisParameterType = "1"; const _a1: Parameters[0] = 1; // @ts-expect-error Type 'string' is not assignable to type 'number'. const _a2: Parameters[0] = "1"; // @ts-expect-error Tuple type '[x: number]' of length '1' has no element at index '1'. const _a3: Parameters[1] = {} as never; const _r1: ReturnType = 1; // @ts-expect-error Type 'string' is not assignable to type 'number'. const _r2: ReturnType = "1"; }); }); await t.step("memoize() correctly preserves generic types", () => { // no need to run, only for type checking void (() => { const fn = (x: T): T => x; const memoized = memoize(fn); const _fn2: typeof fn = memoized; const _fn3: Omit = fn; const _r1: number = fn(1); const _r2: string = fn("1"); // @ts-expect-error Type 'string' is not assignable to type 'number'. const _r3: number = fn("1"); const _fn4: typeof fn = (n: number) => n; // @ts-expect-error Type 'string' is not assignable to type 'number'. const _fn5: typeof fn = (n: number) => n; }); }); });