@ontrails/testing 1.0.0-beta.3 → 1.0.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { describe, test } from 'bun:test';
2
2
 
3
- import { Result, hike, trail, topo } from '@ontrails/core';
3
+ import { Result, trail, topo } from '@ontrails/core';
4
4
  import { z } from 'zod';
5
5
 
6
6
  import { testContracts } from '../contracts.js';
@@ -18,44 +18,43 @@ const validTrail = trail('valid', {
18
18
  name: 'Valid output',
19
19
  },
20
20
  ],
21
- implementation: (input: { name: string }) =>
22
- Result.ok({ id: 1, name: input.name }),
23
21
  input: z.object({ name: z.string() }),
24
22
  output: z.object({ id: z.number(), name: z.string() }),
23
+ run: (input: { name: string }) => Result.ok({ id: 1, name: input.name }),
25
24
  });
26
25
 
27
26
  /** Trail without output schema -- should be skipped. */
28
27
  const noSchemaTrail = trail('noschema', {
29
28
  examples: [{ expected: 10, input: { x: 5 }, name: 'No schema' }],
30
- implementation: (input: { x: number }) => Result.ok(input.x * 2),
31
29
  input: z.object({ x: z.number() }),
30
+ run: (input: { x: number }) => Result.ok(input.x * 2),
32
31
  });
33
32
 
34
33
  /** Trail without examples -- should be skipped. */
35
34
  const noExamplesTrail = trail('noexamples', {
36
- implementation: (input: { x: number }) => Result.ok({ value: input.x }),
37
35
  input: z.object({ x: z.number() }),
38
36
  output: z.object({ value: z.number() }),
37
+ run: (input: { x: number }) => Result.ok({ value: input.x }),
39
38
  });
40
39
 
41
40
  // ---------------------------------------------------------------------------
42
- // Test hikes
41
+ // Composition trail
43
42
  // ---------------------------------------------------------------------------
44
43
 
45
- /** Hike whose implementation matches the output schema. */
46
- const validHike = hike('hike.valid', {
44
+ /** Composition trail whose implementation matches the output schema. */
45
+ const compositionTrail = trail('composition.valid', {
47
46
  examples: [
48
47
  {
49
48
  expected: { total: 3 },
50
49
  input: { a: 1, b: 2 },
51
- name: 'Valid hike output',
50
+ name: 'Valid composition output',
52
51
  },
53
52
  ],
54
- follows: ['valid'],
55
- implementation: (input: { a: number; b: number }) =>
56
- Result.ok({ total: input.a + input.b }),
53
+ follow: ['valid'],
57
54
  input: z.object({ a: z.number(), b: z.number() }),
58
55
  output: z.object({ total: z.number() }),
56
+ run: (input: { a: number; b: number }) =>
57
+ Result.ok({ total: input.a + input.b }),
59
58
  });
60
59
 
61
60
  // ---------------------------------------------------------------------------
@@ -87,7 +86,9 @@ describe('testContracts: skips trails without examples', () => {
87
86
  });
88
87
  });
89
88
 
90
- describe('testContracts: validates hike output schemas', () => {
89
+ describe('testContracts: validates composition trail output schemas', () => {
91
90
  // eslint-disable-next-line jest/require-hook
92
- testContracts(topo('test-app', { validHike } as Record<string, unknown>));
91
+ testContracts(
92
+ topo('test-app', { compositionTrail } as Record<string, unknown>)
93
+ );
93
94
  });
@@ -13,18 +13,18 @@ const showTrail = trail('entity.show', {
13
13
  detours: {
14
14
  related: ['entity.list'],
15
15
  },
16
- implementation: (input: { id: string }) => Result.ok({ id: input.id }),
17
16
  input: z.object({ id: z.string() }),
17
+ run: (input: { id: string }) => Result.ok({ id: input.id }),
18
18
  });
19
19
 
20
20
  const listTrail = trail('entity.list', {
21
- implementation: () => Result.ok([]),
22
21
  input: z.object({}),
22
+ run: () => Result.ok([]),
23
23
  });
24
24
 
25
25
  const noDetoursTrail = trail('entity.plain', {
26
- implementation: () => Result.ok('ok'),
27
26
  input: z.object({}),
27
+ run: () => Result.ok('ok'),
28
28
  });
29
29
 
30
30
  // ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  import { describe, test } from 'bun:test';
2
2
 
3
- import { NotFoundError, Result, hike, trail, topo } from '@ontrails/core';
3
+ import { NotFoundError, Result, trail, topo } from '@ontrails/core';
4
4
  import { z } from 'zod';
5
5
 
6
6
  import { testExamples } from '../examples.js';
@@ -18,10 +18,10 @@ const greetTrail = trail('greet', {
18
18
  name: 'Greet Alice',
19
19
  },
20
20
  ],
21
- implementation: (input: { name: string }) =>
22
- Result.ok({ greeting: `Hello, ${input.name}` }),
23
21
  input: z.object({ name: z.string() }),
24
22
  output: z.object({ greeting: z.string() }),
23
+ run: (input: { name: string }) =>
24
+ Result.ok({ greeting: `Hello, ${input.name}` }),
25
25
  });
26
26
 
27
27
  const searchTrail = trail('search', {
@@ -32,10 +32,10 @@ const searchTrail = trail('search', {
32
32
  name: 'Schema-only search',
33
33
  },
34
34
  ],
35
- implementation: (input: { query: string }) =>
36
- Result.ok({ results: [`result for ${input.query}`] }),
37
35
  input: z.object({ query: z.string() }),
38
36
  output: z.object({ results: z.array(z.string()) }),
37
+ run: (input: { query: string }) =>
38
+ Result.ok({ results: [`result for ${input.query}`] }),
39
39
  });
40
40
 
41
41
  const entityTrail = trail('entity.show', {
@@ -52,42 +52,41 @@ const entityTrail = trail('entity.show', {
52
52
  name: 'Entity not found returns NotFoundError',
53
53
  },
54
54
  ],
55
- implementation: (input: { name: string }) => {
55
+ input: z.object({ name: z.string() }),
56
+ output: z.object({ id: z.number(), name: z.string() }),
57
+ run: (input: { name: string }) => {
56
58
  if (input.name === 'missing') {
57
59
  return Result.err(new NotFoundError('Entity not found'));
58
60
  }
59
61
  return Result.ok({ id: 1, name: input.name });
60
62
  },
61
- input: z.object({ name: z.string() }),
62
- output: z.object({ id: z.number(), name: z.string() }),
63
63
  });
64
64
 
65
65
  const noExamplesTrail = trail('noexamples', {
66
- implementation: (input: { x: number }) => Result.ok(input.x * 2),
67
66
  input: z.object({ x: z.number() }),
67
+ run: (input: { x: number }) => Result.ok(input.x * 2),
68
68
  });
69
69
 
70
70
  // ---------------------------------------------------------------------------
71
- // Test hikes (for follows coverage)
71
+ // Composition trails (for follow coverage)
72
72
  // ---------------------------------------------------------------------------
73
73
 
74
74
  const addTrail = trail('entity.add', {
75
75
  description: 'Add an entity',
76
- implementation: (input: { name: string }) =>
77
- Result.ok({ id: '1', name: input.name }),
78
76
  input: z.object({ name: z.string() }),
79
77
  output: z.object({ id: z.string(), name: z.string() }),
78
+ run: (input: { name: string }) => Result.ok({ id: '1', name: input.name }),
80
79
  });
81
80
 
82
81
  const relateTrail = trail('entity.relate', {
83
82
  description: 'Relate entities',
84
- implementation: (input: { from: string; to: string }) =>
85
- Result.ok({ from: input.from, to: input.to }),
86
83
  input: z.object({ from: z.string(), to: z.string() }),
87
84
  output: z.object({ from: z.string(), to: z.string() }),
85
+ run: (input: { from: string; to: string }) =>
86
+ Result.ok({ from: input.from, to: input.to }),
88
87
  });
89
88
 
90
- const onboardHike = hike('entity.onboard', {
89
+ const onboardTrail = trail('entity.onboard', {
91
90
  description: 'Onboard a new entity',
92
91
  examples: [
93
92
  {
@@ -96,8 +95,10 @@ const onboardHike = hike('entity.onboard', {
96
95
  name: 'Onboard Alpha',
97
96
  },
98
97
  ],
99
- follows: ['entity.add', 'entity.relate'],
100
- implementation: async (input: { name: string }, ctx) => {
98
+ follow: ['entity.add', 'entity.relate'],
99
+ input: z.object({ name: z.string() }),
100
+ output: z.object({ id: z.string(), name: z.string() }),
101
+ run: async (input: { name: string }, ctx) => {
101
102
  if (!ctx.follow) {
102
103
  return Result.err(new Error('follow not available'));
103
104
  }
@@ -114,8 +115,6 @@ const onboardHike = hike('entity.onboard', {
114
115
  }
115
116
  return Result.ok({ id: '1', name: input.name });
116
117
  },
117
- input: z.object({ name: z.string() }),
118
- output: z.object({ id: z.string(), name: z.string() }),
119
118
  });
120
119
 
121
120
  // ---------------------------------------------------------------------------
@@ -164,12 +163,12 @@ describe('testExamples skips trails with no examples', () => {
164
163
  });
165
164
  });
166
165
 
167
- describe('testExamples follows coverage for hikes', () => {
166
+ describe('testExamples follow coverage for composition trails', () => {
168
167
  // eslint-disable-next-line jest/require-hook
169
168
  testExamples(
170
- topo('hike-app', {
169
+ topo('composition-app', {
171
170
  addTrail,
172
- onboardHike,
171
+ onboardTrail,
173
172
  relateTrail,
174
173
  } as Record<string, unknown>)
175
174
  );
@@ -1,13 +1,13 @@
1
1
  import { describe } from 'bun:test';
2
2
 
3
3
  import type { AnyTrail, TrailContext } from '@ontrails/core';
4
- import { Result, hike, trail } from '@ontrails/core';
4
+ import { Result, trail } from '@ontrails/core';
5
5
  import { z } from 'zod';
6
6
 
7
- import { testHike } from '../hike.js';
7
+ import { testFollows } from '../follows.js';
8
8
 
9
9
  // ---------------------------------------------------------------------------
10
- // Test trails (followed by hikes)
10
+ // Test trails (followed by composition trail)
11
11
  // ---------------------------------------------------------------------------
12
12
 
13
13
  const addTrail = trail('entity.add', {
@@ -21,27 +21,28 @@ const addTrail = trail('entity.add', {
21
21
  name: 'duplicate',
22
22
  },
23
23
  ],
24
- implementation: (input: { name: string }) =>
25
- Result.ok({ id: '1', name: input.name }),
26
24
  input: z.object({ name: z.string() }),
27
25
  output: z.object({ id: z.string(), name: z.string() }),
26
+ run: (input: { name: string }) => Result.ok({ id: '1', name: input.name }),
28
27
  });
29
28
 
30
29
  const relateTrail = trail('entity.relate', {
31
30
  description: 'Relate two entities',
32
- implementation: (input: { from: string; to: string }) =>
33
- Result.ok({ from: input.from, to: input.to }),
34
31
  input: z.object({ from: z.string(), to: z.string() }),
35
32
  output: z.object({ from: z.string(), to: z.string() }),
33
+ run: (input: { from: string; to: string }) =>
34
+ Result.ok({ from: input.from, to: input.to }),
36
35
  });
37
36
 
38
37
  // ---------------------------------------------------------------------------
39
- // Test hike
38
+ // Composition trail
40
39
  // ---------------------------------------------------------------------------
41
40
 
42
- const onboardHike = hike('entity.onboard', {
43
- follows: ['entity.add', 'entity.relate'],
44
- implementation: async (
41
+ const onboardTrail = trail('entity.onboard', {
42
+ follow: ['entity.add', 'entity.relate'],
43
+ input: z.object({ name: z.string(), relatedTo: z.string() }),
44
+ output: z.object({ name: z.string(), relatedTo: z.string() }),
45
+ run: async (
45
46
  input: { name: string; relatedTo: string },
46
47
  ctx: TrailContext
47
48
  ) => {
@@ -69,8 +70,6 @@ const onboardHike = hike('entity.onboard', {
69
70
  relatedTo: relateResult.value.to,
70
71
  });
71
72
  },
72
- input: z.object({ name: z.string(), relatedTo: z.string() }),
73
- output: z.object({ name: z.string(), relatedTo: z.string() }),
74
73
  });
75
74
 
76
75
  const trailsMap = new Map<string, AnyTrail>([
@@ -84,10 +83,10 @@ const trailsMap = new Map<string, AnyTrail>([
84
83
 
85
84
  const opts = { trails: trailsMap };
86
85
 
87
- describe('testHike: expectOk', () => {
86
+ describe('testFollows: expectOk', () => {
88
87
  // eslint-disable-next-line jest/require-hook
89
- testHike(
90
- onboardHike,
88
+ testFollows(
89
+ onboardTrail,
91
90
  [
92
91
  {
93
92
  description: 'basic onboard succeeds',
@@ -99,10 +98,10 @@ describe('testHike: expectOk', () => {
99
98
  );
100
99
  });
101
100
 
102
- describe('testHike: expectFollowed', () => {
101
+ describe('testFollows: expectFollowed', () => {
103
102
  // eslint-disable-next-line jest/require-hook
104
- testHike(
105
- onboardHike,
103
+ testFollows(
104
+ onboardTrail,
106
105
  [
107
106
  {
108
107
  description: 'follows add then relate in order',
@@ -115,10 +114,10 @@ describe('testHike: expectFollowed', () => {
115
114
  );
116
115
  });
117
116
 
118
- describe('testHike: expectFollowedCount', () => {
117
+ describe('testFollows: expectFollowedCount', () => {
119
118
  // eslint-disable-next-line jest/require-hook
120
- testHike(
121
- onboardHike,
119
+ testFollows(
120
+ onboardTrail,
122
121
  [
123
122
  {
124
123
  description: 'each trail followed exactly once',
@@ -131,10 +130,10 @@ describe('testHike: expectFollowedCount', () => {
131
130
  );
132
131
  });
133
132
 
134
- describe('testHike: injectFromExample', () => {
133
+ describe('testFollows: injectFromExample', () => {
135
134
  // eslint-disable-next-line jest/require-hook
136
- testHike(
137
- onboardHike,
135
+ testFollows(
136
+ onboardTrail,
138
137
  [
139
138
  {
140
139
  description: 'inject duplicate error from add trail example',
@@ -148,10 +147,10 @@ describe('testHike: injectFromExample', () => {
148
147
  );
149
148
  });
150
149
 
151
- describe('testHike: expectValue', () => {
150
+ describe('testFollows: expectValue', () => {
152
151
  // eslint-disable-next-line jest/require-hook
153
- testHike(
154
- onboardHike,
152
+ testFollows(
153
+ onboardTrail,
155
154
  [
156
155
  {
157
156
  description: 'exact value match',
@@ -10,20 +10,20 @@ import { testTrail } from '../trail.js';
10
10
  // ---------------------------------------------------------------------------
11
11
 
12
12
  const greetTrail = trail('greet', {
13
- implementation: (input: { name: string }) =>
14
- Result.ok({ greeting: `Hello, ${input.name}` }),
15
13
  input: z.object({ name: z.string() }),
16
14
  output: z.object({ greeting: z.string() }),
15
+ run: (input: { name: string }) =>
16
+ Result.ok({ greeting: `Hello, ${input.name}` }),
17
17
  });
18
18
 
19
19
  const failTrail = trail('fail', {
20
- implementation: (input: { id: string }) => {
20
+ input: z.object({ id: z.string() }),
21
+ run: (input: { id: string }) => {
21
22
  if (input.id === 'missing') {
22
23
  return Result.err(new NotFoundError('Not found: missing'));
23
24
  }
24
25
  return Result.ok({ id: input.id });
25
26
  },
26
- input: z.object({ id: z.string() }),
27
27
  });
28
28
 
29
29
  // ---------------------------------------------------------------------------
package/src/contracts.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * testContracts — output schema verification.
3
3
  *
4
- * For every trail and hike that has both examples and an output schema,
4
+ * For every trail that has both examples and an output schema,
5
5
  * run each example and validate the implementation output against
6
6
  * the declared schema.
7
7
  */
@@ -19,13 +19,13 @@ import { mergeTestContext } from './context.js';
19
19
  // Helpers
20
20
  // ---------------------------------------------------------------------------
21
21
 
22
- /** Check if a trail/hike requires follow() but the context doesn't provide it. */
22
+ /** Check if a trail requires follow() but the context doesn't provide it. */
23
23
  const needsFollowContext = (
24
24
  t: unknown,
25
25
  resolveCtx: () => Partial<TrailContext> | undefined
26
26
  ): boolean => {
27
- const spec = t as { follows?: readonly string[] };
28
- if (!spec.follows || spec.follows.length === 0) {
27
+ const spec = t as { follow?: readonly string[] };
28
+ if (!spec.follow || spec.follow.length === 0) {
29
29
  return false;
30
30
  }
31
31
  return !resolveCtx()?.follow;
@@ -51,10 +51,10 @@ const validateOutputSchema = (
51
51
  // ---------------------------------------------------------------------------
52
52
 
53
53
  /**
54
- * Verify that every trail and hike implementation output matches its declared
54
+ * Verify that every trail implementation output matches its declared
55
55
  * output schema. Catches implementation-schema drift.
56
56
  *
57
- * Trails and hikes without output schemas or examples are skipped.
57
+ * Trails without output schemas or examples are skipped.
58
58
  */
59
59
  export const testContracts = (
60
60
  app: Topo,
@@ -87,7 +87,7 @@ export const testContracts = (
87
87
  const validated = validateInput(t.input, example.input);
88
88
  const validatedInput = expectOk(validated);
89
89
 
90
- const result = await t.implementation(validatedInput, testCtx);
90
+ const result = await t.run(validatedInput, testCtx);
91
91
  const resultValue = expectOk(result);
92
92
 
93
93
  validateOutputSchema(outputSchema, resultValue, t.id, example.name);
package/src/examples.ts CHANGED
@@ -3,14 +3,13 @@
3
3
  *
4
4
  * Iterates every trail in the app's topo. For each trail with examples,
5
5
  * generates describe/test blocks using bun:test. Progressive assertion
6
- * determines which check to run per example. For hikes with `follows`
6
+ * determines which check to run per example. For trails with `follow`
7
7
  * declarations, checks that every declared follow was called at least once.
8
8
  */
9
9
 
10
10
  import { describe, expect, test } from 'bun:test';
11
11
 
12
12
  import type {
13
- AnyHike,
14
13
  FollowFn,
15
14
  Topo,
16
15
  TrailExample,
@@ -138,12 +137,12 @@ const runExample = async (
138
137
  }
139
138
  const validatedInput = expectOk(validated);
140
139
 
141
- const result = await t.implementation(validatedInput, testCtx);
140
+ const result = await t.run(validatedInput, testCtx);
142
141
  assertProgressiveMatch(result, example, output);
143
142
  };
144
143
 
145
144
  // ---------------------------------------------------------------------------
146
- // Follows coverage for hikes
145
+ // Follow coverage for composition trails
147
146
  // ---------------------------------------------------------------------------
148
147
 
149
148
  /**
@@ -172,7 +171,7 @@ const createCoverageFollow = (
172
171
  if (validated.isErr()) {
173
172
  return Promise.resolve(validated);
174
173
  }
175
- return Promise.resolve(trailDef.implementation(validated.value, ctx));
174
+ return Promise.resolve(trailDef.run(validated.value, ctx));
176
175
  }
177
176
 
178
177
  return Promise.resolve(Result.ok());
@@ -181,17 +180,17 @@ const createCoverageFollow = (
181
180
  };
182
181
 
183
182
  /**
184
- * Run a single example against a hike, recording follow calls.
183
+ * Run a single example against a composition trail, recording follow calls.
185
184
  */
186
- const runHikeExample = async (
187
- hikeDef: AnyHike,
185
+ const runCompositionExample = async (
186
+ trailDef: Trail<unknown, unknown>,
188
187
  example: TrailExample<unknown, unknown>,
189
188
  output: z.ZodType | undefined,
190
189
  baseCtx: TrailContext,
191
190
  called: Set<string>,
192
191
  topo: Topo
193
192
  ): Promise<void> => {
194
- const validated = validateInput(hikeDef.input, example.input);
193
+ const validated = validateInput(trailDef.input, example.input);
195
194
 
196
195
  if (handleValidationError(validated, example)) {
197
196
  return;
@@ -201,75 +200,10 @@ const runHikeExample = async (
201
200
  const follow = createCoverageFollow(called, baseCtx.follow, topo, baseCtx);
202
201
  const testCtx: TrailContext = { ...baseCtx, follow };
203
202
 
204
- const result = await hikeDef.implementation(validatedInput, testCtx);
203
+ const result = await trailDef.run(validatedInput, testCtx);
205
204
  assertProgressiveMatch(result, example, output);
206
205
  };
207
206
 
208
- // ---------------------------------------------------------------------------
209
- // Hike entry with examples pre-validated
210
- // ---------------------------------------------------------------------------
211
-
212
- interface HikeWithExamples {
213
- readonly hikeDef: AnyHike;
214
- readonly hikeId: string;
215
- readonly examples: readonly TrailExample<unknown, unknown>[];
216
- }
217
-
218
- const collectHikesWithExamples = (app: Topo): readonly HikeWithExamples[] =>
219
- [...app.hikes]
220
- .filter(([, h]) => h.examples !== undefined && h.examples.length > 0)
221
- .map(([hikeId, hikeDef]) => ({
222
- examples: hikeDef.examples as readonly TrailExample<unknown, unknown>[],
223
- hikeDef,
224
- hikeId,
225
- }));
226
-
227
- // ---------------------------------------------------------------------------
228
- // Hike example describe blocks
229
- // ---------------------------------------------------------------------------
230
-
231
- /**
232
- * Generate describe/test blocks for hikes with follows coverage.
233
- *
234
- * Always uses a recording follow so that follows coverage can be checked.
235
- * Hikes without `follows` still run their examples but skip the coverage test.
236
- */
237
- const describeHikeExamples = (
238
- hikesWithExamples: readonly HikeWithExamples[],
239
- resolveCtx: () => Partial<TrailContext> | undefined,
240
- topo: Topo
241
- ): void => {
242
- if (hikesWithExamples.length === 0) {
243
- return;
244
- }
245
-
246
- describe.each([...hikesWithExamples])('$hikeId', ({ hikeDef, examples }) => {
247
- const called = new Set<string>();
248
-
249
- test.each([...examples])(
250
- 'example: $name',
251
- async (example: TrailExample<unknown, unknown>) => {
252
- const baseCtx = mergeTestContext(resolveCtx());
253
- await runHikeExample(
254
- hikeDef,
255
- example,
256
- hikeDef.output,
257
- baseCtx,
258
- called,
259
- topo
260
- );
261
- }
262
- );
263
-
264
- if (hikeDef.follows.length > 0) {
265
- test('follows coverage', () => {
266
- const uncovered = hikeDef.follows.filter((id) => !called.has(id));
267
- expect(uncovered).toEqual([]);
268
- });
269
- }
270
- });
271
- };
272
-
273
207
  // ---------------------------------------------------------------------------
274
208
  // testExamples
275
209
  // ---------------------------------------------------------------------------
@@ -277,7 +211,7 @@ const describeHikeExamples = (
277
211
  /**
278
212
  * Generate describe/test blocks for every trail example in the app.
279
213
  *
280
- * For hikes with `follows` declarations and examples, also verifies that
214
+ * For trails with `follow` declarations and examples, also verifies that
281
215
  * every declared follow ID was called at least once across all examples.
282
216
  *
283
217
  * One line in your test file:
@@ -291,24 +225,54 @@ export const testExamples = (
291
225
  ): void => {
292
226
  const resolveCtx =
293
227
  typeof ctxOrFactory === 'function' ? ctxOrFactory : () => ctxOrFactory;
294
- const trailEntries = [...app.trails];
228
+ const allTrails = app.list() as Trail<unknown, unknown>[];
295
229
 
296
- describe.each(trailEntries)('%s', (_id, trailDef) => {
297
- const t = trailDef as Trail<unknown, unknown>;
298
- if (t.examples === undefined || t.examples.length === 0) {
299
- return;
300
- }
230
+ const withExamples = allTrails.filter(
231
+ (t) => t.examples !== undefined && t.examples.length > 0
232
+ );
233
+ const simpleTrails = withExamples.filter((t) => t.follow.length === 0);
234
+ const compositionTrails = withExamples.filter((t) => t.follow.length > 0);
235
+
236
+ // Simple trails: run examples directly
237
+ if (simpleTrails.length > 0) {
238
+ describe.each(simpleTrails)('$id', (t) => {
239
+ const { examples, output } = t;
240
+ if (!examples) {
241
+ return;
242
+ }
301
243
 
302
- const { examples, output } = t;
244
+ test.each([...examples])(
245
+ 'example: $name',
246
+ async (example: TrailExample<unknown, unknown>) => {
247
+ const testCtx = mergeTestContext(resolveCtx());
248
+ await runExample(t, example, output, testCtx);
249
+ }
250
+ );
251
+ });
252
+ }
303
253
 
304
- test.each([...examples])(
305
- 'example: $name',
306
- async (example: TrailExample<unknown, unknown>) => {
307
- const testCtx = mergeTestContext(resolveCtx());
308
- await runExample(t, example, output, testCtx);
254
+ // Composition trails: use recording follow and check coverage
255
+ if (compositionTrails.length > 0) {
256
+ describe.each(compositionTrails)('$id', (t) => {
257
+ const { examples, output } = t;
258
+ if (!examples) {
259
+ return;
309
260
  }
310
- );
311
- });
312
261
 
313
- describeHikeExamples(collectHikesWithExamples(app), resolveCtx, app);
262
+ const called = new Set<string>();
263
+
264
+ test.each([...examples])(
265
+ 'example: $name',
266
+ async (example: TrailExample<unknown, unknown>) => {
267
+ const baseCtx = mergeTestContext(resolveCtx());
268
+ await runCompositionExample(t, example, output, baseCtx, called, app);
269
+ }
270
+ );
271
+
272
+ test('follow coverage', () => {
273
+ const uncovered = t.follow.filter((id) => !called.has(id));
274
+ expect(uncovered).toEqual([]);
275
+ });
276
+ });
277
+ }
314
278
  };