optimism
Version:
Composable reactive caching with efficient invalidation.
969 lines (814 loc) • 27.6 kB
text/typescript
import * as assert from "assert";
import { createHash } from "crypto";
import {
wrap,
defaultMakeCacheKey,
OptimisticWrapperFunction,
CommonCache,
} from "../index";
import { equal } from '@wry/equality';
import { wrapYieldingFiberMethods } from '@wry/context';
import { dep } from "../dep";
import { permutations } from "./test-utils";
type NumThunk = OptimisticWrapperFunction<[], number>;
describe("optimism", function () {
it("sanity", function () {
assert.strictEqual(typeof wrap, "function");
assert.strictEqual(typeof defaultMakeCacheKey, "function");
});
it("works with single functions", function () {
const test = wrap(function (x: string) {
return x + salt;
}, {
makeCacheKey: function (x: string) {
return x;
}
});
let salt = "salt";
assert.strictEqual(test("a"), "asalt");
salt = "NaCl";
assert.strictEqual(test("a"), "asalt");
assert.strictEqual(test("b"), "bNaCl");
test.dirty("a");
assert.strictEqual(test("a"), "aNaCl");
});
it("can manually specify a cache instance", () => {
class Cache<K, V> implements CommonCache<K, V> {
private _cache = new Map<K, V>()
has = this._cache.has.bind(this._cache);
get = this._cache.get.bind(this._cache);
delete = this._cache.delete.bind(this._cache);
get size(){ return this._cache.size }
set(key: K, value: V): V {
this._cache.set(key, value);
return value;
}
clean(){};
}
const cache = new Cache<String, any>();
const wrapped = wrap(
(obj: { value: string }) => obj.value + " transformed",
{
cache,
makeCacheKey(obj) {
return obj.value;
},
}
);
assert.ok(cache instanceof Cache);
assert.strictEqual(wrapped({ value: "test" }), "test transformed");
assert.strictEqual(wrapped({ value: "test" }), "test transformed");
cache.get("test").value[0] = "test modified";
assert.strictEqual(wrapped({ value: "test" }), "test modified");
});
it("can manually specify a cache constructor", () => {
class Cache<K, V> implements CommonCache<K, V> {
private _cache = new Map<K, V>()
has = this._cache.has.bind(this._cache);
get = this._cache.get.bind(this._cache);
delete = this._cache.delete.bind(this._cache);
get size(){ return this._cache.size }
set(key: K, value: V): V {
this._cache.set(key, value);
return value;
}
clean(){};
}
const wrapped = wrap(
(obj: { value: string }) => obj.value + " transformed",
{
cache: Cache,
makeCacheKey(obj) {
return obj.value;
},
}
);
assert.ok(wrapped.options.cache instanceof Cache);
assert.strictEqual(wrapped({ value: "test" }), "test transformed");
assert.strictEqual(wrapped({ value: "test" }), "test transformed");
wrapped.options.cache.get("test").value[0] = "test modified";
assert.strictEqual(wrapped({ value: "test" }), "test modified");
});
it("works with two layers of functions", function () {
const files: { [key: string]: string } = {
"a.js": "a",
"b.js": "b"
};
const fileNames = Object.keys(files);
const read = wrap(function (path: string) {
return files[path];
});
const hash = wrap(function (paths: string[]) {
const h = createHash("sha1");
paths.forEach(function (path) {
h.update(read(path));
});
return h.digest("hex");
});
const hash1 = hash(fileNames);
files["a.js"] += "yy";
const hash2 = hash(fileNames);
read.dirty("a.js");
const hash3 = hash(fileNames);
files["b.js"] += "ee";
read.dirty("b.js");
const hash4 = hash(fileNames);
assert.strictEqual(hash1, hash2);
assert.notStrictEqual(hash1, hash3);
assert.notStrictEqual(hash1, hash4);
assert.notStrictEqual(hash3, hash4);
});
it("works with subscription functions", function () {
let dirty: () => void;
let sep = ",";
const unsubscribed = Object.create(null);
const test = wrap(function (x: string) {
return [x, x, x].join(sep);
}, {
max: 1,
subscribe: function (x: string) {
dirty = function () {
test.dirty(x);
};
delete unsubscribed[x];
return function () {
unsubscribed[x] = true;
};
}
});
assert.strictEqual(test("a"), "a,a,a");
assert.strictEqual(test("b"), "b,b,b");
assert.deepEqual(unsubscribed, { a: true });
assert.strictEqual(test("c"), "c,c,c");
assert.deepEqual(unsubscribed, {
a: true,
b: true
});
sep = ":";
assert.strictEqual(test("c"), "c,c,c");
assert.deepEqual(unsubscribed, {
a: true,
b: true
});
dirty!();
assert.strictEqual(test("c"), "c:c:c");
assert.deepEqual(unsubscribed, {
a: true,
b: true
});
assert.strictEqual(test("d"), "d:d:d");
assert.deepEqual(unsubscribed, {
a: true,
b: true,
c: true
});
});
// The fibers coroutine library no longer works with Node.js v16.
it.skip("is not confused by fibers", function () {
const Fiber = wrapYieldingFiberMethods(require("fibers"));
const order = [];
let result1 = "one";
let result2 = "two";
const f1 = new Fiber(function () {
order.push(1);
const o1 = wrap(function () {
Fiber.yield();
return result1;
});
order.push(2);
assert.strictEqual(o1(), "one");
order.push(3);
result1 += ":dirty";
assert.strictEqual(o1(), "one");
order.push(4);
Fiber.yield();
order.push(5);
assert.strictEqual(o1(), "one");
order.push(6);
o1.dirty();
order.push(7);
assert.strictEqual(o1(), "one:dirty");
order.push(8);
assert.strictEqual(o2(), "two:dirty");
order.push(9);
});
result2 = "two"
const o2 = wrap(function () {
return result2;
});
order.push(0);
f1.run();
assert.deepEqual(order, [0, 1, 2]);
// The primary goal of this test is to make sure this call to o2()
// does not register a dirty-chain dependency for o1.
assert.strictEqual(o2(), "two");
f1.run();
assert.deepEqual(order, [0, 1, 2, 3, 4]);
// If the call to o2() captured o1() as a parent, then this o2.dirty()
// call will report the o1() call dirty, which is not what we want.
result2 += ":dirty";
o2.dirty();
f1.run();
// The call to o1() between order.push(5) and order.push(6) should not
// yield, because it should still be cached, because it should not be
// dirty. However, the call to o1() between order.push(7) and
// order.push(8) should yield, because we call o1.dirty() explicitly,
// which is why this assertion stops at 7.
assert.deepEqual(order, [0, 1, 2, 3, 4, 5, 6, 7]);
f1.run();
assert.deepEqual(order, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
it("marks evicted cache entries dirty", function () {
let childSalt = "*";
let child = wrap(function (x: string) {
return x + childSalt;
}, { max: 1 });
let parentSalt = "^";
const parent = wrap(function (x: string) {
return child(x) + parentSalt;
});
assert.strictEqual(parent("asdf"), "asdf*^");
childSalt = "&";
parentSalt = "%";
assert.strictEqual(parent("asdf"), "asdf*^");
assert.strictEqual(child("zxcv"), "zxcv&");
assert.strictEqual(parent("asdf"), "asdf&%");
});
it("handles children throwing exceptions", function () {
const expected = new Error("oyez");
const child = wrap(function () {
throw expected;
});
const parent = wrap(function () {
try {
child();
} catch (e) {
return e;
}
});
assert.strictEqual(parent(), expected);
assert.strictEqual(parent(), expected);
child.dirty();
assert.strictEqual(parent(), expected);
parent.dirty();
assert.strictEqual(parent(), expected);
});
it("reports clean children to correct parents", function () {
let childResult = "a";
const child = wrap(function () {
return childResult;
});
const parent = wrap(function (x: any) {
return child() + x;
});
assert.strictEqual(parent(1), "a1");
assert.strictEqual(parent(2), "a2");
childResult = "b";
child.dirty();
// If this call to parent(1) mistakenly reports child() as clean to
// parent(2), then the second assertion will fail by returning "a2".
assert.strictEqual(parent(1), "b1");
assert.strictEqual(parent(2), "b2");
});
it("supports object cache keys", function () {
let counter = 0;
const wrapped = wrap(function (a: any, b: any) {
return counter++;
});
const a = {};
const b = {};
// Different combinations of distinct object references should
// increment the counter.
assert.strictEqual(wrapped(a, a), 0);
assert.strictEqual(wrapped(a, b), 1);
assert.strictEqual(wrapped(b, a), 2);
assert.strictEqual(wrapped(b, b), 3);
// But the same combinations of arguments should return the same
// cached values when passed again.
assert.strictEqual(wrapped(a, a), 0);
assert.strictEqual(wrapped(a, b), 1);
assert.strictEqual(wrapped(b, a), 2);
assert.strictEqual(wrapped(b, b), 3);
});
it("supports falsy non-void cache keys", function () {
let callCount = 0;
const wrapped = wrap((key: number | string | null | boolean | undefined) => {
++callCount;
return key;
}, {
makeCacheKey(key) {
return key;
},
});
assert.strictEqual(wrapped(0), 0);
assert.strictEqual(callCount, 1);
assert.strictEqual(wrapped(0), 0);
assert.strictEqual(callCount, 1);
assert.strictEqual(wrapped(""), "");
assert.strictEqual(callCount, 2);
assert.strictEqual(wrapped(""), "");
assert.strictEqual(callCount, 2);
assert.strictEqual(wrapped(null), null);
assert.strictEqual(callCount, 3);
assert.strictEqual(wrapped(null), null);
assert.strictEqual(callCount, 3);
assert.strictEqual(wrapped(false), false);
assert.strictEqual(callCount, 4);
assert.strictEqual(wrapped(false), false);
assert.strictEqual(callCount, 4);
assert.strictEqual(wrapped(0), 0);
assert.strictEqual(wrapped(""), "");
assert.strictEqual(wrapped(null), null);
assert.strictEqual(wrapped(false), false);
assert.strictEqual(callCount, 4);
assert.strictEqual(wrapped(1), 1);
assert.strictEqual(wrapped("oyez"), "oyez");
assert.strictEqual(wrapped(true), true);
assert.strictEqual(callCount, 7);
assert.strictEqual(wrapped(void 0), void 0);
assert.strictEqual(wrapped(void 0), void 0);
assert.strictEqual(wrapped(void 0), void 0);
assert.strictEqual(callCount, 10);
});
it("detects problematic cycles", function () {
const self: NumThunk = wrap(function () {
return self() + 1;
});
const mutualA: NumThunk = wrap(function () {
return mutualB() + 1;
});
const mutualB: NumThunk = wrap(function () {
return mutualA() + 1;
});
function check(fn: typeof self) {
try {
fn();
throw new Error("should not get here");
} catch (e: any) {
assert.strictEqual(e.message, "already recomputing");
}
// Try dirtying the function, now that there's a cycle in the Entry
// graph. This should succeed.
fn.dirty();
}
check(self);
check(mutualA);
check(mutualB);
let returnZero = true;
const fn: NumThunk = wrap(function () {
if (returnZero) {
returnZero = false;
return 0;
}
returnZero = true;
return fn() + 1;
});
assert.strictEqual(fn(), 0);
assert.strictEqual(returnZero, false);
returnZero = true;
assert.strictEqual(fn(), 0);
assert.strictEqual(returnZero, true);
fn.dirty();
returnZero = false;
check(fn);
});
it("tolerates misbehaving makeCacheKey functions", function () {
type NumNum = OptimisticWrapperFunction<[number], number>;
let chaos = false;
let counter = 0;
const allOddsDep = wrap(() => ++counter);
const sumOdd: NumNum = wrap((n: number) => {
allOddsDep();
if (n < 1) return 0;
if (n % 2 === 1) {
return n + sumEven(n - 1);
}
return sumEven(n);
}, {
makeCacheKey(n) {
// Even though the computation completes, returning "constant" causes
// cycles in the Entry graph.
return chaos ? "constant" : n;
}
});
const sumEven: NumNum = wrap((n: number) => {
if (n < 1) return 0;
if (n % 2 === 0) {
return n + sumOdd(n - 1);
}
return sumOdd(n);
});
function check() {
sumEven.dirty(10);
sumOdd.dirty(10);
if (chaos) {
try {
sumOdd(10);
} catch (e: any) {
assert.strictEqual(e.message, "already recomputing");
}
try {
sumEven(10);
} catch (e: any) {
assert.strictEqual(e.message, "already recomputing");
}
} else {
assert.strictEqual(sumEven(10), 55);
assert.strictEqual(sumOdd(10), 55);
}
}
check();
allOddsDep.dirty();
sumEven.dirty(10);
check();
allOddsDep.dirty();
allOddsDep();
check();
chaos = true;
check();
allOddsDep.dirty();
allOddsDep();
check();
allOddsDep.dirty();
check();
chaos = false;
allOddsDep.dirty();
check();
chaos = true;
sumOdd.dirty(9);
sumOdd.dirty(7);
sumOdd.dirty(5);
check();
chaos = false;
check();
});
it("supports options.keyArgs", function () {
const sumNums = wrap((...args: any[]) => ({
sum: args.reduce(
(sum, arg) => typeof arg === "number" ? arg + sum : sum,
0,
) as number,
}), {
keyArgs(...args) {
return args.filter(arg => typeof arg === "number");
},
});
assert.strictEqual(sumNums().sum, 0);
assert.strictEqual(sumNums("asdf", true, sumNums).sum, 0);
const sumObj1 = sumNums(1, "zxcv", true, 2, false, 3);
assert.strictEqual(sumObj1.sum, 6);
// These results are === sumObj1 because the numbers involved are identical.
assert.strictEqual(sumNums(1, 2, 3), sumObj1);
assert.strictEqual(sumNums("qwer", 1, 2, true, 3, [3]), sumObj1);
assert.strictEqual(sumNums("backwards", 3, 2, 1).sum, 6);
assert.notStrictEqual(sumNums("backwards", 3, 2, 1), sumObj1);
sumNums.dirty(1, 2, 3);
const sumObj2 = sumNums(1, 2, 3);
assert.strictEqual(sumObj2.sum, 6);
assert.notStrictEqual(sumObj2, sumObj1);
assert.strictEqual(sumNums("a", 1, "b", 2, "c", 3), sumObj2);
});
it("supports wrap(fn, {...}).options to reflect input options", function () {
const keyArgs: () => [] = () => [];
function makeCacheKey() { return "constant"; }
function subscribe() {}
let normalizeCalls: [number, number][] = [];
function normalizeResult(newer: number, older: number) {
normalizeCalls.push([newer, older]);
return newer;
}
let counter1 = 0;
const wrapped = wrap(() => ++counter1, {
max: 10,
keyArgs,
makeCacheKey,
normalizeResult,
subscribe,
});
assert.strictEqual(wrapped.options.max, 10);
assert.strictEqual(wrapped.options.keyArgs, keyArgs);
assert.strictEqual(wrapped.options.makeCacheKey, makeCacheKey);
assert.strictEqual(wrapped.options.normalizeResult, normalizeResult);
assert.strictEqual(wrapped.options.subscribe, subscribe);
assert.deepEqual(normalizeCalls, []);
assert.strictEqual(wrapped(), 1);
assert.deepEqual(normalizeCalls, []);
assert.strictEqual(wrapped(), 1);
assert.deepEqual(normalizeCalls, []);
wrapped.dirty();
assert.deepEqual(normalizeCalls, []);
assert.strictEqual(wrapped(), 2);
assert.deepEqual(normalizeCalls, [[2, 1]]);
assert.strictEqual(wrapped(), 2);
wrapped.dirty();
assert.strictEqual(wrapped(), 3);
assert.deepEqual(normalizeCalls, [[2, 1], [3, 2]]);
assert.strictEqual(wrapped(), 3);
assert.deepEqual(normalizeCalls, [[2, 1], [3, 2]]);
assert.strictEqual(wrapped(), 3);
let counter2 = 0;
const wrappedWithDefaults = wrap(() => ++counter2);
assert.strictEqual(wrappedWithDefaults.options.max, Math.pow(2, 16));
assert.strictEqual(wrappedWithDefaults.options.keyArgs, void 0);
assert.strictEqual(typeof wrappedWithDefaults.options.makeCacheKey, "function");
assert.strictEqual(wrappedWithDefaults.options.normalizeResult, void 0);
assert.strictEqual(wrappedWithDefaults.options.subscribe, void 0);
});
it("tolerates cycles when propagating dirty/clean signals", function () {
let counter = 0;
const dep = wrap(() => ++counter);
const callChild = () => child();
let parentBody = callChild;
const parent = wrap(() => {
dep();
return parentBody();
});
const callParent = () => parent();
let childBody = () => "child";
const child = wrap(() => {
dep();
return childBody();
});
assert.strictEqual(parent(), "child");
childBody = callParent;
parentBody = () => "parent";
child.dirty();
assert.strictEqual(child(), "parent");
dep.dirty();
assert.strictEqual(child(), "parent");
});
it("is not confused by eviction during recomputation", function () {
const fib: OptimisticWrapperFunction<[number], number> =
wrap(function (n: number) {
if (n > 1) {
return fib(n - 1) + fib(n - 2);
}
return n;
}, {
max: 10
});
assert.strictEqual(fib.options.max, 10);
assert.strictEqual(fib(78), 8944394323791464);
assert.strictEqual(fib(68), 72723460248141);
assert.strictEqual(fib(58), 591286729879);
assert.strictEqual(fib(48), 4807526976);
assert.strictEqual(fib(38), 39088169);
assert.strictEqual(fib(28), 317811);
assert.strictEqual(fib(18), 2584);
assert.strictEqual(fib(8), 21);
});
it("allows peeking the current value", function () {
const sumFirst = wrap(function (n: number): number {
return n < 1 ? 0 : n + sumFirst(n - 1);
});
assert.strictEqual(sumFirst.peek(3), void 0);
assert.strictEqual(sumFirst.peek(2), void 0);
assert.strictEqual(sumFirst.peek(1), void 0);
assert.strictEqual(sumFirst.peek(0), void 0);
assert.strictEqual(sumFirst(3), 6);
assert.strictEqual(sumFirst.peek(3), 6);
assert.strictEqual(sumFirst.peek(2), 3);
assert.strictEqual(sumFirst.peek(1), 1);
assert.strictEqual(sumFirst.peek(0), 0);
assert.strictEqual(sumFirst.peek(7), void 0);
assert.strictEqual(sumFirst(10), 55);
assert.strictEqual(sumFirst.peek(9), 55 - 10);
assert.strictEqual(sumFirst.peek(8), 55 - 10 - 9);
assert.strictEqual(sumFirst.peek(7), 55 - 10 - 9 - 8);
sumFirst.dirty(7);
// Everything from 7 and above is now unpeekable.
assert.strictEqual(sumFirst.peek(10), void 0);
assert.strictEqual(sumFirst.peek(9), void 0);
assert.strictEqual(sumFirst.peek(8), void 0);
assert.strictEqual(sumFirst.peek(7), void 0);
// Since 6 < 7, its value is still cached.
assert.strictEqual(sumFirst.peek(6), 6 * 7 / 2);
});
it("allows forgetting entries", function () {
const ns: number[] = [];
const sumFirst = wrap(function (n: number): number {
ns.push(n);
return n < 1 ? 0 : n + sumFirst(n - 1);
});
function inclusiveDescendingRange(n: number, limit = 0) {
const range: number[] = [];
while (n >= limit) range.push(n--);
return range;
}
assert.strictEqual(sumFirst(10), 55);
assert.deepStrictEqual(ns, inclusiveDescendingRange(10));
assert.strictEqual(sumFirst.forget(6), true);
assert.strictEqual(sumFirst(4), 10);
assert.deepStrictEqual(ns, inclusiveDescendingRange(10));
assert.strictEqual(sumFirst(11), 66);
assert.deepStrictEqual(ns, [
...inclusiveDescendingRange(10),
...inclusiveDescendingRange(11, 6),
]);
assert.strictEqual(sumFirst.forget(3), true);
assert.strictEqual(sumFirst(7), 28);
assert.deepStrictEqual(ns, [
...inclusiveDescendingRange(10),
...inclusiveDescendingRange(11, 6),
...inclusiveDescendingRange(7, 3),
]);
assert.strictEqual(sumFirst.forget(123), false);
assert.strictEqual(sumFirst.forget(-1), false);
assert.strictEqual(sumFirst.forget("7" as any), false);
assert.strictEqual((sumFirst.forget as any)(6, 4), false);
});
it("allows forgetting entries by key", function () {
const ns: number[] = [];
const sumFirst = wrap(function (n: number): number {
ns.push(n);
return n < 1 ? 0 : n + sumFirst(n - 1);
}, {
makeCacheKey: function (x: number) {
return x * 2;
}
});
assert.strictEqual(sumFirst.options.makeCacheKey!(7), 14);
assert.strictEqual(sumFirst(10), 55);
/*
* Verify:
* 1- Calling forgetKey will remove the entry.
* 2- Calling forgetKey again will return false.
* 3- Callling forget on the same entry will return false.
*/
assert.strictEqual(sumFirst.forgetKey(6 * 2), true);
assert.strictEqual(sumFirst.forgetKey(6 * 2), false);
assert.strictEqual(sumFirst.forget(6), false);
/*
* Verify:
* 1- Calling forget will remove the entry.
* 2- Calling forget again will return false.
* 3- Callling forgetKey on the same entry will return false.
*/
assert.strictEqual(sumFirst.forget(7), true);
assert.strictEqual(sumFirst.forget(7), false);
assert.strictEqual(sumFirst.forgetKey(7 * 2), false);
/*
* Verify you can query an entry key.
*/
assert.strictEqual(sumFirst.getKey(9), 18);
assert.strictEqual(sumFirst.forgetKey(sumFirst.getKey(9)), true);
assert.strictEqual(sumFirst.forgetKey(sumFirst.getKey(9)), false);
assert.strictEqual(sumFirst.forget(9), false);
});
it("exposes optimistic.{size,options.cache.size} properties", function () {
const d = dep<string>();
const fib = wrap((n: number): number => {
d("shared");
return n > 1 ? fib(n - 1) + fib(n - 2) : n;
}, {
makeCacheKey(n) {
return n;
},
});
function size() {
assert.strictEqual(fib.options.cache.size, fib.size);
return fib.size;
}
assert.strictEqual(size(), 0);
assert.strictEqual(fib(0), 0);
assert.strictEqual(fib(1), 1);
assert.strictEqual(fib(2), 1);
assert.strictEqual(fib(3), 2);
assert.strictEqual(fib(4), 3);
assert.strictEqual(fib(5), 5);
assert.strictEqual(fib(6), 8);
assert.strictEqual(fib(7), 13);
assert.strictEqual(fib(8), 21);
assert.strictEqual(size(), 9);
fib.dirty(6);
// Merely dirtying an Entry does not remove it from the LRU cache.
assert.strictEqual(size(), 9);
fib.forget(6);
// Forgetting an Entry both dirties it and removes it from the LRU cache.
assert.strictEqual(size(), 8);
fib.forget(4);
assert.strictEqual(size(), 7);
// This way of calling d.dirty causes any parent Entry objects to be
// forgotten (removed from the LRU cache).
d.dirty("shared", "forget");
assert.strictEqual(size(), 0);
});
describe("wrapOptions.normalizeResult", function () {
it("can normalize array results", function () {
const normalizeArgs: [number[], number[]][] = [];
const range = wrap((n: number) => {
let result = [];
for (let i = 0; i < n; ++i) {
result[i] = i;
}
return result;
}, {
normalizeResult(newer, older) {
normalizeArgs.push([newer, older]);
return equal(newer, older) ? older : newer;
},
});
const r3a = range(3);
assert.deepStrictEqual(r3a, [0, 1, 2]);
// Nothing surprising, just regular caching.
assert.strictEqual(r3a, range(3));
// Force range(3) to be recomputed below.
range.dirty(3);
const r3b = range(3);
assert.deepStrictEqual(r3b, [0, 1, 2]);
assert.strictEqual(r3a, r3b);
assert.deepStrictEqual(normalizeArgs, [
[r3b, r3a],
]);
// Though r3a and r3b ended up ===, the normalizeResult callback should
// have been called with two !== arrays.
assert.notStrictEqual(
normalizeArgs[0][0],
normalizeArgs[0][1],
);
});
it("can normalize recursive array results", function () {
const range = wrap((n: number): number[] => {
if (n <= 0) return [];
return range(n - 1).concat(n - 1);
}, {
normalizeResult: (newer, older) => equal(newer, older) ? older : newer,
});
const ranges = [
range(0),
range(1),
range(2),
range(3),
range(4),
];
assert.deepStrictEqual(ranges[0], []);
assert.deepStrictEqual(ranges[1], [0]);
assert.deepStrictEqual(ranges[2], [0, 1]);
assert.deepStrictEqual(ranges[3], [0, 1, 2]);
assert.deepStrictEqual(ranges[4], [0, 1, 2, 3]);
const perms = permutations(ranges[4]);
assert.strictEqual(perms.length, 4 * 3 * 2 * 1);
// For each permutation of the range sizes, check that strict equality
// holds for r[i] and range(i) for all i after dirtying each number.
let count = 0;
perms.forEach(perm => {
perm.forEach(toDirty => {
range.dirty(toDirty);
perm.forEach(i => {
assert.strictEqual(ranges[i], range(i));
++count;
});
})
});
assert.strictEqual(count, perms.length * 4 * 4);
});
it("exceptions thrown by normalizeResult are ignored", function () {
const normalizeCalls: [string | number, string | number][] = [];
const maybeThrow = wrap((value: string | number, shouldThrow: boolean) => {
if (shouldThrow) throw value;
return value;
}, {
makeCacheKey(value, shouldThrow) {
return JSON.stringify({
// Coerce the value to a string so we can trigger normalizeResult
// using either 2 or "2" below.
value: String(value),
shouldThrow,
});
},
normalizeResult(a, b) {
normalizeCalls.push([a, b]);
throw new Error("from normalizeResult (expected)");
},
});
assert.strictEqual(maybeThrow(1, false), 1);
assert.strictEqual(maybeThrow(2, false), 2);
maybeThrow.dirty(2, false);
assert.strictEqual(maybeThrow("2", false), "2");
assert.strictEqual(maybeThrow(2, false), "2");
maybeThrow.dirty(2, false);
assert.strictEqual(maybeThrow(2, false), 2);
assert.strictEqual(maybeThrow("2", false), 2);
assert.throws(
() => maybeThrow(3, true),
error => error === 3,
);
assert.throws(
() => maybeThrow("3", true),
// Still 3 because the previous maybeThrow(3, true) exception is cached.
error => error === 3,
);
maybeThrow.dirty(3, true);
assert.throws(
() => maybeThrow("3", true),
error => error === "3",
);
// Even though the exception thrown by normalizeResult was ignored, check
// that it was in fact called (twice).
assert.deepStrictEqual(normalizeCalls, [
["2", 2],
[2, "2"],
]);
});
});
});