diff --git a/.changeset/stub-later-with-wins.md b/.changeset/stub-later-with-wins.md new file mode 100644 index 0000000000..ba905c4e64 --- /dev/null +++ b/.changeset/stub-later-with-wins.md @@ -0,0 +1,12 @@ +--- +"type-plus": major +--- + +Let a later `stub.builder().with()` object win over an earlier initializer on the same key. + +Before, a later `.with()` object only filled in keys the earlier initializers left unset, so `stub.builder({ a: 1 }).with({ a: 5 }).create()()` returned `{ a: 1 }`. It now returns `{ a: 5 }`. Objects are deep-merged, so nested keys a later object does not set are kept. + +The partial stub passed to the created function still wins over every object initializer, and a function initializer still receives the stub so far and returns the next one. + +Migration: +- If you relied on an earlier `.with()` value surviving a later one, reorder the `.with()` calls or drop the key from the later object. diff --git a/apps/website/src/content/docs/api/testing.md b/apps/website/src/content/docs/api/testing.md index dc17a0b772..937423fa3f 100644 --- a/apps/website/src/content/docs/api/testing.md +++ b/apps/website/src/content/docs/api/testing.md @@ -489,17 +489,91 @@ const user = stub({ name: 'Alice' }) user.name // string ``` -### stub.builder and stub.build +### stub.build -`stub.builder(init)` returns a builder with `.with(init)` to layer on more partials or handler -functions, and `.create()` to produce the final stub factory. `stub.build(init)` is the shorthand for -`stub.builder(init).create()`. +```ts +stub.build(init: RecursivePartial | ((stub?: RecursivePartial) => RecursivePartial)): (stub?: RecursivePartial) => T +``` + +Builds a stub factory for `T` from default values. Each call deep-merges the partial stub you pass over +`init`, so a value you pass wins over the default. + +```ts +import { stub } from 'type-plus/testing' + +type User = { id: string; name: string; profile: { email: string } } + +const createUser = stub.build({ id: '1', name: 'Alice', profile: { email: 'alice@example.com' } }) + +createUser() // { id: '1', name: 'Alice', profile: { email: 'alice@example.com' } } +createUser({ name: 'Bob' }) // { id: '1', name: 'Bob', profile: { email: 'alice@example.com' } } +``` + +`init` can also be a function. It receives the partial stub passed to the call, or `undefined`, and +returns the stub to use, which lets the defaults depend on what the caller passed. What the function +returns is used as is, so it decides which values win: here `...s` comes after the defaults. + +```ts +const createUser = stub.build((s) => ({ + id: '1', + name: 'Alice', + ...s, + profile: { email: `${s?.name ?? 'alice'}@example.com`, ...s?.profile }, +})) + +createUser({ name: 'bob' }) // { id: '1', name: 'bob', profile: { email: 'bob@example.com' } } +``` + +`stub.build(init)` is the shorthand for `stub.builder(init).create()`. + +### stub.builder + +```ts +stub.builder(init: RecursivePartial | ((stub?: RecursivePartial) => RecursivePartial)) +``` + +Creates a builder for a stub factory of `T`, starting from `init`. The builder has two methods: + +- `.with(init)` adds another partial stub or handler function and returns a new builder. +- `.create()` produces the stub factory, the same kind of function `stub.build` returns. + +```ts +const s = stub.builder<{ a: number; b: string }>({ a: 1 }).with({ b: 'b' }).create() + +s() // { a: 1, b: 'b' } +s({ a: 2 }) // { a: 2, b: 'b' } +``` + +The initializers run in order, starting from the partial stub passed to the call: + +- An object initializer is deep-merged over the earlier ones, so a later `.with()` value wins. Nested + keys it does not set are kept. +- The partial stub passed to the call wins over every object initializer. +- A function initializer receives the stub so far: the partial stub passed to the call, merged with + the initializers before it. What it returns is the next stub, used as is. ```ts -const b = stub.builder<{ a: number; b: string }>({ a: 1 }).with({ b: 'b' }).create() +const s = stub.builder<{ a: number; b: string }>({ a: 1, b: 'b' }).with({ a: 2 }).create() -b({ a: 2 }) // { a: 2, b: 'b' } +s() // { a: 2, b: 'b' } +s({ a: 3 }) // { a: 3, b: 'b' } ``` -Object initializers are deep-merged onto the accumulated stub; function initializers receive the -accumulated stub and return the next one. +A function initializer sees the values merged so far, including the partial stub passed to the call: + +```ts +const s = stub.builder<{ a: number; b: number }>({ a: 1 }).with((acc) => ({ ...acc, b: acc!.a! * 10 })).create() + +s() // { a: 1, b: 10 } +s({ a: 3 }) // { a: 3, b: 30 } +``` + +Each `.with()` returns a new builder and leaves the original unchanged, so one base builder can branch +into several factories. + +```ts +const base = stub.builder({ id: '1', profile: { email: 'user@example.com' } }) + +const createAlice = base.with({ name: 'Alice' }).create() +const createBob = base.with({ name: 'Bob' }).create() +``` diff --git a/packages/type-plus/src/testing/stub.build.spec.ts b/packages/type-plus/src/testing/stub.build.spec.ts index d220a9113d..bd3d2124ea 100644 --- a/packages/type-plus/src/testing/stub.build.spec.ts +++ b/packages/type-plus/src/testing/stub.build.spec.ts @@ -20,3 +20,16 @@ it('pass stub input to the init function', () => { const s = stub.build<{ a: number; b: string }>((stub) => ({ a: (stub?.a ?? 0) + 1 })) expect(s({ a: 1 })).toEqual({ a: 2 }) }) + +it('lets the init function decide which values win', () => { + type User = { id: string; name: string; profile: { email: string } } + const createUser = stub.build((s) => ({ + id: '1', + name: 'Alice', + ...s, + profile: { email: `${s?.name ?? 'alice'}@example.com`, ...s?.profile }, + })) + const r = createUser({ name: 'bob' }) + expect(r).toEqual({ id: '1', name: 'bob', profile: { email: 'bob@example.com' } }) + testType.equal(true) +}) diff --git a/packages/type-plus/src/testing/stub.builder.spec.ts b/packages/type-plus/src/testing/stub.builder.spec.ts index 56d1a24472..64eceacc99 100644 --- a/packages/type-plus/src/testing/stub.builder.spec.ts +++ b/packages/type-plus/src/testing/stub.builder.spec.ts @@ -55,3 +55,70 @@ it('can be use to create multiple builders', () => { expect(b2({ c: 0 })).toEqual({ a: 1, b: 3, c: 0 }) expect(b2({ c: 1 })).toEqual({ a: 1, b: 3, c: 1 }) }) + +it('lets a later init object win over an earlier one', () => { + const s = stub.builder<{ a: number; b: string }>({ a: 1, b: 'b' }).with({ a: 2 }).create() + expect(s()).toEqual({ a: 2, b: 'b' }) + expect(s({ a: 3 })).toEqual({ a: 3, b: 'b' }) +}) + +it('deep-merges a later init object, keeping the nested keys it does not set', () => { + type S = { a: { x: number; y: number }; b: number } + const s = stub + .builder({ a: { x: 1, y: 1 }, b: 1 }) + .with({ a: { x: 2 } }) + .with({ a: { y: 3 } }) + .create() + expect(s()).toEqual({ a: { x: 2, y: 3 }, b: 1 }) + expect(s({ a: { x: 9 } })).toEqual({ a: { x: 9, y: 3 }, b: 1 }) +}) + +it('lets the stub passed to the created function win over every init object', () => { + const s = stub.builder<{ a: number; b: string }>({ a: 1 }).with({ a: 2 }).with({ a: 3, b: 'c' }).create() + expect(s({ a: 0 })).toEqual({ a: 0, b: 'c' }) +}) + +it('passes the stub so far to an init function', () => { + const s = stub + .builder<{ a: number; b: number }>({ a: 1 }) + .with((acc) => ({ ...acc, b: acc!.a! * 10 })) + .create() + expect(s()).toEqual({ a: 1, b: 10 }) + expect(s({ a: 3 })).toEqual({ a: 3, b: 30 }) +}) + +it('passes the later init object value to a following init function', () => { + const s = stub + .builder<{ a: number; b: number }>({ a: 1 }) + .with({ a: 2 }) + .with((acc) => ({ ...acc, b: acc!.a! * 10 })) + .create() + expect(s()).toEqual({ a: 2, b: 20 }) + expect(s({ a: 3 })).toEqual({ a: 3, b: 30 }) +}) + +it('keeps a value an init function returns when a later init object does not set it', () => { + const s = stub + .builder<{ a: number; b: number }>((acc) => ({ ...acc, a: (acc?.a ?? 0) + 1 })) + .with({ b: 2 }) + .create() + expect(s({ a: 1 })).toEqual({ a: 2, b: 2 }) +}) + +it('leaves the original builder unchanged when calling with()', () => { + const base = stub.builder<{ a: number; b: number }>({ a: 1, b: 1 }) + const derived = base.with({ a: 2 }) + expect(base.create()()).toEqual({ a: 1, b: 1 }) + expect(derived.create()()).toEqual({ a: 2, b: 1 }) + expect(derived).not.toBe(base) +}) + +it('does not mutate the init objects', () => { + const init = { a: { x: 1 } } + const s = stub + .builder<{ a: { x: number } }>(init) + .with({ a: { x: 2 } }) + .create() + s({ a: { x: 3 } }) + expect(init).toEqual({ a: { x: 1 } }) +}) diff --git a/packages/type-plus/src/testing/stub.ts b/packages/type-plus/src/testing/stub.ts index 69ea1925c6..c15d5c1ea2 100644 --- a/packages/type-plus/src/testing/stub.ts +++ b/packages/type-plus/src/testing/stub.ts @@ -27,6 +27,23 @@ function stubBuilder(init: RecursivePartial | ((stub?: RecursivePartial return builderInternal([init]) } +/** + * Drops from `init` every path that `stub` sets, so an init object never overrides the stub passed at call time. + */ +function withoutPathsOf(init: any, stub: any): any { + if (!isMergeable(stub)) return init + return Object.keys(init).reduce>((p, k) => { + const s = stub[k] + if (s === undefined) p[k] = init[k] + else if (isMergeable(s) && isMergeable(init[k])) p[k] = withoutPathsOf(init[k], s) + return p + }, {}) +} + +function isMergeable(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + function builderInternal( initializers: Array | ((stub?: RecursivePartial) => RecursivePartial)>, ) { @@ -34,8 +51,9 @@ function builderInternal( /** * Adds an init object or handler to the builder. * - * If `init` is an object, it will be merged with the stub object. - * If `init` is a function, it will be called with the stub object. + * If `init` is an object, it is deep-merged over the earlier initializers, + * except where the partial stub passed at call time sets a value. + * If `init` is a function, it is called with the stub so far and returns the next one. * * @return {Builder} The builder instance. */ @@ -45,14 +63,13 @@ function builderInternal( /** * Creates the resulting stub function. */ - create() { return (stub?: RecursivePartial) => { return initializers.reduce((acc, init) => { if (typeof init === 'function') { return init(acc) } - return requiredDeep>(init, acc) + return requiredDeep>(acc ?? {}, withoutPathsOf(init, stub)) }, stub) as T } }, @@ -69,7 +86,8 @@ export declare namespace stub { * * Each call merges its own partial stub over `init`, so a value you pass in * wins over the default. `init` can also be a function: it receives the - * partial stub passed to the call and returns the stub to use. + * partial stub passed to the call, or `undefined`, and returns the stub to use. + * What a function returns is used as is, so it decides which values win. * * It is `stub.builder(init).create()`. * @@ -78,6 +96,18 @@ export declare namespace stub { * const s = stub.build<{ a: number; b: string }>({ b: 'b' }) * s({ a: 1 }) // { a: 1, b: 'b' } * ``` + * + * @example + * ```ts + * type User = { id: string; name: string; profile: { email: string } } + * const createUser = stub.build((s) => ({ + * id: '1', + * name: 'Alice', + * ...s, + * profile: { email: `${s?.name ?? 'alice'}@example.com`, ...s?.profile }, + * })) + * createUser({ name: 'bob' }) // { id: '1', name: 'bob', profile: { email: 'bob@example.com' } } + * ``` */ let build: typeof buildStub /** @@ -90,6 +120,14 @@ export declare namespace stub { * - `.with(init)`: adds another partial stub or handler, applied after the previous ones. * - `.create()`: creates the stub function. * + * The initializers run in order, starting from the partial stub passed to the call: + * + * - An object initializer is deep-merged over the earlier ones, so a later `.with()` value wins. + * Nested keys it does not set are kept. + * - The partial stub passed to the call wins over every object initializer. + * - A function initializer receives the stub so far: the partial stub passed to the call, + * merged with the initializers before it. What it returns is the next stub, used as is. + * * Each `.with()` returns a new builder, so one builder can branch into several. * * @example @@ -98,6 +136,20 @@ export declare namespace stub { * s() // { a: 1, b: 'b' } * s({ a: 2 }) // { a: 2, b: 'b' } * ``` + * + * @example + * ```ts + * const s = stub.builder<{ a: number; b: string }>({ a: 1, b: 'b' }).with({ a: 2 }).create() + * s() // { a: 2, b: 'b' } + * s({ a: 3 }) // { a: 3, b: 'b' } + * ``` + * + * @example + * ```ts + * const s = stub.builder<{ a: number; b: number }>({ a: 1 }).with((acc) => ({ ...acc, b: acc!.a! * 10 })).create() + * s() // { a: 1, b: 10 } + * s({ a: 3 }) // { a: 3, b: 30 } + * ``` */ let builder: typeof stubBuilder }