params_builder.ts (13708B)
1 import { Merged, mergeParams, mergeParamsChecked } from '../internal/params_utils.js'; 2 import { comparePublicParamsPaths, Ordering } from '../internal/query/compare.js'; 3 import { stringifyPublicParams } from '../internal/query/stringify_params.js'; 4 import { DeepReadonly } from '../util/types.js'; 5 import { assert, mapLazy, objectEquals } from '../util/util.js'; 6 7 import { TestParams } from './fixture.js'; 8 9 // ================================================================ 10 // "Public" ParamsBuilder API / Documentation 11 // ================================================================ 12 13 /** 14 * Provides doc comments for the methods of CaseParamsBuilder and SubcaseParamsBuilder. 15 * (Also enforces rough interface match between them.) 16 */ 17 export interface ParamsBuilder { 18 /** 19 * Expands each item in `this` into zero or more items. 20 * Each item has its parameters expanded with those returned by the `expander`. 21 * 22 * **Note:** When only a single key is being added, use the simpler `expand` for readability. 23 * 24 * ```text 25 * this = [ a , b , c ] 26 * this.map(expander) = [ f(a) f(b) f(c) ] 27 * = [[a1, a2, a3] , [ b1 ] , [] ] 28 * merge and flatten = [ merge(a, a1), merge(a, a2), merge(a, a3), merge(b, b1) ] 29 * ``` 30 */ 31 /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 32 expandWithParams(expander: (_: any) => any): any; 33 34 /** 35 * Expands each item in `this` into zero or more items. Each item has its parameters expanded 36 * with one new key, `key`, and the values returned by `expander`. 37 */ 38 /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 39 expand(key: string, expander: (_: any) => any): any; 40 41 /** 42 * Expands each item in `this` to multiple items, one for each item in `newParams`. 43 * 44 * In other words, takes the cartesian product of [ the items in `this` ] and `newParams`. 45 * 46 * **Note:** When only a single key is being added, use the simpler `combine` for readability. 47 * 48 * ```text 49 * this = [ {a:1}, {b:2} ] 50 * newParams = [ {x:1}, {y:2} ] 51 * this.combineP(newParams) = [ {a:1,x:1}, {a:1,y:2}, {b:2,x:1}, {b:2,y:2} ] 52 * ``` 53 */ 54 /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 55 combineWithParams(newParams: Iterable<any>): any; 56 57 /** 58 * Expands each item in `this` to multiple items with `{ [name]: value }` for each value. 59 * 60 * In other words, takes the cartesian product of [ the items in `this` ] 61 * and `[ {[name]: value} for each value in values ]` 62 */ 63 /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 64 combine(key: string, newParams: Iterable<any>): any; 65 66 /** 67 * Filters `this` to only items for which `pred` returns true. 68 */ 69 /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 70 filter(pred: (_: any) => boolean): any; 71 72 /** 73 * Filters `this` to only items for which `pred` returns false. 74 */ 75 /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 76 unless(pred: (_: any) => boolean): any; 77 } 78 79 /** 80 * Determines the resulting parameter object type which would be generated by an object of 81 * the given ParamsBuilder type. 82 */ 83 export type ParamTypeOf< 84 /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 85 T extends ParamsBuilder, 86 > = T extends SubcaseParamsBuilder<infer CaseP, infer SubcaseP> 87 ? Merged<CaseP, SubcaseP> 88 : T extends CaseParamsBuilder<infer CaseP> 89 ? CaseP 90 : never; 91 92 // ================================================================ 93 // Implementation 94 // ================================================================ 95 96 /** 97 * Iterable over pairs of either: 98 * - `[case params, Iterable<subcase params>]` if there are subcases. 99 * - `[case params, undefined]` if not. 100 */ 101 export type CaseSubcaseIterable<CaseP, SubcaseP> = Iterable< 102 readonly [DeepReadonly<CaseP>, Iterable<DeepReadonly<SubcaseP>> | undefined] 103 >; 104 105 /** 106 * Base class for `CaseParamsBuilder` and `SubcaseParamsBuilder`. 107 */ 108 export abstract class ParamsBuilderBase<CaseP extends {}, SubcaseP extends {}> { 109 protected readonly cases: (caseFilter: TestParams | null) => Generator<CaseP>; 110 111 constructor(cases: (caseFilter: TestParams | null) => Generator<CaseP>) { 112 this.cases = cases; 113 } 114 115 /** 116 * Hidden from test files. Use `builderIterateCasesWithSubcases` to access this. 117 */ 118 protected abstract iterateCasesWithSubcases( 119 caseFilter: TestParams | null 120 ): CaseSubcaseIterable<CaseP, SubcaseP>; 121 } 122 123 /** 124 * Calls the (normally hidden) `iterateCasesWithSubcases()` method. 125 */ 126 export function builderIterateCasesWithSubcases( 127 builder: ParamsBuilderBase<{}, {}>, 128 caseFilter: TestParams | null 129 ) { 130 interface IterableParamsBuilder { 131 iterateCasesWithSubcases(caseFilter: TestParams | null): CaseSubcaseIterable<{}, {}>; 132 } 133 134 return (builder as unknown as IterableParamsBuilder).iterateCasesWithSubcases(caseFilter); 135 } 136 137 /** 138 * Builder for combinatorial test **case** parameters. 139 * 140 * CaseParamsBuilder is immutable. Each method call returns a new, immutable object, 141 * modifying the list of cases according to the method called. 142 * 143 * This means, for example, that the `unit` passed into `TestBuilder.params()` can be reused. 144 */ 145 export class CaseParamsBuilder<CaseP extends {}> 146 extends ParamsBuilderBase<CaseP, {}> 147 implements Iterable<DeepReadonly<CaseP>>, ParamsBuilder 148 { 149 *iterateCasesWithSubcases(caseFilter: TestParams | null): CaseSubcaseIterable<CaseP, {}> { 150 for (const caseP of this.cases(caseFilter)) { 151 if (caseFilter) { 152 // this.cases() only filters out cases which conflict with caseFilter. Now that we have 153 // the final caseP, filter out cases which are missing keys that caseFilter requires. 154 const ordering = comparePublicParamsPaths(caseP, caseFilter); 155 if (ordering === Ordering.StrictSuperset || ordering === Ordering.Unordered) { 156 continue; 157 } 158 } 159 160 yield [caseP as DeepReadonly<typeof caseP>, undefined]; 161 } 162 } 163 164 [Symbol.iterator](): Iterator<DeepReadonly<CaseP>> { 165 return this.cases(null) as Iterator<DeepReadonly<CaseP>>; 166 } 167 168 /** @inheritDoc */ 169 expandWithParams<NewP extends {}>( 170 expander: (_: CaseP) => Iterable<NewP> 171 ): CaseParamsBuilder<Merged<CaseP, NewP>> { 172 const baseGenerator = this.cases; 173 return new CaseParamsBuilder(function* (caseFilter) { 174 for (const a of baseGenerator(caseFilter)) { 175 for (const b of expander(a)) { 176 if (caseFilter) { 177 // If the expander generated any key-value pair that conflicts with caseFilter, skip. 178 const kvPairs = Object.entries(b); 179 if (kvPairs.some(([k, v]) => k in caseFilter && !objectEquals(caseFilter[k], v))) { 180 continue; 181 } 182 } 183 184 yield mergeParamsChecked(a, b); 185 } 186 } 187 }); 188 } 189 190 /** @inheritDoc */ 191 expand<NewPKey extends string, NewPValue>( 192 key: NewPKey, 193 expander: (_: CaseP) => Iterable<NewPValue> 194 ): CaseParamsBuilder<Merged<CaseP, { [name in NewPKey]: NewPValue }>> { 195 const baseGenerator = this.cases; 196 return new CaseParamsBuilder(function* (caseFilter) { 197 for (const a of baseGenerator(caseFilter)) { 198 assert(!(key in a), `New key '${key}' already exists in ${JSON.stringify(a)}`); 199 200 for (const v of expander(a)) { 201 // If the expander generated a value for this key that conflicts with caseFilter, skip. 202 if (caseFilter && key in caseFilter) { 203 if (!objectEquals(caseFilter[key], v)) { 204 continue; 205 } 206 } 207 yield { ...a, [key]: v } as Merged<CaseP, { [name in NewPKey]: NewPValue }>; 208 } 209 } 210 }); 211 } 212 213 /** @inheritDoc */ 214 combineWithParams<NewP extends {}>( 215 newParams: Iterable<NewP> 216 ): CaseParamsBuilder<Merged<CaseP, NewP>> { 217 assertNotGenerator(newParams); 218 const seenValues = new Set<string>(); 219 for (const params of newParams) { 220 const paramsStr = stringifyPublicParams(params); 221 assert(!seenValues.has(paramsStr), `Duplicate entry in combine[WithParams]: ${paramsStr}`); 222 seenValues.add(paramsStr); 223 } 224 225 return this.expandWithParams(() => newParams); 226 } 227 228 /** @inheritDoc */ 229 combine<NewPKey extends string, NewPValue>( 230 key: NewPKey, 231 values: Iterable<NewPValue> 232 ): CaseParamsBuilder<Merged<CaseP, { [name in NewPKey]: NewPValue }>> { 233 assertNotGenerator(values); 234 const mapped = mapLazy(values, v => ({ [key]: v }) as { [name in NewPKey]: NewPValue }); 235 return this.combineWithParams(mapped); 236 } 237 238 /** @inheritDoc */ 239 filter(pred: (_: CaseP) => boolean): CaseParamsBuilder<CaseP> { 240 const baseGenerator = this.cases; 241 return new CaseParamsBuilder(function* (caseFilter) { 242 for (const a of baseGenerator(caseFilter)) { 243 if (pred(a)) yield a; 244 } 245 }); 246 } 247 248 /** @inheritDoc */ 249 unless(pred: (_: CaseP) => boolean): CaseParamsBuilder<CaseP> { 250 return this.filter(x => !pred(x)); 251 } 252 253 /** 254 * "Finalize" the list of cases and begin defining subcases. 255 * Returns a new SubcaseParamsBuilder. Methods called on SubcaseParamsBuilder 256 * generate new subcases instead of new cases. 257 */ 258 beginSubcases(): SubcaseParamsBuilder<CaseP, {}> { 259 return new SubcaseParamsBuilder(this.cases, function* () { 260 yield {}; 261 }); 262 } 263 } 264 265 /** 266 * The unit CaseParamsBuilder, representing a single case with no params: `[ {} ]`. 267 * 268 * `punit` is passed to every `.params()`/`.paramsSubcasesOnly()` call, so `kUnitCaseParamsBuilder` 269 * is only explicitly needed if constructing a ParamsBuilder outside of a test builder. 270 */ 271 export const kUnitCaseParamsBuilder = new CaseParamsBuilder(function* () { 272 yield {}; 273 }); 274 275 /** 276 * Builder for combinatorial test _subcase_ parameters. 277 * 278 * SubcaseParamsBuilder is immutable. Each method call returns a new, immutable object, 279 * modifying the list of subcases according to the method called. 280 */ 281 export class SubcaseParamsBuilder<CaseP extends {}, SubcaseP extends {}> 282 extends ParamsBuilderBase<CaseP, SubcaseP> 283 implements ParamsBuilder 284 { 285 protected readonly subcases: (_: CaseP) => Generator<SubcaseP>; 286 287 constructor( 288 cases: (caseFilter: TestParams | null) => Generator<CaseP>, 289 generator: (_: CaseP) => Generator<SubcaseP> 290 ) { 291 super(cases); 292 this.subcases = generator; 293 } 294 295 *iterateCasesWithSubcases(caseFilter: TestParams | null): CaseSubcaseIterable<CaseP, SubcaseP> { 296 for (const caseP of this.cases(caseFilter)) { 297 if (caseFilter) { 298 // this.cases() only filters out cases which conflict with caseFilter. Now that we have 299 // the final caseP, filter out cases which are missing keys that caseFilter requires. 300 const ordering = comparePublicParamsPaths(caseP, caseFilter); 301 if (ordering === Ordering.StrictSuperset || ordering === Ordering.Unordered) { 302 continue; 303 } 304 } 305 306 const subcases = Array.from(this.subcases(caseP)); 307 if (subcases.length) { 308 yield [ 309 caseP as DeepReadonly<typeof caseP>, 310 subcases as DeepReadonly<(typeof subcases)[number]>[], 311 ]; 312 } 313 } 314 } 315 316 /** @inheritDoc */ 317 expandWithParams<NewP extends {}>( 318 expander: (_: Merged<CaseP, SubcaseP>) => Iterable<NewP> 319 ): SubcaseParamsBuilder<CaseP, Merged<SubcaseP, NewP>> { 320 const baseGenerator = this.subcases; 321 return new SubcaseParamsBuilder(this.cases, function* (base) { 322 for (const a of baseGenerator(base)) { 323 for (const b of expander(mergeParams(base, a))) { 324 yield mergeParamsChecked(a, b); 325 } 326 } 327 }); 328 } 329 330 /** @inheritDoc */ 331 expand<NewPKey extends string, NewPValue>( 332 key: NewPKey, 333 expander: (_: Merged<CaseP, SubcaseP>) => Iterable<NewPValue> 334 ): SubcaseParamsBuilder<CaseP, Merged<SubcaseP, { [name in NewPKey]: NewPValue }>> { 335 const baseGenerator = this.subcases; 336 return new SubcaseParamsBuilder(this.cases, function* (base) { 337 for (const a of baseGenerator(base)) { 338 const before = mergeParams(base, a); 339 assert(!(key in before), () => `Key '${key}' already exists in ${JSON.stringify(before)}`); 340 341 for (const v of expander(before)) { 342 yield { ...a, [key]: v } as Merged<SubcaseP, { [k in NewPKey]: NewPValue }>; 343 } 344 } 345 }); 346 } 347 348 /** @inheritDoc */ 349 combineWithParams<NewP extends {}>( 350 newParams: Iterable<NewP> 351 ): SubcaseParamsBuilder<CaseP, Merged<SubcaseP, NewP>> { 352 assertNotGenerator(newParams); 353 return this.expandWithParams(() => newParams); 354 } 355 356 /** @inheritDoc */ 357 combine<NewPKey extends string, NewPValue>( 358 key: NewPKey, 359 values: Iterable<NewPValue> 360 ): SubcaseParamsBuilder<CaseP, Merged<SubcaseP, { [name in NewPKey]: NewPValue }>> { 361 assertNotGenerator(values); 362 return this.expand(key, () => values); 363 } 364 365 /** @inheritDoc */ 366 filter(pred: (_: Merged<CaseP, SubcaseP>) => boolean): SubcaseParamsBuilder<CaseP, SubcaseP> { 367 const baseGenerator = this.subcases; 368 return new SubcaseParamsBuilder(this.cases, function* (base) { 369 for (const a of baseGenerator(base)) { 370 if (pred(mergeParams(base, a))) yield a; 371 } 372 }); 373 } 374 375 /** @inheritDoc */ 376 unless(pred: (_: Merged<CaseP, SubcaseP>) => boolean): SubcaseParamsBuilder<CaseP, SubcaseP> { 377 return this.filter(x => !pred(x)); 378 } 379 } 380 381 /** Assert an object is not a Generator (a thing returned from a generator function). */ 382 function assertNotGenerator(x: object) { 383 if ('constructor' in x) { 384 assert( 385 x.constructor !== (function* () {})().constructor, 386 'Argument must not be a generator, as generators are not reusable' 387 ); 388 } 389 }