flow-capture-unit.test.mjs
205 lines 6.8 KB
Raw
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 10 hours ago
1 /**
2 * Tier 1 — UNIT: capture thresholds, validation, confidence, gating envelopes.
3 *
4 * @see lib/flow/flow-capture.mjs
5 * @see docs/FLOW-CAPTURE-FLYWHEEL-CONTRACT-7A-L4.md §4, §10
6 */
7 import { describe, it, beforeEach, afterEach } from 'node:test';
8 import assert from 'node:assert/strict';
9 import fs from 'node:fs';
10 import path from 'node:path';
11 import { fileURLToPath } from 'node:url';
12 import {
13 FLOW_CAPTURE_MIN_REPETITIONS,
14 FLOW_CAPTURE_MIN_CONFIDENCE,
15 FLOW_CAPTURE_PER_SESSION_CAP,
16 FLOW_CAPTURE_DEDUP_OVERLAP,
17 MAX_SESSION_SIGNAL_REFS,
18 MAX_CANDIDATE_SUMMARIES,
19 MAX_DRAFT_STEPS,
20 FLOW_CANDIDATE_SCHEMA,
21 FLOW_CAPTURE_PROPOSAL_SCHEMA,
22 validateSessionMeta,
23 deriveConfidence,
24 validateCandidate,
25 runDetectors,
26 getFlowCaptureDetectionEnabled,
27 getFlowCaptureWritesEnabled,
28 handleFlowCaptureObserveRequest,
29 handleFlowCaptureProposeRequest,
30 handleFlowCaptureDismissRequest,
31 } from '../lib/flow/flow-capture.mjs';
32 import { upsertCandidate } from '../lib/flow/flow-store.mjs';
33 import { createProposal } from '../hub/proposals-store.mjs';
34 import { validSessionMeta, payloadBearingSessionMeta, makeCandidateRecord } from './fixtures/flow/capture-helpers.mjs';
35
36 const __dirname = path.dirname(fileURLToPath(import.meta.url));
37 const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-flow-capture-unit');
38
39 describe('pinned threshold constants', () => {
40 it('match contract §4.3', () => {
41 assert.equal(FLOW_CAPTURE_MIN_REPETITIONS, 3);
42 assert.equal(FLOW_CAPTURE_MIN_CONFIDENCE, 'medium');
43 assert.equal(FLOW_CAPTURE_PER_SESSION_CAP, 2);
44 assert.equal(FLOW_CAPTURE_DEDUP_OVERLAP, 0.8);
45 assert.equal(MAX_SESSION_SIGNAL_REFS, 64);
46 assert.equal(MAX_CANDIDATE_SUMMARIES, 50);
47 assert.equal(MAX_DRAFT_STEPS, 32);
48 });
49 });
50
51 describe('validateSessionMeta — rejects raw content', () => {
52 it('accepts valid structural meta', () => {
53 const r = validateSessionMeta(validSessionMeta());
54 assert.equal(r.ok, true);
55 });
56
57 it('rejects payload-bearing forbidden keys', () => {
58 const r = validateSessionMeta(payloadBearingSessionMeta());
59 assert.equal(r.ok, false);
60 });
61
62 it('rejects unbounded step_sequence_refs', () => {
63 const refs = Array.from({ length: MAX_SESSION_SIGNAL_REFS + 1 }, (_, i) => `flow_x#${i + 1}`);
64 const r = validateSessionMeta(validSessionMeta({ step_sequence_refs: refs }));
65 assert.equal(r.ok, false);
66 });
67 });
68
69 describe('deriveConfidence — bounded enum', () => {
70 it('low at threshold edge with single signal', () => {
71 assert.equal(deriveConfidence('repetition', 2), 'low');
72 });
73
74 it('medium at threshold', () => {
75 assert.equal(deriveConfidence('repetition', FLOW_CAPTURE_MIN_REPETITIONS), 'medium');
76 });
77
78 it('high at 2× threshold or multi-signal', () => {
79 assert.equal(deriveConfidence('repetition', FLOW_CAPTURE_MIN_REPETITIONS * 2), 'high');
80 assert.equal(deriveConfidence('repetition', 3, 2), 'high');
81 });
82 });
83
84 describe('validateCandidate — stamps knowtation.flow_candidate/v0', () => {
85 it('accepts canonical candidate', () => {
86 const r = validateCandidate(makeCandidateRecord());
87 assert.equal(r.ok, true);
88 assert.equal(r.candidate.schema, FLOW_CANDIDATE_SCHEMA);
89 });
90
91 it('rejects malformed candidate_id', () => {
92 const r = validateCandidate(makeCandidateRecord({ candidate_id: 'bad' }));
93 assert.equal(r.ok, false);
94 });
95 });
96
97 describe('runDetectors — server-side only', () => {
98 it('emits repetition when count meets threshold', () => {
99 const hits = runDetectors(validSessionMeta(), { session_extraction_opt_in: false });
100 assert.ok(hits.some((h) => h.signal === 'repetition'));
101 });
102 });
103
104 describe('gating — sub-gates default OFF', () => {
105 const dataDir = path.join(tmpRoot, 'gate');
106
107 beforeEach(() => {
108 fs.rmSync(tmpRoot, { recursive: true, force: true });
109 fs.mkdirSync(dataDir, { recursive: true });
110 delete process.env.FLOW_CAPTURE_DETECTION_ENABLED;
111 delete process.env.FLOW_CAPTURE_WRITES_ENABLED;
112 });
113 afterEach(() => {
114 fs.rmSync(tmpRoot, { recursive: true, force: true });
115 delete process.env.FLOW_CAPTURE_DETECTION_ENABLED;
116 delete process.env.FLOW_CAPTURE_WRITES_ENABLED;
117 });
118
119 it('detection defaults off', () => {
120 assert.equal(getFlowCaptureDetectionEnabled(dataDir), false);
121 assert.equal(getFlowCaptureWritesEnabled(dataDir), false);
122 });
123
124 it('observe off ⇒ detection_authorized false, no candidates', () => {
125 const r = handleFlowCaptureObserveRequest({
126 dataDir,
127 vaultId: 'default',
128 sessionMeta: validSessionMeta(),
129 });
130 assert.equal(r.ok, true);
131 assert.equal(r.payload.detection_authorized, false);
132 assert.equal(r.payload.returned_count, 0);
133 });
134
135 it('propose off ⇒ FLOW_CAPTURE_WRITES_DISABLED', () => {
136 const r = handleFlowCaptureProposeRequest({
137 dataDir,
138 vaultId: 'default',
139 candidateId: 'cand_a1b2c3d4',
140 confirmedScope: 'personal',
141 intent: 'promote',
142 createProposal,
143 });
144 assert.equal(r.ok, false);
145 assert.equal(r.code, 'FLOW_CAPTURE_WRITES_DISABLED');
146 });
147
148 it('dismiss off ⇒ FLOW_CAPTURE_WRITES_DISABLED', () => {
149 const r = handleFlowCaptureDismissRequest({
150 dataDir,
151 vaultId: 'default',
152 candidateId: 'cand_a1b2c3d4',
153 intent: 'dismiss',
154 createProposal,
155 });
156 assert.equal(r.ok, false);
157 assert.equal(r.code, 'FLOW_CAPTURE_WRITES_DISABLED');
158 });
159 });
160
161 describe('proposal envelopes when writes forced on', () => {
162 const dataDir = path.join(tmpRoot, 'writes');
163 const visible = new Set(['personal', 'project', 'org']);
164
165 beforeEach(() => {
166 fs.rmSync(tmpRoot, { recursive: true, force: true });
167 fs.mkdirSync(dataDir, { recursive: true });
168 process.env.FLOW_CAPTURE_WRITES_ENABLED = '1';
169 process.env.FLOW_CAPTURE_DETECTION_ENABLED = '1';
170 upsertCandidate(dataDir, 'default', makeCandidateRecord());
171 });
172 afterEach(() => {
173 fs.rmSync(tmpRoot, { recursive: true, force: true });
174 delete process.env.FLOW_CAPTURE_WRITES_ENABLED;
175 delete process.env.FLOW_CAPTURE_DETECTION_ENABLED;
176 });
177
178 it('propose stamps flow_candidate_promote envelope', () => {
179 const r = handleFlowCaptureProposeRequest({
180 dataDir,
181 vaultId: 'default',
182 visibleScopes: visible,
183 candidateId: 'cand_a1b2c3d4',
184 confirmedScope: 'personal',
185 intent: 'Promote weekly verify',
186 createProposal,
187 });
188 assert.equal(r.ok, true);
189 assert.equal(r.payload.schema, FLOW_CAPTURE_PROPOSAL_SCHEMA);
190 assert.equal(r.payload.proposal_kind, 'flow_candidate_promote');
191 });
192
193 it('dismiss stamps flow_candidate_dismiss envelope', () => {
194 const r = handleFlowCaptureDismissRequest({
195 dataDir,
196 vaultId: 'default',
197 visibleScopes: visible,
198 candidateId: 'cand_a1b2c3d4',
199 intent: 'Not recurring',
200 createProposal,
201 });
202 assert.equal(r.ok, true);
203 assert.equal(r.payload.proposal_kind, 'flow_candidate_dismiss');
204 });
205 });
File History 1 commit
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 10 hours ago