feat(PocomathInstance): Add bounded template parameters
This feature helps specify the return type of implementations where the return type depends on the exact subtype that the implementation was called with, such as negate.
This commit is contained in:
parent
dc6921e768
commit
f7bb3697ed
@ -2,7 +2,7 @@
|
|||||||
import typed from 'typed-function'
|
import typed from 'typed-function'
|
||||||
import {makeChain} from './Chain.mjs'
|
import {makeChain} from './Chain.mjs'
|
||||||
import {dependencyExtractor, generateTypeExtractor} from './extractors.mjs'
|
import {dependencyExtractor, generateTypeExtractor} from './extractors.mjs'
|
||||||
import {R_, returnTypeOf} from './returns.mjs'
|
import {Returns, returnTypeOf} from './Returns.mjs'
|
||||||
import {typeListOfSignature, typesOfSignature, subsetOfKeys} from './utils.mjs'
|
import {typeListOfSignature, typesOfSignature, subsetOfKeys} from './utils.mjs'
|
||||||
|
|
||||||
const anySpec = {} // fixed dummy specification of 'any' type
|
const anySpec = {} // fixed dummy specification of 'any' type
|
||||||
@ -13,7 +13,9 @@ const templateFromParam = 'U' // For defining covariant conversions
|
|||||||
/* Returns a new signature just like sig but with the parameter replaced by
|
/* Returns a new signature just like sig but with the parameter replaced by
|
||||||
* the type
|
* the type
|
||||||
*/
|
*/
|
||||||
function substituteInSig(sig, parameter, type) {
|
const upperBounds = /\s*[(]=\s*(\w*)\s*/g
|
||||||
|
function substituteInSignature(signature, parameter, type) {
|
||||||
|
const sig = signature.replaceAll(upperBounds, '')
|
||||||
const pattern = new RegExp("\\b" + parameter + "\\b", 'g')
|
const pattern = new RegExp("\\b" + parameter + "\\b", 'g')
|
||||||
return sig.replaceAll(pattern, type)
|
return sig.replaceAll(pattern, type)
|
||||||
}
|
}
|
||||||
@ -30,12 +32,14 @@ export default class PocomathInstance {
|
|||||||
'install',
|
'install',
|
||||||
'installType',
|
'installType',
|
||||||
'instantiateTemplate',
|
'instantiateTemplate',
|
||||||
|
'isPriorTo',
|
||||||
'isSubtypeOf',
|
'isSubtypeOf',
|
||||||
'joinTypes',
|
'joinTypes',
|
||||||
'name',
|
'name',
|
||||||
'returnTypeOf',
|
'returnTypeOf',
|
||||||
'self',
|
'self',
|
||||||
'subtypesOf',
|
'subtypesOf',
|
||||||
|
'supertypesOf',
|
||||||
'Templates',
|
'Templates',
|
||||||
'typeOf',
|
'typeOf',
|
||||||
'Types',
|
'Types',
|
||||||
@ -88,7 +92,7 @@ export default class PocomathInstance {
|
|||||||
|
|
||||||
this._installFunctions({
|
this._installFunctions({
|
||||||
typeOf: {
|
typeOf: {
|
||||||
ground: {uses: new Set(), does: () => R_('string', () => 'any')}
|
ground: {uses: new Set(), does: () => Returns('string', () => 'any')}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -202,7 +206,7 @@ export default class PocomathInstance {
|
|||||||
}
|
}
|
||||||
if (!(this._typed.isTypedFunction(operation))) return 'any'
|
if (!(this._typed.isTypedFunction(operation))) return 'any'
|
||||||
const details = this._typed.findSignature(operation, signature)
|
const details = this._typed.findSignature(operation, signature)
|
||||||
return returnTypeOf(details.fn)
|
return returnTypeOf(details.fn, signature, this)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Return a chain object for this instance with a given value: */
|
/* Return a chain object for this instance with a given value: */
|
||||||
@ -398,7 +402,7 @@ export default class PocomathInstance {
|
|||||||
}
|
}
|
||||||
// update the typeOf function
|
// update the typeOf function
|
||||||
const imp = {}
|
const imp = {}
|
||||||
imp[type] = {uses: new Set(), does: () => R_('string', () => type)}
|
imp[type] = {uses: new Set(), does: () => Returns('string', () => type)}
|
||||||
this._installFunctions({typeOf: imp})
|
this._installFunctions({typeOf: imp})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -412,18 +416,36 @@ export default class PocomathInstance {
|
|||||||
supSubs.splice(i, 0, sub)
|
supSubs.splice(i, 0, sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Returns true is typeA is a subtype of type B */
|
/* Returns true if typeA is a subtype of type B */
|
||||||
isSubtypeOf(typeA, typeB) {
|
isSubtypeOf(typeA, typeB) {
|
||||||
return this._subtypes[typeB].includes(typeA)
|
return this._subtypes[typeB].includes(typeA)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Returns true if typeA is a subtype of or converts to type B */
|
||||||
|
isPriorTo(typeA, typeB) {
|
||||||
|
if (!(typeB in this._priorTypes)) return false
|
||||||
|
return this._priorTypes[typeB].has(typeA)
|
||||||
|
}
|
||||||
|
|
||||||
/* Returns a list of the subtypes of a given type, in topological sorted
|
/* Returns a list of the subtypes of a given type, in topological sorted
|
||||||
* order (i.e, no type on the list contains one that comes after it).
|
* order (i.e, no type on the list contains one that comes after it).
|
||||||
*/
|
*/
|
||||||
subtypesOf(type) {
|
subtypesOf(type) {
|
||||||
// HERE! For this to work, have to maintain subtypes as a sorted list.
|
|
||||||
return this._subtypes[type] // should we clone?
|
return this._subtypes[type] // should we clone?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Returns a list of the supertypes of a given type, starting with itself,
|
||||||
|
* in topological order
|
||||||
|
*/
|
||||||
|
supertypesOf(type) {
|
||||||
|
const supList = []
|
||||||
|
while (type) {
|
||||||
|
supList.push(type)
|
||||||
|
type = this.Types[type].refines
|
||||||
|
}
|
||||||
|
return supList
|
||||||
|
}
|
||||||
|
|
||||||
/* Returns the most refined type containing all the types in the array,
|
/* Returns the most refined type containing all the types in the array,
|
||||||
* with '' standing for the empty type for convenience. If the second
|
* with '' standing for the empty type for convenience. If the second
|
||||||
* argument `convert` is true, a convertible type is considered a
|
* argument `convert` is true, a convertible type is considered a
|
||||||
@ -488,7 +510,7 @@ export default class PocomathInstance {
|
|||||||
const imp = {}
|
const imp = {}
|
||||||
imp[type] = {
|
imp[type] = {
|
||||||
uses: new Set(['T']),
|
uses: new Set(['T']),
|
||||||
does: ({T}) => R_('string', () => `${base}<${T}>`)
|
does: ({T}) => Returns('string', () => `${base}<${T}>`)
|
||||||
}
|
}
|
||||||
this._installFunctions({typeOf: imp})
|
this._installFunctions({typeOf: imp})
|
||||||
|
|
||||||
@ -628,7 +650,7 @@ export default class PocomathInstance {
|
|||||||
/* Check if it's an ordinary non-template signature */
|
/* Check if it's an ordinary non-template signature */
|
||||||
let explicit = true
|
let explicit = true
|
||||||
for (const type of typesOfSignature(rawSignature)) {
|
for (const type of typesOfSignature(rawSignature)) {
|
||||||
for (const word of type.split(/[<>]/)) {
|
for (const word of type.split(/[<>(=\s]/)) {
|
||||||
if (this._templateParam(word)) {
|
if (this._templateParam(word)) {
|
||||||
explicit = false
|
explicit = false
|
||||||
break
|
break
|
||||||
@ -640,14 +662,26 @@ export default class PocomathInstance {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
/* It's a template, have to instantiate */
|
/* It's a template, have to instantiate */
|
||||||
|
/* First, find any upper bounds on the instantation */
|
||||||
|
/* TODO: handle multiple upper bounds */
|
||||||
|
upperBounds.lastIndex = 0
|
||||||
|
let ubType = upperBounds.exec(rawSignature)
|
||||||
|
if (ubType) {
|
||||||
|
ubType = ubType[1]
|
||||||
|
if (!ubType in this.Types) {
|
||||||
|
throw new TypeError(
|
||||||
|
`Unknown type upper bound '${ubType}' in '${rawSignature}'`)
|
||||||
|
}
|
||||||
|
}
|
||||||
/* First, add the known instantiations, gathering all types needed */
|
/* First, add the known instantiations, gathering all types needed */
|
||||||
if (!('instantiations' in behavior)) {
|
if (!('instantiations' in behavior)) {
|
||||||
behavior.instantiations = new Set()
|
behavior.instantiations = new Set()
|
||||||
|
if (ubType) behavior.instantiations.add(ubType)
|
||||||
}
|
}
|
||||||
let instantiationSet = new Set()
|
let instantiationSet = new Set()
|
||||||
for (const instType of behavior.instantiations) {
|
for (const instType of behavior.instantiations) {
|
||||||
instantiationSet.add(instType)
|
instantiationSet.add(instType)
|
||||||
for (const other of this._priorTypes[instType]) {
|
for (const other of this.subtypesOf(instType)) {
|
||||||
instantiationSet.add(other)
|
instantiationSet.add(other)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -656,7 +690,7 @@ export default class PocomathInstance {
|
|||||||
if (!(instType in this.Types)) continue
|
if (!(instType in this.Types)) continue
|
||||||
if (this.Types[instType] === anySpec) continue
|
if (this.Types[instType] === anySpec) continue
|
||||||
const signature =
|
const signature =
|
||||||
substituteInSig(rawSignature, theTemplateParam, instType)
|
substituteInSignature(rawSignature, theTemplateParam, instType)
|
||||||
/* Don't override an explicit implementation: */
|
/* Don't override an explicit implementation: */
|
||||||
if (signature in imps) continue
|
if (signature in imps) continue
|
||||||
/* Don't go too deep */
|
/* Don't go too deep */
|
||||||
@ -670,7 +704,7 @@ export default class PocomathInstance {
|
|||||||
const uses = new Set()
|
const uses = new Set()
|
||||||
for (const dep of behavior.uses) {
|
for (const dep of behavior.uses) {
|
||||||
if (this._templateParam(dep)) continue
|
if (this._templateParam(dep)) continue
|
||||||
uses.add(substituteInSig(dep, theTemplateParam, instType))
|
uses.add(substituteInSignature(dep, theTemplateParam, instType))
|
||||||
}
|
}
|
||||||
const patch = (refs) => {
|
const patch = (refs) => {
|
||||||
const innerRefs = {}
|
const innerRefs = {}
|
||||||
@ -678,7 +712,7 @@ export default class PocomathInstance {
|
|||||||
if (this._templateParam(dep)) {
|
if (this._templateParam(dep)) {
|
||||||
innerRefs[dep] = instType
|
innerRefs[dep] = instType
|
||||||
} else {
|
} else {
|
||||||
const outerName = substituteInSig(
|
const outerName = substituteInSignature(
|
||||||
dep, theTemplateParam, instType)
|
dep, theTemplateParam, instType)
|
||||||
innerRefs[dep] = refs[outerName]
|
innerRefs[dep] = refs[outerName]
|
||||||
}
|
}
|
||||||
@ -689,11 +723,13 @@ export default class PocomathInstance {
|
|||||||
tf_imps, signature, {uses, does: patch})
|
tf_imps, signature, {uses, does: patch})
|
||||||
}
|
}
|
||||||
/* Now add the catchall signature */
|
/* Now add the catchall signature */
|
||||||
|
/* (Not needed if if it's a bounded template) */
|
||||||
|
if (ubType) continue
|
||||||
let templateCall = `<${theTemplateParam}>`
|
let templateCall = `<${theTemplateParam}>`
|
||||||
/* Relying here that the base of 'Foo<T>' is 'Foo': */
|
/* Relying here that the base of 'Foo<T>' is 'Foo': */
|
||||||
let baseSignature = rawSignature.replaceAll(templateCall, '')
|
let baseSignature = rawSignature.replaceAll(templateCall, '')
|
||||||
/* Any remaining template params are top-level */
|
/* Any remaining template params are top-level */
|
||||||
const signature = substituteInSig(
|
const signature = substituteInSignature(
|
||||||
baseSignature, theTemplateParam, 'ground')
|
baseSignature, theTemplateParam, 'ground')
|
||||||
/* The catchall signature has to detect the actual type of the call
|
/* The catchall signature has to detect the actual type of the call
|
||||||
* and add the new instantiations.
|
* and add the new instantiations.
|
||||||
@ -718,7 +754,8 @@ export default class PocomathInstance {
|
|||||||
for (const dep of behavior.uses) {
|
for (const dep of behavior.uses) {
|
||||||
let [func, needsig] = dep.split(/[()]/)
|
let [func, needsig] = dep.split(/[()]/)
|
||||||
if (needsig) {
|
if (needsig) {
|
||||||
const subsig = substituteInSig(needsig, theTemplateParam, '')
|
const subsig = substituteInSignature(
|
||||||
|
needsig, theTemplateParam, '')
|
||||||
if (subsig === needsig) {
|
if (subsig === needsig) {
|
||||||
simplifiedUses[dep] = dep
|
simplifiedUses[dep] = dep
|
||||||
} else {
|
} else {
|
||||||
@ -789,7 +826,7 @@ export default class PocomathInstance {
|
|||||||
self._maxDepthSeen = depth
|
self._maxDepthSeen = depth
|
||||||
}
|
}
|
||||||
/* Generate the list of actual wanted types */
|
/* Generate the list of actual wanted types */
|
||||||
const wantTypes = parTypes.map(type => substituteInSig(
|
const wantTypes = parTypes.map(type => substituteInSignature(
|
||||||
type, theTemplateParam, instantiateFor))
|
type, theTemplateParam, instantiateFor))
|
||||||
/* Now we have to add any actual types that are relevant
|
/* Now we have to add any actual types that are relevant
|
||||||
* to this invocation. Namely, that would be every formal parameter
|
* to this invocation. Namely, that would be every formal parameter
|
||||||
@ -811,7 +848,7 @@ export default class PocomathInstance {
|
|||||||
if (wantType.slice(0,3) === '...') {
|
if (wantType.slice(0,3) === '...') {
|
||||||
wantType = wantType.slice(3)
|
wantType = wantType.slice(3)
|
||||||
}
|
}
|
||||||
wantType = substituteInSig(
|
wantType = substituteInSignature(
|
||||||
wantType, theTemplateParam, instantiateFor)
|
wantType, theTemplateParam, instantiateFor)
|
||||||
if (wantType !== parTypes[i]) {
|
if (wantType !== parTypes[i]) {
|
||||||
args[j] = self._typed.convert(args[j], wantType)
|
args[j] = self._typed.convert(args[j], wantType)
|
||||||
@ -840,7 +877,7 @@ export default class PocomathInstance {
|
|||||||
} else {
|
} else {
|
||||||
let [func, needsig] = dep.split(/[()]/)
|
let [func, needsig] = dep.split(/[()]/)
|
||||||
if (self._typed.isTypedFunction(refs[simplifiedDep])) {
|
if (self._typed.isTypedFunction(refs[simplifiedDep])) {
|
||||||
const subsig = substituteInSig(
|
const subsig = substituteInSignature(
|
||||||
needsig, theTemplateParam, instantiateFor)
|
needsig, theTemplateParam, instantiateFor)
|
||||||
let resname = simplifiedDep
|
let resname = simplifiedDep
|
||||||
if (resname == 'self') resname = name
|
if (resname == 'self') resname = name
|
||||||
@ -890,8 +927,8 @@ export default class PocomathInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Adapts Pocomath-style behavior specification (uses, does) for signature
|
/* Adapts Pocomath-style behavior specification (uses, does) for signature
|
||||||
* to typed-function implementations and inserts the result into plain object
|
* to typed-function implementations and inserts the result into plain
|
||||||
* imps
|
* object imps
|
||||||
*/
|
*/
|
||||||
_addTFimplementation(imps, signature, behavior) {
|
_addTFimplementation(imps, signature, behavior) {
|
||||||
const {uses, does} = behavior
|
const {uses, does} = behavior
|
||||||
@ -910,7 +947,7 @@ export default class PocomathInstance {
|
|||||||
* Verify that the desired signature has been fully grounded:
|
* Verify that the desired signature has been fully grounded:
|
||||||
*/
|
*/
|
||||||
if (needsig) {
|
if (needsig) {
|
||||||
const trysig = substituteInSig(needsig, theTemplateParam, '')
|
const trysig = substituteInSignature(needsig, theTemplateParam, '')
|
||||||
if (trysig !== needsig) {
|
if (trysig !== needsig) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Attempt to add a template implementation: ' +
|
'Attempt to add a template implementation: ' +
|
||||||
@ -1051,8 +1088,9 @@ export default class PocomathInstance {
|
|||||||
return resultingTypes
|
return resultingTypes
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Maybe add the instantiation of template type base with argument tyoe
|
/* Maybe add the instantiation of template type base with argument type
|
||||||
* instantiator to the Types of this instance, if it hasn't happened already.
|
* instantiator to the Types of this instance, if it hasn't happened
|
||||||
|
* already.
|
||||||
* Returns the name of the type if added, false if it was already there,
|
* Returns the name of the type if added, false if it was already there,
|
||||||
* and undefined if the type is declined (because of being nested too deep).
|
* and undefined if the type is declined (because of being nested too deep).
|
||||||
*/
|
*/
|
||||||
@ -1081,7 +1119,7 @@ export default class PocomathInstance {
|
|||||||
if (template.before) {
|
if (template.before) {
|
||||||
for (const beforeTmpl of template.before) {
|
for (const beforeTmpl of template.before) {
|
||||||
beforeTypes.push(
|
beforeTypes.push(
|
||||||
substituteInSig(beforeTmpl, theTemplateParam, instantiator))
|
substituteInSignature(beforeTmpl, theTemplateParam, instantiator))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (beforeTypes.length > 0) {
|
if (beforeTypes.length > 0) {
|
||||||
@ -1090,7 +1128,7 @@ export default class PocomathInstance {
|
|||||||
newTypeSpec.test = template.test(this._typeTests[instantiator])
|
newTypeSpec.test = template.test(this._typeTests[instantiator])
|
||||||
if (template.from) {
|
if (template.from) {
|
||||||
for (let source in template.from) {
|
for (let source in template.from) {
|
||||||
const instSource = substituteInSig(
|
const instSource = substituteInSignature(
|
||||||
source, theTemplateParam, instantiator)
|
source, theTemplateParam, instantiator)
|
||||||
let usesFromParam = false
|
let usesFromParam = false
|
||||||
for (const word of instSource.split(/[<>]/)) {
|
for (const word of instSource.split(/[<>]/)) {
|
||||||
@ -1101,14 +1139,14 @@ export default class PocomathInstance {
|
|||||||
}
|
}
|
||||||
if (usesFromParam) {
|
if (usesFromParam) {
|
||||||
for (const iFrom in instantiatorSpec.from) {
|
for (const iFrom in instantiatorSpec.from) {
|
||||||
const finalSource = substituteInSig(
|
const finalSource = substituteInSignature(
|
||||||
instSource, templateFromParam, iFrom)
|
instSource, templateFromParam, iFrom)
|
||||||
maybeFrom[finalSource] = template.from[source](
|
maybeFrom[finalSource] = template.from[source](
|
||||||
instantiatorSpec.from[iFrom])
|
instantiatorSpec.from[iFrom])
|
||||||
}
|
}
|
||||||
// Assuming all templates are covariant here, I guess...
|
// Assuming all templates are covariant here, I guess...
|
||||||
for (const subType of this._subtypes[instantiator]) {
|
for (const subType of this._subtypes[instantiator]) {
|
||||||
const finalSource = substituteInSig(
|
const finalSource = substituteInSignature(
|
||||||
instSource, templateFromParam, subType)
|
instSource, templateFromParam, subType)
|
||||||
maybeFrom[finalSource] = template.from[source](x => x)
|
maybeFrom[finalSource] = template.from[source](x => x)
|
||||||
}
|
}
|
||||||
|
@ -15,15 +15,20 @@ function cloneFunction(fn) {
|
|||||||
return theClone
|
return theClone
|
||||||
}
|
}
|
||||||
|
|
||||||
export function R_(type, fn) {
|
export function Returns(type, fn) {
|
||||||
if ('returns' in fn) fn = cloneFunction(fn)
|
if ('returns' in fn) fn = cloneFunction(fn)
|
||||||
fn.returns = type
|
fn.returns = type
|
||||||
return fn
|
return fn
|
||||||
}
|
}
|
||||||
|
|
||||||
export function returnTypeOf(fn) {
|
export function returnTypeOf(fn, signature, pmInstance) {
|
||||||
return fn.returns || 'any'
|
const typeOfReturns = typeof fn.returns
|
||||||
|
if (typeOfReturns === 'undefined') return 'any'
|
||||||
|
if (typeOfReturns === 'string') return fn.returns
|
||||||
|
// not sure if we will need a function to determine the return type,
|
||||||
|
// but allow it for now:
|
||||||
|
return fn.returns(signature, pmInstance)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default R_
|
export default Returns
|
||||||
|
|
@ -1,37 +1,6 @@
|
|||||||
import R_ from '../core/returns.mjs'
|
import Returns from '../core/Returns.mjs'
|
||||||
export * from './Types/number.mjs'
|
export * from './Types/number.mjs'
|
||||||
|
|
||||||
export const negate = {
|
export const negate = {
|
||||||
NumInt: () => R_('NumInt', n => -n),
|
'T (= number': ({T}) => Returns(T, n => -n)
|
||||||
number: () => R_('number', n => -n)
|
|
||||||
}
|
}
|
||||||
/* Can we find a mechanism to avoid reiterating the definition
|
|
||||||
for each number (sub)type? Maybe something like:
|
|
||||||
|
|
||||||
export const negate = {'T:number': ({T}) => R_(T, n => -n)}
|
|
||||||
|
|
||||||
where 'T:number' is a new notation that means "T is a (non-strict) subtype
|
|
||||||
of number". That doesn't look too bad, but if we went down that route, we
|
|
||||||
might also need to make sure that if we ever implemented a PositiveNumber
|
|
||||||
type and a NegativeNumber type then we could say
|
|
||||||
|
|
||||||
export const negate = {
|
|
||||||
PositiveNumber: () => R_('NegativeNumber', n => -n),
|
|
||||||
NegativeNumber: () => R_('PositiveNumber', n => -n),
|
|
||||||
'T:number': ({T}) => R_(T, n => -n)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
This just gets worse if there are also PosNumInt and NegNumInt types;
|
|
||||||
maybe Positive<T>, Negative<T>, and Zero<T> are template types, so that
|
|
||||||
we could say:
|
|
||||||
|
|
||||||
export const negate = {
|
|
||||||
'Positive<T:number>': ({'Negative<T>': negT}) => R_(negT, n => -n),
|
|
||||||
'Negative<T:number>': ({'Positive<T>': posT}) => R_(posT, n => -n),
|
|
||||||
T:number: ({T}) => R_(T, n => -n)
|
|
||||||
}
|
|
||||||
|
|
||||||
But all of that is pretty speculative, for now let's just go with writing
|
|
||||||
out the different types.
|
|
||||||
*/
|
|
||||||
|
@ -27,7 +27,7 @@ describe('The default full pocomath instance "math"', () => {
|
|||||||
|
|
||||||
it('can subtract numbers', () => {
|
it('can subtract numbers', () => {
|
||||||
assert.strictEqual(math.subtract(12, 5), 7)
|
assert.strictEqual(math.subtract(12, 5), 7)
|
||||||
//assert.strictEqual(math.subtract(3n, 1.5), 1.5)
|
assert.throws(() => math.subtract(3n, 1.5), 'TypeError')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('can add numbers', () => {
|
it('can add numbers', () => {
|
||||||
|
Loading…
Reference in New Issue
Block a user