| import { Merged, mergeParams, mergeParamsChecked } from '../internal/params_utils.js'; |
| import { comparePublicParamsPaths, Ordering } from '../internal/query/compare.js'; |
| import { stringifyPublicParams } from '../internal/query/stringify_params.js'; |
| import { DeepReadonly } from '../util/types.js'; |
| import { assert, mapLazy, objectEquals } from '../util/util.js'; |
| |
| import { TestParams } from './fixture.js'; |
| |
| // ================================================================ |
| // "Public" ParamsBuilder API / Documentation |
| // ================================================================ |
| |
| /** |
| * Provides doc comments for the methods of CaseParamsBuilder and SubcaseParamsBuilder. |
| * (Also enforces rough interface match between them.) |
| */ |
| export interface ParamsBuilder { |
| /** |
| * Expands each item in `this` into zero or more items. |
| * Each item has its parameters expanded with those returned by the `expander`. |
| * |
| * **Note:** When only a single key is being added, use the simpler `expand` for readability. |
| * |
| * ```text |
| * this = [ a , b , c ] |
| * this.map(expander) = [ f(a) f(b) f(c) ] |
| * = [[a1, a2, a3] , [ b1 ] , [] ] |
| * merge and flatten = [ merge(a, a1), merge(a, a2), merge(a, a3), merge(b, b1) ] |
| * ``` |
| */ |
| /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ |
| expandWithParams(expander: (_: any) => any): any; |
| |
| /** |
| * Expands each item in `this` into zero or more items. Each item has its parameters expanded |
| * with one new key, `key`, and the values returned by `expander`. |
| */ |
| /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ |
| expand(key: string, expander: (_: any) => any): any; |
| |
| /** |
| * Expands each item in `this` to multiple items, one for each item in `newParams`. |
| * |
| * In other words, takes the cartesian product of [ the items in `this` ] and `newParams`. |
| * |
| * **Note:** When only a single key is being added, use the simpler `combine` for readability. |
| * |
| * ```text |
| * this = [ {a:1}, {b:2} ] |
| * newParams = [ {x:1}, {y:2} ] |
| * this.combineP(newParams) = [ {a:1,x:1}, {a:1,y:2}, {b:2,x:1}, {b:2,y:2} ] |
| * ``` |
| */ |
| /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ |
| combineWithParams(newParams: Iterable<any>): any; |
| |
| /** |
| * Expands each item in `this` to multiple items with `{ [name]: value }` for each value. |
| * |
| * In other words, takes the cartesian product of [ the items in `this` ] |
| * and `[ {[name]: value} for each value in values ]` |
| */ |
| /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ |
| combine(key: string, newParams: Iterable<any>): any; |
| |
| /** |
| * Filters `this` to only items for which `pred` returns true. |
| */ |
| /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ |
| filter(pred: (_: any) => boolean): any; |
| |
| /** |
| * Filters `this` to only items for which `pred` returns false. |
| */ |
| /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ |
| unless(pred: (_: any) => boolean): any; |
| } |
| |
| /** |
| * Determines the resulting parameter object type which would be generated by an object of |
| * the given ParamsBuilder type. |
| */ |
| export type ParamTypeOf< |
| /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ |
| T extends ParamsBuilder, |
| > = T extends SubcaseParamsBuilder<infer CaseP, infer SubcaseP> |
| ? Merged<CaseP, SubcaseP> |
| : T extends CaseParamsBuilder<infer CaseP> |
| ? CaseP |
| : never; |
| |
| // ================================================================ |
| // Implementation |
| // ================================================================ |
| |
| /** |
| * Iterable over pairs of either: |
| * - `[case params, Iterable<subcase params>]` if there are subcases. |
| * - `[case params, undefined]` if not. |
| */ |
| export type CaseSubcaseIterable<CaseP, SubcaseP> = Iterable< |
| readonly [DeepReadonly<CaseP>, Iterable<DeepReadonly<SubcaseP>> | undefined] |
| >; |
| |
| /** |
| * Base class for `CaseParamsBuilder` and `SubcaseParamsBuilder`. |
| */ |
| export abstract class ParamsBuilderBase<CaseP extends {}, SubcaseP extends {}> { |
| protected readonly cases: (caseFilter: TestParams | null) => Generator<CaseP>; |
| |
| constructor(cases: (caseFilter: TestParams | null) => Generator<CaseP>) { |
| this.cases = cases; |
| } |
| |
| /** |
| * Hidden from test files. Use `builderIterateCasesWithSubcases` to access this. |
| */ |
| protected abstract iterateCasesWithSubcases( |
| caseFilter: TestParams | null |
| ): CaseSubcaseIterable<CaseP, SubcaseP>; |
| } |
| |
| /** |
| * Calls the (normally hidden) `iterateCasesWithSubcases()` method. |
| */ |
| export function builderIterateCasesWithSubcases( |
| builder: ParamsBuilderBase<{}, {}>, |
| caseFilter: TestParams | null |
| ) { |
| interface IterableParamsBuilder { |
| iterateCasesWithSubcases(caseFilter: TestParams | null): CaseSubcaseIterable<{}, {}>; |
| } |
| |
| return (builder as unknown as IterableParamsBuilder).iterateCasesWithSubcases(caseFilter); |
| } |
| |
| /** |
| * Builder for combinatorial test **case** parameters. |
| * |
| * CaseParamsBuilder is immutable. Each method call returns a new, immutable object, |
| * modifying the list of cases according to the method called. |
| * |
| * This means, for example, that the `unit` passed into `TestBuilder.params()` can be reused. |
| */ |
| export class CaseParamsBuilder<CaseP extends {}> |
| extends ParamsBuilderBase<CaseP, {}> |
| implements Iterable<DeepReadonly<CaseP>>, ParamsBuilder |
| { |
| *iterateCasesWithSubcases(caseFilter: TestParams | null): CaseSubcaseIterable<CaseP, {}> { |
| for (const caseP of this.cases(caseFilter)) { |
| if (caseFilter) { |
| // this.cases() only filters out cases which conflict with caseFilter. Now that we have |
| // the final caseP, filter out cases which are missing keys that caseFilter requires. |
| const ordering = comparePublicParamsPaths(caseP, caseFilter); |
| if (ordering === Ordering.StrictSuperset || ordering === Ordering.Unordered) { |
| continue; |
| } |
| } |
| |
| yield [caseP as DeepReadonly<typeof caseP>, undefined]; |
| } |
| } |
| |
| [Symbol.iterator](): Iterator<DeepReadonly<CaseP>> { |
| return this.cases(null) as Iterator<DeepReadonly<CaseP>>; |
| } |
| |
| /** @inheritDoc */ |
| expandWithParams<NewP extends {}>( |
| expander: (_: CaseP) => Iterable<NewP> |
| ): CaseParamsBuilder<Merged<CaseP, NewP>> { |
| const baseGenerator = this.cases; |
| return new CaseParamsBuilder(function* (caseFilter) { |
| for (const a of baseGenerator(caseFilter)) { |
| for (const b of expander(a)) { |
| if (caseFilter) { |
| // If the expander generated any key-value pair that conflicts with caseFilter, skip. |
| const kvPairs = Object.entries(b); |
| if (kvPairs.some(([k, v]) => k in caseFilter && !objectEquals(caseFilter[k], v))) { |
| continue; |
| } |
| } |
| |
| yield mergeParamsChecked(a, b); |
| } |
| } |
| }); |
| } |
| |
| /** @inheritDoc */ |
| expand<NewPKey extends string, NewPValue>( |
| key: NewPKey, |
| expander: (_: CaseP) => Iterable<NewPValue> |
| ): CaseParamsBuilder<Merged<CaseP, { [name in NewPKey]: NewPValue }>> { |
| const baseGenerator = this.cases; |
| return new CaseParamsBuilder(function* (caseFilter) { |
| for (const a of baseGenerator(caseFilter)) { |
| assert(!(key in a), `New key '${key}' already exists in ${JSON.stringify(a)}`); |
| |
| for (const v of expander(a)) { |
| // If the expander generated a value for this key that conflicts with caseFilter, skip. |
| if (caseFilter && key in caseFilter) { |
| if (!objectEquals(caseFilter[key], v)) { |
| continue; |
| } |
| } |
| yield { ...a, [key]: v } as Merged<CaseP, { [name in NewPKey]: NewPValue }>; |
| } |
| } |
| }); |
| } |
| |
| /** @inheritDoc */ |
| combineWithParams<NewP extends {}>( |
| newParams: Iterable<NewP> |
| ): CaseParamsBuilder<Merged<CaseP, NewP>> { |
| assertNotGenerator(newParams); |
| const seenValues = new Set<string>(); |
| for (const params of newParams) { |
| const paramsStr = stringifyPublicParams(params); |
| assert(!seenValues.has(paramsStr), `Duplicate entry in combine[WithParams]: ${paramsStr}`); |
| seenValues.add(paramsStr); |
| } |
| |
| return this.expandWithParams(() => newParams); |
| } |
| |
| /** @inheritDoc */ |
| combine<NewPKey extends string, NewPValue>( |
| key: NewPKey, |
| values: Iterable<NewPValue> |
| ): CaseParamsBuilder<Merged<CaseP, { [name in NewPKey]: NewPValue }>> { |
| assertNotGenerator(values); |
| const mapped = mapLazy(values, v => ({ [key]: v }) as { [name in NewPKey]: NewPValue }); |
| return this.combineWithParams(mapped); |
| } |
| |
| /** @inheritDoc */ |
| filter(pred: (_: CaseP) => boolean): CaseParamsBuilder<CaseP> { |
| const baseGenerator = this.cases; |
| return new CaseParamsBuilder(function* (caseFilter) { |
| for (const a of baseGenerator(caseFilter)) { |
| if (pred(a)) yield a; |
| } |
| }); |
| } |
| |
| /** @inheritDoc */ |
| unless(pred: (_: CaseP) => boolean): CaseParamsBuilder<CaseP> { |
| return this.filter(x => !pred(x)); |
| } |
| |
| /** |
| * "Finalize" the list of cases and begin defining subcases. |
| * Returns a new SubcaseParamsBuilder. Methods called on SubcaseParamsBuilder |
| * generate new subcases instead of new cases. |
| */ |
| beginSubcases(): SubcaseParamsBuilder<CaseP, {}> { |
| return new SubcaseParamsBuilder(this.cases, function* () { |
| yield {}; |
| }); |
| } |
| } |
| |
| /** |
| * The unit CaseParamsBuilder, representing a single case with no params: `[ {} ]`. |
| * |
| * `punit` is passed to every `.params()`/`.paramsSubcasesOnly()` call, so `kUnitCaseParamsBuilder` |
| * is only explicitly needed if constructing a ParamsBuilder outside of a test builder. |
| */ |
| export const kUnitCaseParamsBuilder = new CaseParamsBuilder(function* () { |
| yield {}; |
| }); |
| |
| /** |
| * Builder for combinatorial test _subcase_ parameters. |
| * |
| * SubcaseParamsBuilder is immutable. Each method call returns a new, immutable object, |
| * modifying the list of subcases according to the method called. |
| */ |
| export class SubcaseParamsBuilder<CaseP extends {}, SubcaseP extends {}> |
| extends ParamsBuilderBase<CaseP, SubcaseP> |
| implements ParamsBuilder |
| { |
| protected readonly subcases: (_: CaseP) => Generator<SubcaseP>; |
| |
| constructor( |
| cases: (caseFilter: TestParams | null) => Generator<CaseP>, |
| generator: (_: CaseP) => Generator<SubcaseP> |
| ) { |
| super(cases); |
| this.subcases = generator; |
| } |
| |
| *iterateCasesWithSubcases(caseFilter: TestParams | null): CaseSubcaseIterable<CaseP, SubcaseP> { |
| for (const caseP of this.cases(caseFilter)) { |
| if (caseFilter) { |
| // this.cases() only filters out cases which conflict with caseFilter. Now that we have |
| // the final caseP, filter out cases which are missing keys that caseFilter requires. |
| const ordering = comparePublicParamsPaths(caseP, caseFilter); |
| if (ordering === Ordering.StrictSuperset || ordering === Ordering.Unordered) { |
| continue; |
| } |
| } |
| |
| const subcases = Array.from(this.subcases(caseP)); |
| if (subcases.length) { |
| yield [ |
| caseP as DeepReadonly<typeof caseP>, |
| subcases as DeepReadonly<(typeof subcases)[number]>[], |
| ]; |
| } |
| } |
| } |
| |
| /** @inheritDoc */ |
| expandWithParams<NewP extends {}>( |
| expander: (_: Merged<CaseP, SubcaseP>) => Iterable<NewP> |
| ): SubcaseParamsBuilder<CaseP, Merged<SubcaseP, NewP>> { |
| const baseGenerator = this.subcases; |
| return new SubcaseParamsBuilder(this.cases, function* (base) { |
| for (const a of baseGenerator(base)) { |
| for (const b of expander(mergeParams(base, a))) { |
| yield mergeParamsChecked(a, b); |
| } |
| } |
| }); |
| } |
| |
| /** @inheritDoc */ |
| expand<NewPKey extends string, NewPValue>( |
| key: NewPKey, |
| expander: (_: Merged<CaseP, SubcaseP>) => Iterable<NewPValue> |
| ): SubcaseParamsBuilder<CaseP, Merged<SubcaseP, { [name in NewPKey]: NewPValue }>> { |
| const baseGenerator = this.subcases; |
| return new SubcaseParamsBuilder(this.cases, function* (base) { |
| for (const a of baseGenerator(base)) { |
| const before = mergeParams(base, a); |
| assert(!(key in before), () => `Key '${key}' already exists in ${JSON.stringify(before)}`); |
| |
| for (const v of expander(before)) { |
| yield { ...a, [key]: v } as Merged<SubcaseP, { [k in NewPKey]: NewPValue }>; |
| } |
| } |
| }); |
| } |
| |
| /** @inheritDoc */ |
| combineWithParams<NewP extends {}>( |
| newParams: Iterable<NewP> |
| ): SubcaseParamsBuilder<CaseP, Merged<SubcaseP, NewP>> { |
| assertNotGenerator(newParams); |
| return this.expandWithParams(() => newParams); |
| } |
| |
| /** @inheritDoc */ |
| combine<NewPKey extends string, NewPValue>( |
| key: NewPKey, |
| values: Iterable<NewPValue> |
| ): SubcaseParamsBuilder<CaseP, Merged<SubcaseP, { [name in NewPKey]: NewPValue }>> { |
| assertNotGenerator(values); |
| return this.expand(key, () => values); |
| } |
| |
| /** @inheritDoc */ |
| filter(pred: (_: Merged<CaseP, SubcaseP>) => boolean): SubcaseParamsBuilder<CaseP, SubcaseP> { |
| const baseGenerator = this.subcases; |
| return new SubcaseParamsBuilder(this.cases, function* (base) { |
| for (const a of baseGenerator(base)) { |
| if (pred(mergeParams(base, a))) yield a; |
| } |
| }); |
| } |
| |
| /** @inheritDoc */ |
| unless(pred: (_: Merged<CaseP, SubcaseP>) => boolean): SubcaseParamsBuilder<CaseP, SubcaseP> { |
| return this.filter(x => !pred(x)); |
| } |
| } |
| |
| /** Assert an object is not a Generator (a thing returned from a generator function). */ |
| function assertNotGenerator(x: object) { |
| if ('constructor' in x) { |
| assert( |
| x.constructor !== (function* () {})().constructor, |
| 'Argument must not be a generator, as generators are not reusable' |
| ); |
| } |
| } |