From 05d46d6aafa93fad66210e6632c7216e4bea7252 Mon Sep 17 00:00:00 2001 From: "Mark S. Miller" Date: Thu, 18 Jan 2024 17:22:11 -0800 Subject: [PATCH] feat(exo): instance testing (#1925) --- packages/exo/src/exo-makers.js | 75 ++++++++++++-- .../exo/test/test-amplify-heap-class-kits.js | 2 +- .../test/test-is-instance-heap-class-kits.js | 97 +++++++++++++++++++ 3 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 packages/exo/test/test-is-instance-heap-class-kits.js diff --git a/packages/exo/src/exo-makers.js b/packages/exo/src/exo-makers.js index ec921790e8..54b1904376 100644 --- a/packages/exo/src/exo-makers.js +++ b/packages/exo/src/exo-makers.js @@ -88,7 +88,7 @@ export const initEmpty = () => emptyRecord; */ /** - * The power to amplify a live facet instance of the associated exo class kit + * The power to amplify a facet instance of the associated exo class kit * into the record of all facets of this facet instance's cohort. * * @template {any} [F=any] @@ -97,6 +97,19 @@ export const initEmpty = () => emptyRecord; * @returns {F} */ +/** + * The power to test if a value is an instance of the + * associated exo class, or a facet instance of the + * associated exo class kit. In the later case, if a `facetName` is provided, + * then it tests only whether the argument is a facet instance of that + * facet of the associated exo class kit. + * + * @callback IsInstance + * @param {any} exo + * @param {string} [facetName] + * @returns {boolean} + */ + // TODO Should we split FarClassOptions into distinct types for // class options vs class kit options? After all, `receiveAmplifier` // makes no sense for normal exo classes. @@ -129,9 +142,18 @@ export const initEmpty = () => emptyRecord; * definition of the exo class kit with an `Amplify` function. If called * during the definition of a normal exo or exo class, it will throw, since * only exo kits can be amplified. - * An `Amplify` function is a function that takes a live facet instance of + * An `Amplify` function is a function that takes a facet instance of * this class kit as an argument, in which case it will return the facets * record, giving access to all the facet instances of the same cohort. + * + * @property {ReceivePower} [receiveInstanceTester] + * If a `receiveInstanceTester` function is provided, it will be called + * during the definition of the exo class or exo class kit with an + * `IsInstance` function. The first argument of `IsInstance` + * is the value to be tested. When it may be a facet instance of an + * exo class kit, the optional second argument, if provided, is + * a `facetName`. In that case, the function tests only if the first + * argument is an instance of that facet of the associated exo class kit. */ /** @@ -169,7 +191,11 @@ export const defineExoClass = ( options = {}, ) => { harden(methods); - const { finish = undefined, receiveAmplifier = undefined } = options; + const { + finish = undefined, + receiveAmplifier = undefined, + receiveInstanceTester = undefined, + } = options; receiveAmplifier === undefined || Fail`Only facets of an exo class kit can be amplified ${q(tag)}`; @@ -202,6 +228,18 @@ export const defineExoClass = ( return self; }; + if (receiveInstanceTester) { + const isInstance = (exo, facetName = undefined) => { + facetName === undefined || + Fail`facetName can only be used with an exo class kit: ${q( + tag, + )} has no facet ${q(facetName)}`; + return contextMap.has(exo); + }; + harden(isInstance); + receiveInstanceTester(isInstance); + } + return harden(makeInstance); }; harden(defineExoClass); @@ -229,7 +267,11 @@ export const defineExoClassKit = ( options = {}, ) => { harden(methodsKit); - const { finish = undefined, receiveAmplifier = undefined } = options; + const { + finish = undefined, + receiveAmplifier = undefined, + receiveInstanceTester = undefined, + } = options; const contextMapKit = objectMap(methodsKit, () => new WeakMap()); const getContextKit = objectMap( contextMapKit, @@ -268,19 +310,36 @@ export const defineExoClassKit = ( }; if (receiveAmplifier) { - const amplify = aFacet => { + const amplify = exoFacet => { for (const contextMap of values(contextMapKit)) { - if (contextMap.has(aFacet)) { - const { facets } = contextMap.get(aFacet); + if (contextMap.has(exoFacet)) { + const { facets } = contextMap.get(exoFacet); return facets; } } - throw Fail`Must be a facet of ${q(tag)}: ${aFacet}`; + throw Fail`Must be a facet of ${q(tag)}: ${exoFacet}`; }; harden(amplify); receiveAmplifier(amplify); } + if (receiveInstanceTester) { + const isInstance = (exoFacet, facetName = undefined) => { + if (facetName === undefined) { + return values(contextMapKit).some(contextMap => + contextMap.has(exoFacet), + ); + } + assert.typeof(facetName, 'string'); + const contextMap = contextMapKit[facetName]; + contextMap !== undefined || + Fail`exo class kit ${q(tag)} has no facet named ${q(facetName)}`; + return contextMap.has(exoFacet); + }; + harden(isInstance); + receiveInstanceTester(isInstance); + } + return harden(makeInstanceKit); }; harden(defineExoClassKit); diff --git a/packages/exo/test/test-amplify-heap-class-kits.js b/packages/exo/test/test-amplify-heap-class-kits.js index a9c018f74c..7e827d22de 100644 --- a/packages/exo/test/test-amplify-heap-class-kits.js +++ b/packages/exo/test/test-amplify-heap-class-kits.js @@ -1,4 +1,4 @@ -// modeled on test-revoke-heap-classes.js +// modeled on test-heap-classes.js // eslint-disable-next-line import/order import { test } from './prepare-test-env-ava.js'; diff --git a/packages/exo/test/test-is-instance-heap-class-kits.js b/packages/exo/test/test-is-instance-heap-class-kits.js new file mode 100644 index 0000000000..2274ac43c4 --- /dev/null +++ b/packages/exo/test/test-is-instance-heap-class-kits.js @@ -0,0 +1,97 @@ +// modeled on test-heap-classes.js + +// eslint-disable-next-line import/order +import { test } from './prepare-test-env-ava.js'; + +// eslint-disable-next-line import/order +import { M } from '@endo/patterns'; +import { defineExoClass, defineExoClassKit } from '../src/exo-makers.js'; + +const UpCounterI = M.interface('UpCounter', { + incr: M.call().optional(M.gte(0)).returns(M.number()), +}); + +const DownCounterI = M.interface('DownCounter', { + decr: M.call().optional(M.gte(0)).returns(M.number()), +}); + +test('test isInstance defineExoClass', t => { + let isInstance; + const makeUpCounter = defineExoClass( + 'UpCounter', + UpCounterI, + /** @param {number} x */ + (x = 0) => ({ x }), + { + incr(y = 1) { + const { state } = this; + state.x += y; + return state.x; + }, + }, + { + receiveInstanceTester(i) { + isInstance = i; + }, + }, + ); + t.is(isInstance(harden({})), false); + t.throws(() => isInstance(harden({}), 'up'), { + message: + 'facetName can only be used with an exo class kit: "UpCounter" has no facet "up"', + }); + + const upCounter = makeUpCounter(3); + + t.is(isInstance(upCounter), true); + t.throws(() => isInstance(upCounter, 'up'), { + message: + 'facetName can only be used with an exo class kit: "UpCounter" has no facet "up"', + }); +}); + +test('test isInstance defineExoClassKit', t => { + let isInstance; + const makeCounterKit = defineExoClassKit( + 'Counter', + { up: UpCounterI, down: DownCounterI }, + /** @param {number} x */ + (x = 0) => ({ x }), + { + up: { + incr(y = 1) { + const { state } = this; + state.x += y; + return state.x; + }, + }, + down: { + decr(y = 1) { + const { state } = this; + state.x -= y; + return state.x; + }, + }, + }, + { + receiveInstanceTester(i) { + isInstance = i; + }, + }, + ); + + t.is(isInstance(harden({})), false); + t.is(isInstance(harden({}), 'up'), false); + t.throws(() => isInstance(harden({}), 'foo'), { + message: 'exo class kit "Counter" has no facet named "foo"', + }); + + const { up: upCounter } = makeCounterKit(3); + + t.is(isInstance(upCounter), true); + t.is(isInstance(upCounter, 'up'), true); + t.is(isInstance(upCounter, 'down'), false); + t.throws(() => isInstance(upCounter, 'foo'), { + message: 'exo class kit "Counter" has no facet named "foo"', + }); +});