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; /** * 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; /** * 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 ? Merged : T extends CaseParamsBuilder ? CaseP : never; // ================================================================ // Implementation // ================================================================ /** * Iterable over pairs of either: * - `[case params, Iterable]` if there are subcases. * - `[case params, undefined]` if not. */ export type CaseSubcaseIterable = Iterable< readonly [DeepReadonly, Iterable> | undefined] >; /** * Base class for `CaseParamsBuilder` and `SubcaseParamsBuilder`. */ export abstract class ParamsBuilderBase { protected readonly cases: (caseFilter: TestParams | null) => Generator; constructor(cases: (caseFilter: TestParams | null) => Generator) { this.cases = cases; } /** * Hidden from test files. Use `builderIterateCasesWithSubcases` to access this. */ protected abstract iterateCasesWithSubcases( caseFilter: TestParams | null ): CaseSubcaseIterable; } /** * 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 extends ParamsBuilderBase implements Iterable>, ParamsBuilder { *iterateCasesWithSubcases(caseFilter: TestParams | null): CaseSubcaseIterable { 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, undefined]; } } [Symbol.iterator](): Iterator> { return this.cases(null) as Iterator>; } /** @inheritDoc */ expandWithParams( expander: (_: CaseP) => Iterable ): CaseParamsBuilder> { 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( key: NewPKey, expander: (_: CaseP) => Iterable ): CaseParamsBuilder> { 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; } } }); } /** @inheritDoc */ combineWithParams( newParams: Iterable ): CaseParamsBuilder> { assertNotGenerator(newParams); const seenValues = new Set(); 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( key: NewPKey, values: Iterable ): CaseParamsBuilder> { assertNotGenerator(values); const mapped = mapLazy(values, v => ({ [key]: v }) as { [name in NewPKey]: NewPValue }); return this.combineWithParams(mapped); } /** @inheritDoc */ filter(pred: (_: CaseP) => boolean): CaseParamsBuilder { 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 { 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 { 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 extends ParamsBuilderBase implements ParamsBuilder { protected readonly subcases: (_: CaseP) => Generator; constructor( cases: (caseFilter: TestParams | null) => Generator, generator: (_: CaseP) => Generator ) { super(cases); this.subcases = generator; } *iterateCasesWithSubcases(caseFilter: TestParams | null): CaseSubcaseIterable { 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, subcases as DeepReadonly<(typeof subcases)[number]>[], ]; } } } /** @inheritDoc */ expandWithParams( expander: (_: Merged) => Iterable ): SubcaseParamsBuilder> { 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( key: NewPKey, expander: (_: Merged) => Iterable ): SubcaseParamsBuilder> { 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; } } }); } /** @inheritDoc */ combineWithParams( newParams: Iterable ): SubcaseParamsBuilder> { assertNotGenerator(newParams); return this.expandWithParams(() => newParams); } /** @inheritDoc */ combine( key: NewPKey, values: Iterable ): SubcaseParamsBuilder> { assertNotGenerator(values); return this.expand(key, () => values); } /** @inheritDoc */ filter(pred: (_: Merged) => boolean): SubcaseParamsBuilder { 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) => boolean): SubcaseParamsBuilder { 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' ); } }