|
1 | | -import { describe, expect, it, vi } from "vitest"; |
| 1 | +import { describe, expect, it } from "vitest"; |
2 | 2 | import { |
3 | 3 | evaluateGate, |
4 | 4 | type GateDependencies, |
5 | 5 | type GateInputs, |
6 | 6 | type TripDecision, |
7 | 7 | } from "~/v3/mollifier/mollifierGate.server"; |
| 8 | +import type { DecisionOutcome, DecisionReason } from "~/v3/mollifier/mollifierTelemetry.server"; |
8 | 9 |
|
| 10 | +// We deliberately don't use vi.fn here. Per repo policy tests shouldn't lean on |
| 11 | +// mock frameworks for behaviours that are pure functions of the inputs — the |
| 12 | +// gate is pure decision logic, so a hand-rolled "deps + spy log" wired with |
| 13 | +// plain closures gives exactly the assertions we need without the indirection. |
9 | 14 | type Spies = { |
10 | | - [K in keyof GateDependencies]: ReturnType<typeof vi.fn>; |
| 15 | + evaluatorCalls: number; |
| 16 | + logShadowCalls: Array<{ inputs: GateInputs; decision: Extract<TripDecision, { divert: true }> }>; |
| 17 | + logMollifiedCalls: Array<{ inputs: GateInputs; decision: Extract<TripDecision, { divert: true }> }>; |
| 18 | + recordDecisionCalls: Array<{ outcome: DecisionOutcome; reason?: DecisionReason }>; |
11 | 19 | }; |
12 | 20 |
|
13 | | -function makeDeps(overrides: Partial<GateDependencies> = {}): { |
14 | | - deps: GateDependencies; |
15 | | - spies: Spies; |
16 | | -} { |
17 | | - const defaults: GateDependencies = { |
18 | | - isMollifierEnabled: () => false, |
19 | | - isShadowModeOn: () => false, |
20 | | - resolveOrgFlag: async () => false, |
21 | | - evaluator: async () => ({ divert: false }) as TripDecision, |
22 | | - logShadow: () => {}, |
23 | | - logMollified: () => {}, |
24 | | - recordDecision: () => {}, |
| 21 | +type Toggles = { |
| 22 | + enabled: boolean; |
| 23 | + shadow: boolean; |
| 24 | + flag: boolean; |
| 25 | + decision: TripDecision; |
| 26 | +}; |
| 27 | + |
| 28 | +function makeDeps(toggles: Toggles): { deps: GateDependencies; spies: Spies } { |
| 29 | + const spies: Spies = { |
| 30 | + evaluatorCalls: 0, |
| 31 | + logShadowCalls: [], |
| 32 | + logMollifiedCalls: [], |
| 33 | + recordDecisionCalls: [], |
| 34 | + }; |
| 35 | + const deps: GateDependencies = { |
| 36 | + isMollifierEnabled: () => toggles.enabled, |
| 37 | + isShadowModeOn: () => toggles.shadow, |
| 38 | + resolveFlag: async () => toggles.flag, |
| 39 | + evaluator: async () => { |
| 40 | + spies.evaluatorCalls += 1; |
| 41 | + return toggles.decision; |
| 42 | + }, |
| 43 | + logShadow: (inputs, decision) => { |
| 44 | + spies.logShadowCalls.push({ inputs, decision }); |
| 45 | + }, |
| 46 | + logMollified: (inputs, decision) => { |
| 47 | + spies.logMollifiedCalls.push({ inputs, decision }); |
| 48 | + }, |
| 49 | + recordDecision: (outcome, reason) => { |
| 50 | + spies.recordDecisionCalls.push({ outcome, reason }); |
| 51 | + }, |
25 | 52 | }; |
26 | | - const merged = { ...defaults, ...overrides }; |
27 | | - const spies = { |
28 | | - isMollifierEnabled: vi.fn(merged.isMollifierEnabled), |
29 | | - isShadowModeOn: vi.fn(merged.isShadowModeOn), |
30 | | - resolveOrgFlag: vi.fn(merged.resolveOrgFlag), |
31 | | - evaluator: vi.fn(merged.evaluator), |
32 | | - logShadow: vi.fn(merged.logShadow), |
33 | | - logMollified: vi.fn(merged.logMollified), |
34 | | - recordDecision: vi.fn(merged.recordDecision), |
35 | | - } satisfies Spies; |
36 | | - return { deps: spies, spies }; |
| 53 | + return { deps, spies }; |
37 | 54 | } |
38 | 55 |
|
39 | 56 | const trippedDecision = { |
@@ -101,53 +118,49 @@ describe("evaluateGate cascade — exhaustive truth table", () => { |
101 | 118 | "row $id: enabled=$enabled shadow=$shadow flag=$flag divert=$divert → action=$expected.action", |
102 | 119 | async (row) => { |
103 | 120 | const { deps, spies } = makeDeps({ |
104 | | - isMollifierEnabled: () => row.enabled, |
105 | | - isShadowModeOn: () => row.shadow, |
106 | | - resolveOrgFlag: async () => row.flag, |
107 | | - evaluator: async () => (row.divert ? trippedDecision : passDecision), |
| 121 | + enabled: row.enabled, |
| 122 | + shadow: row.shadow, |
| 123 | + flag: row.flag, |
| 124 | + decision: row.divert ? trippedDecision : passDecision, |
108 | 125 | }); |
109 | 126 |
|
110 | 127 | const outcome = await evaluateGate(inputs, deps); |
111 | 128 |
|
112 | 129 | expect(outcome.action).toBe(row.expected.action); |
113 | | - expect(spies.evaluator).toHaveBeenCalledTimes(row.expected.evaluatorCalls); |
114 | | - expect(spies.logShadow).toHaveBeenCalledTimes(row.expected.logShadowCalls); |
115 | | - expect(spies.logMollified).toHaveBeenCalledTimes(row.expected.logMollifiedCalls); |
| 130 | + expect(spies.evaluatorCalls).toBe(row.expected.evaluatorCalls); |
| 131 | + expect(spies.logShadowCalls).toHaveLength(row.expected.logShadowCalls); |
| 132 | + expect(spies.logMollifiedCalls).toHaveLength(row.expected.logMollifiedCalls); |
116 | 133 |
|
117 | 134 | // Every evaluation records exactly one decision. |
118 | | - expect(spies.recordDecision).toHaveBeenCalledTimes(1); |
119 | | - if (row.expected.expectedReason === undefined) { |
120 | | - expect(spies.recordDecision).toHaveBeenCalledWith(row.expected.recordedOutcome); |
121 | | - } else { |
122 | | - expect(spies.recordDecision).toHaveBeenCalledWith( |
123 | | - row.expected.recordedOutcome, |
124 | | - row.expected.expectedReason, |
125 | | - ); |
126 | | - } |
| 135 | + expect(spies.recordDecisionCalls).toHaveLength(1); |
| 136 | + expect(spies.recordDecisionCalls[0].outcome).toBe(row.expected.recordedOutcome); |
| 137 | + expect(spies.recordDecisionCalls[0].reason).toBe(row.expected.expectedReason); |
127 | 138 | }, |
128 | 139 | ); |
129 | 140 |
|
130 | 141 | it("divert log carries the full decision (envId, orgId, taskId, reason, count, threshold, windowMs, holdMs)", async () => { |
131 | 142 | const { deps, spies } = makeDeps({ |
132 | | - isMollifierEnabled: () => true, |
133 | | - isShadowModeOn: () => true, |
134 | | - evaluator: async () => trippedDecision, |
| 143 | + enabled: true, |
| 144 | + shadow: true, |
| 145 | + flag: false, |
| 146 | + decision: trippedDecision, |
135 | 147 | }); |
136 | 148 |
|
137 | 149 | await evaluateGate(inputs, deps); |
138 | 150 |
|
139 | | - expect(spies.logShadow).toHaveBeenCalledWith(inputs, trippedDecision); |
| 151 | + expect(spies.logShadowCalls).toEqual([{ inputs, decision: trippedDecision }]); |
140 | 152 | }); |
141 | 153 |
|
142 | 154 | it("mollify log carries the full decision (mirrors shadow log)", async () => { |
143 | 155 | const { deps, spies } = makeDeps({ |
144 | | - isMollifierEnabled: () => true, |
145 | | - resolveOrgFlag: async () => true, |
146 | | - evaluator: async () => trippedDecision, |
| 156 | + enabled: true, |
| 157 | + shadow: false, |
| 158 | + flag: true, |
| 159 | + decision: trippedDecision, |
147 | 160 | }); |
148 | 161 |
|
149 | 162 | await evaluateGate(inputs, deps); |
150 | 163 |
|
151 | | - expect(spies.logMollified).toHaveBeenCalledWith(inputs, trippedDecision); |
| 164 | + expect(spies.logMollifiedCalls).toEqual([{ inputs, decision: trippedDecision }]); |
152 | 165 | }); |
153 | 166 | }); |
0 commit comments