mirror of
https://github.com/denoland/std.git
synced 2024-11-22 04:59:05 +00:00
8782bdb699
* fix(cache/unstable): Fix flaky fibonacci test * Attempt 2
566 lines
15 KiB
TypeScript
566 lines
15 KiB
TypeScript
// 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<string, number>(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<string, number>(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<typeof memoized, "cache" | "getKey"> = fn;
|
||
|
||
const _t1: ThisParameterType<typeof fn> = 1;
|
||
// @ts-expect-error Type 'string' is not assignable to type 'number'.
|
||
const _t2: ThisParameterType<typeof fn> = "1";
|
||
|
||
const _a1: Parameters<typeof fn>[0] = 1;
|
||
// @ts-expect-error Type 'string' is not assignable to type 'number'.
|
||
const _a2: Parameters<typeof fn>[0] = "1";
|
||
// @ts-expect-error Tuple type '[x: number]' of length '1' has no element at index '1'.
|
||
const _a3: Parameters<typeof fn>[1] = {} as never;
|
||
|
||
const _r1: ReturnType<typeof fn> = 1;
|
||
// @ts-expect-error Type 'string' is not assignable to type 'number'.
|
||
const _r2: ReturnType<typeof fn> = "1";
|
||
});
|
||
});
|
||
|
||
await t.step("memoize() correctly preserves generic types", () => {
|
||
// no need to run, only for type checking
|
||
void (() => {
|
||
const fn = <T>(x: T): T => x;
|
||
const memoized = memoize(fn);
|
||
|
||
const _fn2: typeof fn = memoized;
|
||
const _fn3: Omit<typeof memoized, "cache" | "getKey"> = 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<number> = (n: number) => n;
|
||
// @ts-expect-error Type 'string' is not assignable to type 'number'.
|
||
const _fn5: typeof fn<string> = (n: number) => n;
|
||
});
|
||
});
|
||
});
|