@tsfpp/agents 1.2.3 → 1.3.1
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 +34 -0
- package/copilot/agents/tsfpp-audit.agent.md +88 -10
- package/copilot/agents/tsfpp-guarded-coding.agent.md +58 -4
- package/copilot/agents/tsfpp-tdd.agent.md +292 -0
- package/copilot/copilot-instructions.md +143 -66
- package/copilot/instructions/tsfpp-api.instructions.md +104 -39
- package/copilot/instructions/tsfpp-base.instructions.md +18 -7
- package/copilot/instructions/tsfpp-prelude.instructions.md +95 -47
- package/copilot/instructions/tsfpp-react.instructions.md +152 -40
- package/copilot/instructions/tsfpp-testing.instructions.md +154 -0
- package/copilot/skills/test-standard/SKILL.md +238 -0
- package/init.mjs +67 -84
- package/package.json +1 -1
|
@@ -12,23 +12,33 @@ Recipes: `node_modules/@tsfpp/prelude/RECIPES.md`
|
|
|
12
12
|
```ts
|
|
13
13
|
import {
|
|
14
14
|
// ADT constructors
|
|
15
|
-
some, none, ok, err,
|
|
15
|
+
some, none, ok, err, unit,
|
|
16
16
|
// Type guards
|
|
17
17
|
isSome, isNone, isOk, isErr,
|
|
18
18
|
// Option combinators
|
|
19
|
-
mapO, flatMapO, orElse, getOrElse, fromNullable,
|
|
19
|
+
mapO, flatMapO, orElse, getOrElse, fromNullable, toNullable,
|
|
20
20
|
// Result combinators
|
|
21
|
-
map, flatMap, flatMapAsync,
|
|
21
|
+
map, flatMap, flatMapAsync, tap, tapErr,
|
|
22
22
|
// Async adapters
|
|
23
23
|
tryCatch, tryCatchAsync,
|
|
24
24
|
// Traversal
|
|
25
25
|
traverseArray, traverseArrayO, sequenceArrayO,
|
|
26
|
+
// Unknown decoding
|
|
27
|
+
isRecord, fromUnknownString, fromUnknownArray, fromUnknownArrayOf, fromNonEmptyString,
|
|
28
|
+
getStringField, getNumberField, getBooleanField, getTypedField,
|
|
29
|
+
// ReadonlyMap
|
|
30
|
+
intoMap, entriesOfMap, assoc, dissoc, lookup,
|
|
31
|
+
// ReadonlySet
|
|
32
|
+
intoSet, conj, disj, member,
|
|
33
|
+
// List
|
|
34
|
+
fromArray, toArray, cons, nil, isCons, isNil,
|
|
26
35
|
// Pipe
|
|
27
36
|
pipe, flow, comp, complement,
|
|
28
37
|
// Utilities
|
|
29
|
-
absurd,
|
|
38
|
+
absurd, unique,
|
|
30
39
|
// Types
|
|
31
40
|
type Option, type Result, type Unit, type Brand,
|
|
41
|
+
type UnknownRecord,
|
|
32
42
|
} from '@tsfpp/prelude'
|
|
33
43
|
```
|
|
34
44
|
|
|
@@ -37,7 +47,6 @@ Never `import from 'ramda'`.
|
|
|
37
47
|
## Option\<A\>
|
|
38
48
|
|
|
39
49
|
```ts
|
|
40
|
-
// Construct
|
|
41
50
|
const a: Option<number> = some(42)
|
|
42
51
|
const b: Option<number> = none
|
|
43
52
|
|
|
@@ -48,17 +57,16 @@ if (isNone(opt)) return ... // early exit
|
|
|
48
57
|
// Transform
|
|
49
58
|
pipe(opt, mapO(n => n + 1))
|
|
50
59
|
pipe(opt, flatMapO(n => n > 0 ? some(n) : none))
|
|
51
|
-
pipe(opt, getOrElse(() => 0))
|
|
52
|
-
pipe(opt, orElse(() => some(
|
|
60
|
+
pipe(opt, getOrElse(() => 0)) // collapse to value
|
|
61
|
+
pipe(opt, orElse(() => some(fallback))) // keep Option context
|
|
53
62
|
|
|
54
|
-
// Lift from nullable
|
|
63
|
+
// Lift from nullable — never use if (x === null) directly
|
|
55
64
|
const opt = fromNullable(maybeNull) // null | undefined → Option<T>
|
|
56
65
|
```
|
|
57
66
|
|
|
58
67
|
## Result\<T, E\>
|
|
59
68
|
|
|
60
69
|
```ts
|
|
61
|
-
// Construct
|
|
62
70
|
const r: Result<number, string> = ok(42)
|
|
63
71
|
const e: Result<number, string> = err('oops')
|
|
64
72
|
|
|
@@ -69,73 +77,113 @@ if (isErr(r)) r.error // E
|
|
|
69
77
|
// Transform
|
|
70
78
|
pipe(r, map(v => v + 1))
|
|
71
79
|
pipe(r, flatMap(v => v > 0 ? ok(v) : err('non-positive')))
|
|
72
|
-
pipe(r, mapErr(e => `Wrapped: ${e}`))
|
|
73
80
|
|
|
74
|
-
// Side effects
|
|
75
|
-
pipe(r,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
// Async adapter — wraps throwing code
|
|
79
|
-
const result = await tryCatchAsync(
|
|
80
|
-
() => db.findById(id),
|
|
81
|
-
e => mkDbError(e),
|
|
81
|
+
// Side effects — never break the pipe chain for logging
|
|
82
|
+
pipe(r,
|
|
83
|
+
tap(v => log.debug({ v })),
|
|
84
|
+
tapErr(e => log.warn({ e })),
|
|
82
85
|
)
|
|
83
86
|
|
|
84
|
-
//
|
|
87
|
+
// Wrapping throwing code — never use raw try/catch in core
|
|
85
88
|
const result = tryCatch(
|
|
86
89
|
() => JSON.parse(raw),
|
|
87
|
-
e => `parse error: ${e}`,
|
|
90
|
+
e => `parse error: ${String(e)}`,
|
|
91
|
+
)
|
|
92
|
+
const result = await tryCatchAsync(
|
|
93
|
+
() => db.findById(id),
|
|
94
|
+
e => mkDbError(e),
|
|
88
95
|
)
|
|
89
96
|
```
|
|
90
97
|
|
|
91
|
-
##
|
|
98
|
+
## Result\<Unit, E\> for no-value success
|
|
92
99
|
|
|
93
100
|
```ts
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
mapO(transform),
|
|
97
|
-
flatMapO(validate),
|
|
98
|
-
getOrElse(() => fallback),
|
|
99
|
-
)
|
|
101
|
+
// Never Result<void, E>
|
|
102
|
+
const save = (): Result<Unit, DbError> => ok(unit)
|
|
100
103
|
```
|
|
101
104
|
|
|
102
|
-
##
|
|
105
|
+
## pipe vs flow
|
|
103
106
|
|
|
104
107
|
```ts
|
|
105
|
-
//
|
|
106
|
-
const
|
|
108
|
+
// pipe — value at hand
|
|
109
|
+
const result = pipe(input, mapO(transform), getOrElse(() => fallback))
|
|
110
|
+
|
|
111
|
+
// flow — reusable pipeline, returns a function
|
|
112
|
+
const process = flow(mapO(transform), getOrElse(() => fallback))
|
|
113
|
+
const result = process(input)
|
|
107
114
|
```
|
|
108
115
|
|
|
109
|
-
## absurd
|
|
116
|
+
## absurd — exhaustiveness witness
|
|
110
117
|
|
|
111
118
|
```ts
|
|
112
|
-
|
|
113
|
-
|
|
119
|
+
switch (result._tag) {
|
|
120
|
+
case 'Ok': return result.value
|
|
121
|
+
case 'Err': return result.error
|
|
122
|
+
default: return absurd(result)
|
|
123
|
+
}
|
|
114
124
|
```
|
|
115
125
|
|
|
116
|
-
##
|
|
126
|
+
## Unknown record decoding
|
|
117
127
|
|
|
118
128
|
```ts
|
|
119
|
-
|
|
129
|
+
import { isRecord, getStringField, getNumberField, getTypedField } from '@tsfpp/prelude'
|
|
130
|
+
|
|
131
|
+
const decode = (raw: unknown): Result<Foo, string> => {
|
|
132
|
+
if (!isRecord(raw)) return err('not an object')
|
|
133
|
+
const name = getStringField(raw, 'name') // Option<string> — rejects empty/whitespace
|
|
134
|
+
const age = getNumberField(raw, 'age') // Option<number> — rejects NaN/Infinity
|
|
135
|
+
const id = getTypedField(raw, 'id', isFooId) // Option<FooId> — custom guard
|
|
136
|
+
return isSome(name) && isSome(age) && isSome(id)
|
|
137
|
+
? ok({ name: name.value, age: age.value, id: id.value })
|
|
138
|
+
: err('missing or invalid fields')
|
|
139
|
+
}
|
|
140
|
+
```
|
|
120
141
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
142
|
+
## Array traversal
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
// Fallible map — short-circuits on first Err
|
|
146
|
+
const all = traverseArray(parseFoo)(rawItems) // Result<ReadonlyArray<Foo>, E>
|
|
147
|
+
// Never: rawItems.map(parseFoo) — produces ReadonlyArray<Result<Foo,E>>
|
|
148
|
+
|
|
149
|
+
// Option traversal — None if any element is None
|
|
150
|
+
traverseArrayO(fromNullable)([1, 2, 3]) // Some([1, 2, 3])
|
|
151
|
+
traverseArrayO(fromNullable)([1, null, 3]) // None
|
|
152
|
+
|
|
153
|
+
// Already have ReadonlyArray<Option<A>>?
|
|
154
|
+
sequenceArrayO([some(1), some(2)]) // Some([1, 2])
|
|
155
|
+
sequenceArrayO([some(1), none]) // None
|
|
156
|
+
|
|
157
|
+
// Guard typed arrays from unknown
|
|
158
|
+
const strings = fromUnknownArrayOf(
|
|
159
|
+
(v): v is string => typeof v === 'string'
|
|
160
|
+
)(raw) // Option<ReadonlyArray<string>>
|
|
125
161
|
```
|
|
126
162
|
|
|
127
|
-
##
|
|
163
|
+
## ReadonlyMap
|
|
128
164
|
|
|
129
165
|
```ts
|
|
130
|
-
//
|
|
131
|
-
const
|
|
166
|
+
// Never new Map()
|
|
167
|
+
const m = intoMap([['a', 1], ['b', 2]]) // ReadonlyMap<string, number>
|
|
168
|
+
const v = pipe(m, lookup('a')) // Some(1)
|
|
169
|
+
const m2 = pipe(m, assoc('c', 3)) // insert / replace
|
|
170
|
+
const m3 = pipe(m2, dissoc('a')) // remove
|
|
171
|
+
const es = entriesOfMap(m) // ReadonlyArray<readonly [string, number]>
|
|
172
|
+
```
|
|
132
173
|
|
|
133
|
-
|
|
134
|
-
|
|
174
|
+
## ReadonlySet
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
// Never new Set()
|
|
178
|
+
const s = intoSet([1, 2, 2, 3]) // ReadonlySet<number> — {1, 2, 3}
|
|
179
|
+
const s2 = pipe(s, conj(4)) // add
|
|
180
|
+
const s3 = pipe(s2, disj(2)) // remove (no-op when absent)
|
|
181
|
+
const has = pipe(s, member(1)) // true
|
|
135
182
|
```
|
|
136
183
|
|
|
137
184
|
## Discriminant convention
|
|
138
185
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
186
|
+
| ADT origin | Field | Access |
|
|
187
|
+
|---|---|---|
|
|
188
|
+
| `@tsfpp/prelude` (Result, Option) | `_tag` | **via guards only** — never `x._tag === 'Ok'` |
|
|
189
|
+
| Domain ADTs | `kind` | `switch (x.kind)` with `absurd` |
|
|
@@ -5,83 +5,195 @@ applyTo: "**/*.tsx"
|
|
|
5
5
|
# TSF++ React rules
|
|
6
6
|
|
|
7
7
|
Full standard: `node_modules/@tsfpp/standard/spec/REACT_CODING_STANDARD.md`
|
|
8
|
-
Extends: tsfpp-base.instructions.md (all base rules apply to `.tsx` too)
|
|
8
|
+
Extends: `tsfpp-base.instructions.md` (all base rules apply to `.tsx` too)
|
|
9
9
|
|
|
10
10
|
## Component shape
|
|
11
11
|
|
|
12
12
|
```ts
|
|
13
|
-
// Props:
|
|
13
|
+
// Props: type alias, Props suffix, all fields readonly, no optional fields
|
|
14
14
|
type TrackCardProps = {
|
|
15
|
-
readonly track:
|
|
15
|
+
readonly track: Track
|
|
16
16
|
readonly onSelect: Option<(id: TrackId) => void>
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
// Component:
|
|
20
|
-
const TrackCard = ({ track, onSelect }: TrackCardProps): React.ReactElement =>
|
|
19
|
+
// Component: arrow const, explicit return type
|
|
20
|
+
const TrackCard = ({ track, onSelect }: TrackCardProps): React.ReactElement => (
|
|
21
|
+
<article className="rounded-lg border p-4">
|
|
22
|
+
<h2>{track.title}</h2>
|
|
23
|
+
</article>
|
|
24
|
+
)
|
|
21
25
|
```
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
One public exported component per file. `.tsx` only when the file contains JSX.
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
## State elimination ladder
|
|
30
|
+
|
|
31
|
+
Exhaust top-to-bottom before introducing state:
|
|
32
|
+
|
|
33
|
+
1. Derivable from props? → compute during render
|
|
34
|
+
2. Derivable from existing state? → compute during render or `useMemo` if expensive
|
|
35
|
+
3. Belongs in URL? → router search/path params
|
|
36
|
+
4. Server data? → TanStack Query
|
|
37
|
+
5. Form state? → React Hook Form
|
|
38
|
+
6. Ephemeral UI state, one component? → `useState` / `useReducer`
|
|
39
|
+
7. Shared between siblings? → lift to nearest common ancestor
|
|
40
|
+
8. Shared across distant subtrees, low-frequency? → Context
|
|
41
|
+
9. Shared across distant subtrees, high-frequency? → Zustand / Jotai
|
|
42
|
+
|
|
43
|
+
Model multi-field or state-machine state with `useReducer` over multiple `useState`:
|
|
26
44
|
|
|
27
45
|
```ts
|
|
28
46
|
// Yes
|
|
29
|
-
type
|
|
47
|
+
type EditorState =
|
|
30
48
|
| { readonly kind: 'idle' }
|
|
31
|
-
| { readonly kind: '
|
|
32
|
-
| { readonly kind: '
|
|
33
|
-
| { readonly kind: 'error';
|
|
49
|
+
| { readonly kind: 'saving' }
|
|
50
|
+
| { readonly kind: 'saved'; readonly at: Date }
|
|
51
|
+
| { readonly kind: 'error'; readonly message: string }
|
|
34
52
|
|
|
35
53
|
// No
|
|
36
54
|
const [isLoading, setIsLoading] = useState(false)
|
|
37
|
-
const [hasError,
|
|
38
|
-
|
|
55
|
+
const [hasError, setHasError] = useState(false)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Effect discipline
|
|
59
|
+
|
|
60
|
+
`useEffect` is reserved exclusively for synchronising with systems **outside React**: subscriptions, browser APIs, imperative third-party libraries.
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// Yes — external subscription with cleanup
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
// NOTE(author, date): Syncing to ResizeObserver — no React equivalent
|
|
66
|
+
const observer = new ResizeObserver(onResize)
|
|
67
|
+
observer.observe(ref.current)
|
|
68
|
+
return () => observer.disconnect()
|
|
69
|
+
}, [onResize])
|
|
70
|
+
|
|
71
|
+
// No — use TanStack Query
|
|
72
|
+
useEffect(() => { fetch('/api/tracks').then(setTracks) }, [])
|
|
73
|
+
|
|
74
|
+
// No — use event handler
|
|
75
|
+
useEffect(() => { if (submitted) navigate('/done') }, [submitted])
|
|
76
|
+
|
|
77
|
+
// No — compute during render
|
|
78
|
+
useEffect(() => { setFullName(`${first} ${last}`) }, [first, last])
|
|
39
79
|
```
|
|
40
80
|
|
|
41
|
-
|
|
81
|
+
Never disable `react-hooks/exhaustive-deps`. Every subscribing effect returns a cleanup.
|
|
42
82
|
|
|
43
|
-
|
|
83
|
+
## Data fetching — TanStack Query
|
|
44
84
|
|
|
45
85
|
```ts
|
|
46
86
|
// Yes
|
|
47
|
-
const { data, isPending, isError } = useQuery({
|
|
87
|
+
const { data, isPending, isError } = useQuery({
|
|
88
|
+
queryKey: trackKeys.byId(id),
|
|
89
|
+
queryFn: () => fetchTrack(id),
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// Query key factory — typed, never inline string arrays
|
|
93
|
+
const trackKeys = {
|
|
94
|
+
all: ['tracks'] as const,
|
|
95
|
+
byId: (id: TrackId) => [...trackKeys.all, id] as const,
|
|
96
|
+
}
|
|
97
|
+
```
|
|
48
98
|
|
|
49
|
-
|
|
50
|
-
|
|
99
|
+
## Forms — React Hook Form + Zod
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
const schema = z.object({ title: z.string().min(1), artistId: z.string().uuid() })
|
|
103
|
+
type FormData = z.infer<typeof schema>
|
|
104
|
+
|
|
105
|
+
const form = useForm<FormData>({ resolver: zodResolver(schema) })
|
|
106
|
+
|
|
107
|
+
// Submit returns Result<T, E> — never throw
|
|
108
|
+
const onSubmit = async (data: FormData): Promise<Result<Track, ApiError>> => { ... }
|
|
51
109
|
```
|
|
52
110
|
|
|
53
|
-
##
|
|
111
|
+
## Routing — TanStack Router
|
|
54
112
|
|
|
55
|
-
|
|
113
|
+
```ts
|
|
114
|
+
// Yes — typed navigate
|
|
115
|
+
navigate({ to: '/tracks/$id', params: { id } })
|
|
116
|
+
|
|
117
|
+
// No — hand-built URL string
|
|
118
|
+
navigate(`/tracks/${id}`)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Search params validated by Zod at the route definition.
|
|
122
|
+
|
|
123
|
+
## Global state — Zustand / Jotai
|
|
56
124
|
|
|
57
125
|
```ts
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
126
|
+
// Yes — narrow selection
|
|
127
|
+
const title = useTrackStore((s) => s.track.title)
|
|
128
|
+
|
|
129
|
+
// No — whole-store selection
|
|
130
|
+
const store = useTrackStore((s) => s)
|
|
63
131
|
```
|
|
64
132
|
|
|
65
|
-
|
|
133
|
+
Store actions are pure `(state, payload) => state` — no I/O inside.
|
|
134
|
+
|
|
135
|
+
## Styling — Tailwind + cva
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
// Variants via cva — never if/else string concatenation
|
|
139
|
+
const buttonVariants = cva('rounded-lg px-3 py-2', {
|
|
140
|
+
variants: {
|
|
141
|
+
variant: {
|
|
142
|
+
primary: 'bg-primary text-primary-foreground',
|
|
143
|
+
destructive: 'bg-destructive text-destructive-foreground',
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// Conditional classes via cn
|
|
149
|
+
const cls = cn('rounded-lg p-4', active && 'ring-2', disabled && 'opacity-50')
|
|
150
|
+
|
|
151
|
+
// Design tokens only — no hex codes or magic pixel values
|
|
152
|
+
// Good: className="bg-background text-foreground gap-4"
|
|
153
|
+
// Bad: className="bg-[#0a0a0a] gap-[17px]"
|
|
154
|
+
```
|
|
66
155
|
|
|
67
|
-
|
|
68
|
-
- Prop drilling > 2 levels — lift state or use context/Jotai
|
|
69
|
-
- `any` in prop types or event handlers
|
|
70
|
-
- Mutable refs as state (`useRef` for values that drive rendering)
|
|
71
|
-
- Inline object/array literals in JSX props without `useMemo`
|
|
156
|
+
## Memoization
|
|
72
157
|
|
|
73
|
-
|
|
158
|
+
Add memoization only after a profiler measurement identifies re-renders as the bottleneck. Document the reason inline.
|
|
74
159
|
|
|
75
160
|
```ts
|
|
76
|
-
//
|
|
77
|
-
const
|
|
161
|
+
// Only when passed to a memoized consumer or genuinely expensive
|
|
162
|
+
const sorted = useMemo(() => sortTracks(tracks), [tracks])
|
|
163
|
+
// Reason: passed to memoized VirtualList
|
|
78
164
|
|
|
79
|
-
//
|
|
80
|
-
|
|
165
|
+
// Only when passed to memoized component or in another hook's dep array
|
|
166
|
+
const handleSelect = useCallback((id: TrackId) => onSelect(id), [onSelect])
|
|
81
167
|
```
|
|
82
168
|
|
|
83
|
-
|
|
169
|
+
Never add speculative `useMemo` / `useCallback` / `React.memo`.
|
|
170
|
+
|
|
171
|
+
## Accessibility
|
|
172
|
+
|
|
173
|
+
- Interactive elements use semantic HTML — never `div` with `onClick`
|
|
174
|
+
- Forms: associated `label`; icon-buttons: `aria-label`; images: `alt`
|
|
175
|
+
- Keyboard navigation complete; focus order logical and visible
|
|
176
|
+
|
|
177
|
+
## Testing
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
// Yes — query by accessible role
|
|
181
|
+
screen.getByRole('button', { name: /save/i })
|
|
182
|
+
|
|
183
|
+
// No — data-testid
|
|
184
|
+
screen.getByTestId('save-button')
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Network mocked with MSW — never stub `fetch` directly.
|
|
188
|
+
|
|
189
|
+
## Forbidden in React
|
|
84
190
|
|
|
85
|
-
-
|
|
86
|
-
-
|
|
87
|
-
-
|
|
191
|
+
- `useEffect` for data fetching, derived state, or user-event reactions
|
|
192
|
+
- Prop drilling beyond 2 levels
|
|
193
|
+
- `useState` for server state
|
|
194
|
+
- `useStore((s) => s)` whole-store selection
|
|
195
|
+
- Inline `if/else` string concatenation for Tailwind variants — use `cva`
|
|
196
|
+
- `div` with `onClick` for an action — use `button`
|
|
197
|
+
- `data-testid` queries in tests
|
|
198
|
+
- Per-field `useState` in forms — use React Hook Form
|
|
199
|
+
- Hand-built URL strings for navigation — use typed router API
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
---
|
|
2
|
+
applyTo: "**/*.{test,spec}.{ts,tsx}"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# TSF++ test rules
|
|
6
|
+
|
|
7
|
+
Full standard: `node_modules/@tsfpp/standard/spec/TEST_CODING_STANDARD.md`
|
|
8
|
+
Extends: `tsfpp-base.instructions.md` (all base TSF++ rules apply to test code)
|
|
9
|
+
|
|
10
|
+
## Toolchain
|
|
11
|
+
|
|
12
|
+
| Layer | Runner | Property tests | Network | DB |
|
|
13
|
+
|---|---|---|---|---|
|
|
14
|
+
| Core | Vitest | fast-check | — | — |
|
|
15
|
+
| Use-case | Vitest | fast-check (optional) | — | In-memory stub |
|
|
16
|
+
| API / handler | Vitest | — | MSW | In-memory stub |
|
|
17
|
+
| DAL | Vitest | — | — | Real / containerised |
|
|
18
|
+
| React | Vitest + RTL | — | MSW | — |
|
|
19
|
+
|
|
20
|
+
## Test structure — AAA
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
it('returns None when the input string is empty', () => {
|
|
24
|
+
const raw = '' // Arrange
|
|
25
|
+
|
|
26
|
+
const result = mkTrackId(raw) // Act
|
|
27
|
+
|
|
28
|
+
expect(result).toEqual(none) // Assert
|
|
29
|
+
})
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
One blank line between phases. One logical assertion per test. No branching or loops in test bodies.
|
|
33
|
+
|
|
34
|
+
## Describe block structure
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
describe('mkTrackId', () => {
|
|
38
|
+
describe('when the input is valid', () => {
|
|
39
|
+
it('returns Some containing a branded TrackId', () => { ... })
|
|
40
|
+
})
|
|
41
|
+
describe('when the input is empty', () => {
|
|
42
|
+
it('returns None', () => { ... })
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Max two levels of nesting. Test descriptions are full sentences describing behaviour.
|
|
48
|
+
|
|
49
|
+
## Property-based tests — fast-check
|
|
50
|
+
|
|
51
|
+
Required for every pure function and combinator. Laws in `@law` JSDoc must have a corresponding property test.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import * as fc from 'fast-check'
|
|
55
|
+
|
|
56
|
+
it('satisfies the identity law: map(id) ≡ id', () => {
|
|
57
|
+
fc.assert(
|
|
58
|
+
fc.property(fc.integer(), (n) => {
|
|
59
|
+
expect(pipe(ok(n), map(x => x))).toEqual(ok(n))
|
|
60
|
+
}),
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## React components — RTL
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { render, screen } from '@testing-library/react'
|
|
69
|
+
import userEvent from '@testing-library/user-event'
|
|
70
|
+
|
|
71
|
+
it('calls onSelect with the track id when the row is clicked', async () => {
|
|
72
|
+
const onSelect = vi.fn()
|
|
73
|
+
render(<TrackRow track={makeTrack()} onSelect={onSelect} />)
|
|
74
|
+
|
|
75
|
+
await userEvent.click(screen.getByRole('row', { name: /test track/i }))
|
|
76
|
+
|
|
77
|
+
expect(onSelect).toHaveBeenCalledWith(expect.any(String))
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Query hierarchy (use the first that works):
|
|
82
|
+
1. `getByRole` — always preferred
|
|
83
|
+
2. `getByLabelText`
|
|
84
|
+
3. `getByText`
|
|
85
|
+
4. `getByPlaceholderText`
|
|
86
|
+
|
|
87
|
+
Never `getByTestId`.
|
|
88
|
+
|
|
89
|
+
## Network mocking — MSW
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { http, HttpResponse } from 'msw'
|
|
93
|
+
import { setupServer } from 'msw/node'
|
|
94
|
+
|
|
95
|
+
const server = setupServer(
|
|
96
|
+
http.get('/api/tracks', () => HttpResponse.json(trackFixtures)),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
beforeAll(() => server.listen())
|
|
100
|
+
afterEach(() => server.resetHandlers())
|
|
101
|
+
afterAll(() => server.close())
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Never stub `fetch`, `axios`, or any HTTP client directly.
|
|
105
|
+
|
|
106
|
+
## Port stubs — in-memory implementations
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
// Good — typed in-memory implementation of the port
|
|
110
|
+
const repo = mkInMemoryTrackRepository()
|
|
111
|
+
|
|
112
|
+
// Bad — partial vi.fn() mock
|
|
113
|
+
const repo = { findById: vi.fn().mockResolvedValue(track) }
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`vi.fn()` is permitted only for standalone callbacks (`onClose`, `onSelect`, etc.).
|
|
117
|
+
|
|
118
|
+
## Factories
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// tests/factories/track.factory.ts
|
|
122
|
+
const makeTrack = (overrides: Partial<Track> = {}): Track => ({
|
|
123
|
+
id: mkTrackId('test-track-001'),
|
|
124
|
+
title: 'Default Title',
|
|
125
|
+
artistId: mkArtistId('test-artist-001'),
|
|
126
|
+
...overrides,
|
|
127
|
+
})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- Factories live in `tests/factories/` — never inline raw object literals
|
|
131
|
+
- Fixture IDs are deterministic strings that cannot collide with real data
|
|
132
|
+
- Never copy IDs from production or staging environments
|
|
133
|
+
|
|
134
|
+
## What to cover
|
|
135
|
+
|
|
136
|
+
| Layer | Must cover |
|
|
137
|
+
|---|---|
|
|
138
|
+
| Core | Every export · every smart constructor boundary · every error path · laws via fast-check |
|
|
139
|
+
| Use-case | Success path · each distinct `Err` variant · any enforced invariant |
|
|
140
|
+
| API handler | Each missing required field (422) · success status + headers · each `ApiError` variant · auth failure |
|
|
141
|
+
| DAL | Insert+read round-trip · not-found → `None` · constraint violation → typed `DataError` |
|
|
142
|
+
| React | Renders · user interactions · loading state · error state · keyboard/ARIA |
|
|
143
|
+
|
|
144
|
+
## Never
|
|
145
|
+
|
|
146
|
+
- `data-testid` queries
|
|
147
|
+
- `vi.fn()` to implement a port interface
|
|
148
|
+
- Assert that an internal function was called — assert on the observable outcome
|
|
149
|
+
- `any` in test code
|
|
150
|
+
- `setTimeout` delays — use `waitFor` or `findBy*`
|
|
151
|
+
- `beforeAll` for state that mutates between tests
|
|
152
|
+
- Snapshot tests for component structure or API response shape
|
|
153
|
+
- Direct access to non-exported symbols or internal state
|
|
154
|
+
- `shallow` rendering
|