@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.
- package/CHANGELOG.md +26 -0
- package/README.md +6 -7
- package/dist/contracts.d.ts +3 -3
- package/dist/contracts.js +6 -6
- package/dist/contracts.js.map +1 -1
- package/dist/examples.d.ts +2 -2
- package/dist/examples.d.ts.map +1 -1
- package/dist/examples.js +43 -55
- package/dist/examples.js.map +1 -1
- package/dist/follows.d.ts +32 -0
- package/dist/follows.d.ts.map +1 -0
- package/dist/{hike.js → follows.js} +15 -15
- package/dist/follows.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/trail.js +1 -1
- package/dist/trail.js.map +1 -1
- package/dist/types.d.ts +2 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/contracts.test.ts +15 -14
- package/src/__tests__/detours.test.ts +3 -3
- package/src/__tests__/examples.test.ts +21 -22
- package/src/__tests__/{hike.test.ts → follows.test.ts} +27 -28
- package/src/__tests__/trail.test.ts +4 -4
- package/src/contracts.ts +7 -7
- package/src/examples.ts +55 -91
- package/src/{hike.ts → follows.ts} +30 -30
- package/src/index.ts +3 -3
- package/src/trail.ts +1 -1
- package/src/types.ts +3 -3
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/hike.d.ts +0 -32
- package/dist/hike.d.ts.map +0 -1
- package/dist/hike.js.map +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test } from 'bun:test';
|
|
2
2
|
|
|
3
|
-
import { Result,
|
|
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
|
-
//
|
|
41
|
+
// Composition trail
|
|
43
42
|
// ---------------------------------------------------------------------------
|
|
44
43
|
|
|
45
|
-
/**
|
|
46
|
-
const
|
|
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
|
|
50
|
+
name: 'Valid composition output',
|
|
52
51
|
},
|
|
53
52
|
],
|
|
54
|
-
|
|
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
|
|
89
|
+
describe('testContracts: validates composition trail output schemas', () => {
|
|
91
90
|
// eslint-disable-next-line jest/require-hook
|
|
92
|
-
testContracts(
|
|
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,
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
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
|
|
166
|
+
describe('testExamples follow coverage for composition trails', () => {
|
|
168
167
|
// eslint-disable-next-line jest/require-hook
|
|
169
168
|
testExamples(
|
|
170
|
-
topo('
|
|
169
|
+
topo('composition-app', {
|
|
171
170
|
addTrail,
|
|
172
|
-
|
|
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,
|
|
4
|
+
import { Result, trail } from '@ontrails/core';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { testFollows } from '../follows.js';
|
|
8
8
|
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
|
-
// Test trails (followed by
|
|
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
|
-
//
|
|
38
|
+
// Composition trail
|
|
40
39
|
// ---------------------------------------------------------------------------
|
|
41
40
|
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
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('
|
|
86
|
+
describe('testFollows: expectOk', () => {
|
|
88
87
|
// eslint-disable-next-line jest/require-hook
|
|
89
|
-
|
|
90
|
-
|
|
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('
|
|
101
|
+
describe('testFollows: expectFollowed', () => {
|
|
103
102
|
// eslint-disable-next-line jest/require-hook
|
|
104
|
-
|
|
105
|
-
|
|
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('
|
|
117
|
+
describe('testFollows: expectFollowedCount', () => {
|
|
119
118
|
// eslint-disable-next-line jest/require-hook
|
|
120
|
-
|
|
121
|
-
|
|
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('
|
|
133
|
+
describe('testFollows: injectFromExample', () => {
|
|
135
134
|
// eslint-disable-next-line jest/require-hook
|
|
136
|
-
|
|
137
|
-
|
|
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('
|
|
150
|
+
describe('testFollows: expectValue', () => {
|
|
152
151
|
// eslint-disable-next-line jest/require-hook
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
28
|
-
if (!spec.
|
|
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
|
|
54
|
+
* Verify that every trail implementation output matches its declared
|
|
55
55
|
* output schema. Catches implementation-schema drift.
|
|
56
56
|
*
|
|
57
|
-
* Trails
|
|
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.
|
|
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
|
|
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.
|
|
140
|
+
const result = await t.run(validatedInput, testCtx);
|
|
142
141
|
assertProgressiveMatch(result, example, output);
|
|
143
142
|
};
|
|
144
143
|
|
|
145
144
|
// ---------------------------------------------------------------------------
|
|
146
|
-
//
|
|
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.
|
|
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
|
|
183
|
+
* Run a single example against a composition trail, recording follow calls.
|
|
185
184
|
*/
|
|
186
|
-
const
|
|
187
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
228
|
+
const allTrails = app.list() as Trail<unknown, unknown>[];
|
|
295
229
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
};
|