@holoscript/plugin-government-civic 2.0.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.
@@ -0,0 +1,302 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import {
3
+ createPermitHandler,
4
+ createPublicMeetingHandler,
5
+ createServiceRequestHandler,
6
+ createVotingRecordHandler,
7
+ createCivicComplianceHandler,
8
+ pluginMeta,
9
+ traitHandlers,
10
+ } from '../index';
11
+ import type { PermitConfig, PermitState } from '../traits/PermitTrait';
12
+ import type { PublicMeetingConfig } from '../traits/PublicMeetingTrait';
13
+ import type { ServiceRequestConfig } from '../traits/ServiceRequestTrait';
14
+ import type { VotingRecordConfig } from '../traits/VotingRecordTrait';
15
+ import type { CivicComplianceConfig } from '../traits/CivicComplianceTrait';
16
+ import type { HSPlusNode, TraitContext } from '../traits/types';
17
+
18
+ // ── Helpers ──────────────────────────────────────────────────────────────────
19
+
20
+ function makeNode(): HSPlusNode { return { id: 'n1', traits: [], state: {}, position: [0, 0, 0] }; }
21
+ function makeCtx(): TraitContext { return { emit: vi.fn(), scene: {} }; }
22
+
23
+ // ── Plugin metadata ───────────────────────────────────────────────────────────
24
+
25
+ describe('pluginMeta', () => {
26
+ it('has correct name', () => {
27
+ expect(pluginMeta.name).toBe('@holoscript/plugin-government-civic');
28
+ });
29
+ it('exports six traits (incl. runtime-wired civic_decision)', () => {
30
+ expect(pluginMeta.traits).toHaveLength(6);
31
+ expect(pluginMeta.traits).toContain('permit');
32
+ expect(pluginMeta.traits).toContain('public_meeting');
33
+ expect(pluginMeta.traits).toContain('service_request');
34
+ expect(pluginMeta.traits).toContain('voting_record');
35
+ expect(pluginMeta.traits).toContain('civic_compliance');
36
+ expect(pluginMeta.traits).toContain('civic_decision');
37
+ });
38
+ it('traitHandlers has five entries', () => {
39
+ expect(traitHandlers).toHaveLength(5);
40
+ });
41
+ it('each handler has required interface', () => {
42
+ for (const h of traitHandlers) {
43
+ expect(typeof h.name).toBe('string');
44
+ expect(h.defaultConfig).toBeDefined();
45
+ expect(typeof h.onAttach).toBe('function');
46
+ expect(typeof h.onDetach).toBe('function');
47
+ expect(typeof h.onEvent).toBe('function');
48
+ }
49
+ });
50
+ });
51
+
52
+ // ── PermitTrait ───────────────────────────────────────────────────────────────
53
+
54
+ describe('PermitTrait', () => {
55
+ const cfg: PermitConfig = {
56
+ permitType: 'building',
57
+ applicationNumber: 'P-001',
58
+ applicantName: 'ACME Corp',
59
+ projectAddress: '100 Main St',
60
+ submittedAt: new Date(Date.now() - 86_400_000 * 5).toISOString(), // 5 days ago
61
+ reviewDeadlineDays: 30,
62
+ showStatusBadge: true,
63
+ };
64
+
65
+ it('initialises state on attach', () => {
66
+ const handler = createPermitHandler();
67
+ const node = makeNode();
68
+ const ctx = makeCtx();
69
+ handler.onAttach(node, cfg, ctx);
70
+ expect(node.__permitState).toBeDefined();
71
+ expect((node.__permitState as { daysInReview: number }).daysInReview).toBeGreaterThanOrEqual(4);
72
+ expect(ctx.emit).toHaveBeenCalledWith('permit:attached', expect.any(Object));
73
+ });
74
+
75
+ it('changes status via permit:update_status event', () => {
76
+ const handler = createPermitHandler();
77
+ const node = makeNode();
78
+ const ctx = makeCtx();
79
+ handler.onAttach(node, cfg, ctx);
80
+ handler.onEvent(node, cfg, ctx, { type: 'permit:update_status', payload: { status: 'approved' } });
81
+ expect((node.__permitState as { status: string }).status).toBe('approved');
82
+ expect(ctx.emit).toHaveBeenCalledWith('permit:status_changed', expect.objectContaining({ to: 'approved' }));
83
+ });
84
+
85
+ it('cleans up on detach', () => {
86
+ const handler = createPermitHandler();
87
+ const node = makeNode();
88
+ const ctx = makeCtx();
89
+ handler.onAttach(node, cfg, ctx);
90
+ handler.onDetach(node, cfg, ctx);
91
+ expect(node.__permitState).toBeUndefined();
92
+ });
93
+ });
94
+
95
+ // ── PublicMeetingTrait ────────────────────────────────────────────────────────
96
+
97
+ describe('PublicMeetingTrait', () => {
98
+ const cfg: PublicMeetingConfig = {
99
+ meetingType: 'city_council',
100
+ meetingId: 'CC-001',
101
+ title: 'April Session',
102
+ scheduledAt: new Date().toISOString(),
103
+ location: 'City Hall',
104
+ quorumRequired: 4,
105
+ membersPresent: 5,
106
+ publicCommentMinutesPerSpeaker: 3,
107
+ agenda: [
108
+ { id: '1', title: 'Call to Order', type: 'action', durationMinutes: 5, requiresVote: false },
109
+ { id: '2', title: 'Public Comment', type: 'public_comment', durationMinutes: 30, requiresVote: false },
110
+ ],
111
+ accessibilityFeatures: ['captioning'],
112
+ };
113
+
114
+ it('initialises with quorum met when members >= required', () => {
115
+ const handler = createPublicMeetingHandler();
116
+ const node = makeNode();
117
+ const ctx = makeCtx();
118
+ handler.onAttach(node, cfg, ctx);
119
+ expect((node.__meetingState as { quorumMet: boolean }).quorumMet).toBe(true);
120
+ });
121
+
122
+ it('call_to_order changes status and starts recording', () => {
123
+ const handler = createPublicMeetingHandler();
124
+ const node = makeNode();
125
+ const ctx = makeCtx();
126
+ handler.onAttach(node, cfg, ctx);
127
+ handler.onEvent(node, cfg, ctx, { type: 'meeting:call_to_order' });
128
+ const s = node.__meetingState as { status: string; recordingActive: boolean };
129
+ expect(s.status).toBe('in_session');
130
+ expect(s.recordingActive).toBe(true);
131
+ expect(ctx.emit).toHaveBeenCalledWith('meeting:called_to_order', expect.any(Object));
132
+ });
133
+
134
+ it('adjourn changes status and emits event', () => {
135
+ const handler = createPublicMeetingHandler();
136
+ const node = makeNode();
137
+ const ctx = makeCtx();
138
+ handler.onAttach(node, cfg, ctx);
139
+ handler.onEvent(node, cfg, ctx, { type: 'meeting:call_to_order' });
140
+ handler.onEvent(node, cfg, ctx, { type: 'meeting:adjourn' });
141
+ expect((node.__meetingState as { status: string }).status).toBe('adjourned');
142
+ expect(ctx.emit).toHaveBeenCalledWith('meeting:adjourned', expect.any(Object));
143
+ });
144
+ });
145
+
146
+ // ── ServiceRequestTrait ───────────────────────────────────────────────────────
147
+
148
+ describe('ServiceRequestTrait', () => {
149
+ const cfg: ServiceRequestConfig = {
150
+ requestId: 'SR-001',
151
+ category: 'pothole',
152
+ description: 'Large pothole on Main St',
153
+ location: 'Main St & 3rd',
154
+ submittedAt: new Date(Date.now() - 86_400_000).toISOString(), // 1 day ago
155
+ priority: 'high',
156
+ targetResolutionDays: 3,
157
+ department: 'Public Works',
158
+ isAnonymous: false,
159
+ };
160
+
161
+ it('initialises state on attach', () => {
162
+ const handler = createServiceRequestHandler();
163
+ const node = makeNode();
164
+ const ctx = makeCtx();
165
+ handler.onAttach(node, cfg, ctx);
166
+ expect(node.__srState).toBeDefined();
167
+ expect(ctx.emit).toHaveBeenCalledWith('sr:created', expect.any(Object));
168
+ });
169
+
170
+ it('sr:assign sets assignedTo and status', () => {
171
+ const handler = createServiceRequestHandler();
172
+ const node = makeNode();
173
+ const ctx = makeCtx();
174
+ handler.onAttach(node, cfg, ctx);
175
+ handler.onEvent(node, cfg, ctx, { type: 'sr:assign', payload: { assignedTo: 'crew-7' } });
176
+ const s = node.__srState as { status: string; assignedTo: string };
177
+ expect(s.status).toBe('assigned');
178
+ expect(s.assignedTo).toBe('crew-7');
179
+ });
180
+
181
+ it('sr:resolve sets status to resolved', () => {
182
+ const handler = createServiceRequestHandler();
183
+ const node = makeNode();
184
+ const ctx = makeCtx();
185
+ handler.onAttach(node, cfg, ctx);
186
+ handler.onEvent(node, cfg, ctx, { type: 'sr:resolve' });
187
+ expect((node.__srState as { status: string }).status).toBe('resolved');
188
+ expect(ctx.emit).toHaveBeenCalledWith('sr:resolved', expect.any(Object));
189
+ });
190
+ });
191
+
192
+ // ── VotingRecordTrait ─────────────────────────────────────────────────────────
193
+
194
+ describe('VotingRecordTrait', () => {
195
+ const cfg: VotingRecordConfig = {
196
+ voteId: 'V-001',
197
+ voteType: 'council_vote',
198
+ title: 'Budget Amendment',
199
+ description: 'FY2026 infrastructure spend',
200
+ motionText: 'Move to adopt Res 2026-19',
201
+ scheduledAt: new Date().toISOString(),
202
+ requiredMajority: 'simple',
203
+ eligibleVoters: ['CM-A', 'CM-B', 'CM-C', 'CM-D', 'Mayor'],
204
+ showLiveResults: true,
205
+ showMemberVotes: true,
206
+ };
207
+
208
+ it('initialises with pending outcome and no votes', () => {
209
+ const handler = createVotingRecordHandler();
210
+ const node = makeNode();
211
+ const ctx = makeCtx();
212
+ handler.onAttach(node, cfg, ctx);
213
+ const s = node.__votingState as { outcome: string; ayeCount: number };
214
+ expect(s.outcome).toBe('pending');
215
+ expect(s.ayeCount).toBe(0);
216
+ });
217
+
218
+ it('vote:cast increments aye count', () => {
219
+ const handler = createVotingRecordHandler();
220
+ const node = makeNode();
221
+ const ctx = makeCtx();
222
+ handler.onAttach(node, cfg, ctx);
223
+ handler.onEvent(node, cfg, ctx, { type: 'vote:open' });
224
+ handler.onEvent(node, cfg, ctx, { type: 'vote:cast', payload: { memberId: 'CM-A', memberName: 'Councilmember A', vote: 'aye' } });
225
+ expect((node.__votingState as { ayeCount: number }).ayeCount).toBe(1);
226
+ });
227
+
228
+ it('vote:close with majority ayes resolves to passed', () => {
229
+ const handler = createVotingRecordHandler();
230
+ const node = makeNode();
231
+ const ctx = makeCtx();
232
+ handler.onAttach(node, cfg, ctx);
233
+ handler.onEvent(node, cfg, ctx, { type: 'vote:open' });
234
+ for (const id of ['CM-A', 'CM-B', 'CM-C']) {
235
+ handler.onEvent(node, cfg, ctx, { type: 'vote:cast', payload: { memberId: id, memberName: id, vote: 'aye' } });
236
+ }
237
+ for (const id of ['CM-D', 'Mayor']) {
238
+ handler.onEvent(node, cfg, ctx, { type: 'vote:cast', payload: { memberId: id, memberName: id, vote: 'nay' } });
239
+ }
240
+ handler.onEvent(node, cfg, ctx, { type: 'vote:close' });
241
+ expect((node.__votingState as { outcome: string }).outcome).toBe('passed');
242
+ expect(ctx.emit).toHaveBeenCalledWith('vote:closed', expect.objectContaining({ outcome: 'passed' }));
243
+ });
244
+ });
245
+
246
+ // ── CivicComplianceTrait ──────────────────────────────────────────────────────
247
+
248
+ describe('CivicComplianceTrait', () => {
249
+ const cfg: CivicComplianceConfig = {
250
+ entityId: 'portal-1',
251
+ entityType: 'website',
252
+ frameworks: ['ADA', 'WCAG', 'FOIA'],
253
+ foiaEnabled: true,
254
+ foiaResponseDaysLimit: 20,
255
+ accessibilityStandard: 'WCAG_2_1_AA',
256
+ auditLogRetentionDays: 2555,
257
+ isPublicRecord: true,
258
+ };
259
+
260
+ it('initialises checks for each framework', () => {
261
+ const handler = createCivicComplianceHandler();
262
+ const node = makeNode();
263
+ const ctx = makeCtx();
264
+ handler.onAttach(node, cfg, ctx);
265
+ const s = node.__complianceState as { checks: unknown[] };
266
+ expect(s.checks).toHaveLength(3);
267
+ });
268
+
269
+ it('compliance:update_check changes check status', () => {
270
+ const handler = createCivicComplianceHandler();
271
+ const node = makeNode();
272
+ const ctx = makeCtx();
273
+ handler.onAttach(node, cfg, ctx);
274
+ const s = node.__complianceState as { checks: Array<{ id: string; status: string }> };
275
+ const checkId = s.checks[0]!.id;
276
+ handler.onEvent(node, cfg, ctx, { type: 'compliance:update_check', payload: { checkId, status: 'compliant' } });
277
+ expect(s.checks[0]!.status).toBe('compliant');
278
+ expect(ctx.emit).toHaveBeenCalledWith('compliance:check_updated', expect.objectContaining({ status: 'compliant' }));
279
+ });
280
+
281
+ it('compliance:foia_received creates a pending FOIA request', () => {
282
+ const handler = createCivicComplianceHandler();
283
+ const node = makeNode();
284
+ const ctx = makeCtx();
285
+ handler.onAttach(node, cfg, ctx);
286
+ handler.onEvent(node, cfg, ctx, { type: 'compliance:foia_received', payload: { requestId: 'FOIA-001', description: 'Budget docs' } });
287
+ const s = node.__complianceState as { foiaRequests: unknown[]; openFoiaCount: number };
288
+ expect(s.foiaRequests).toHaveLength(1);
289
+ expect(s.openFoiaCount).toBe(1);
290
+ expect(ctx.emit).toHaveBeenCalledWith('compliance:foia_received', expect.objectContaining({ requestId: 'FOIA-001' }));
291
+ });
292
+
293
+ it('audit log entry is stored', () => {
294
+ const handler = createCivicComplianceHandler();
295
+ const node = makeNode();
296
+ const ctx = makeCtx();
297
+ handler.onAttach(node, cfg, ctx);
298
+ handler.onEvent(node, cfg, ctx, { type: 'compliance:audit_log', payload: { action: 'page_view', actor: 'user-99', details: 'Viewed homepage' } });
299
+ const s = node.__complianceState as { auditLog: unknown[] };
300
+ expect(s.auditLog).toHaveLength(1);
301
+ });
302
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Integration proof: the government-civic `civic_decision` trait, once
3
+ * registered via the runtime's real `registerTrait` seam, is dispatched BY THE
4
+ * RUNTIME and runs the deterministic TOPSIS MCDA solver — NOT called directly
5
+ * as a handler object.
6
+ *
7
+ * Mirrors energy-grid-plugin's runtime-integration reference (power_flow).
8
+ * Drives the real path: executeNode(orb) -> orb-executor -> applyDirectives ->
9
+ * traitHandlers.get('civic_decision').onAttach -> mcdaAnalysis.
10
+ * The negative control proves the registration is load-bearing (without it,
11
+ * the trait is a dead no-op — which is exactly the tier's status quo).
12
+ */
13
+ import { describe, it, expect } from 'vitest';
14
+ import { HoloScriptRuntime } from '@holoscript/core/runtime';
15
+ import { registerGovernmentCivicTraitHandlers } from '../runtime';
16
+ import type { MCDACandidate, MCDACriterion } from '../civicsolver';
17
+
18
+ // One benefit criterion (weight 1.0). Two candidates: A scores 100, B scores 0.
19
+ // normalize([100,0]) => A=1.0, B=0.0; weighted by 1.0 unchanged.
20
+ // ideal=1.0, antiIdeal=0.0 => TOPSIS: A = 1/(0+1) = 1.0, B = 0/(1+0) = 0.0.
21
+ // Ranked desc by TOPSIS => winner = 'A', A.rank=1, B.rank=2.
22
+ const TWO_CANDIDATE_CRITERIA: MCDACriterion[] = [
23
+ { name: 'public_benefit', weight: 1.0, isBenefit: true },
24
+ ];
25
+ const TWO_CANDIDATES: MCDACandidate[] = [
26
+ { id: 'A', scores: [100] },
27
+ { id: 'B', scores: [0] },
28
+ ];
29
+
30
+ function civicDecisionOrb(config: Record<string, unknown>): unknown {
31
+ return {
32
+ type: 'orb',
33
+ name: 'civic',
34
+ properties: {},
35
+ methods: [],
36
+ position: [0, 0, 0],
37
+ hologram: { shape: 'orb', color: '#fff', size: 1, glow: false, interactive: false },
38
+ directives: [{ type: 'trait', name: 'civic_decision', config }],
39
+ };
40
+ }
41
+
42
+ /** Flush the runtime's async emit dispatch so `on` listeners have fired. */
43
+ const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
44
+
45
+ describe('government-civic -> HoloScript runtime integration (civic_decision)', () => {
46
+ it('runtime dispatch runs the TOPSIS MCDA solver for a registered @civic_decision orb', async () => {
47
+ const runtime = new HoloScriptRuntime();
48
+ registerGovernmentCivicTraitHandlers(runtime);
49
+
50
+ const solved: Array<Record<string, unknown>> = [];
51
+ runtime.on('civic_decision_solved', (e: unknown) => {
52
+ solved.push(e as Record<string, unknown>);
53
+ });
54
+
55
+ await runtime.executeNode(
56
+ civicDecisionOrb({ candidates: TWO_CANDIDATES, criteria: TWO_CANDIDATE_CRITERIA }) as never,
57
+ );
58
+ await flush();
59
+
60
+ expect(solved).toHaveLength(1);
61
+ const summary = solved[0];
62
+ // Hand-checked: candidate A (score 100) dominates B (score 0) on the single
63
+ // benefit criterion, so TOPSIS makes A the winner at rank 1, B at rank 2.
64
+ expect(summary.winner).toBe('A');
65
+ expect(summary.candidateCount).toBe(2);
66
+ expect(summary.criterionCount).toBe(1);
67
+ const ranking = summary.ranking as Array<{ id: string; rank: number; topsisScore: number }>;
68
+ expect(ranking[0].id).toBe('A');
69
+ expect(ranking[0].rank).toBe(1);
70
+ expect(ranking[0].topsisScore).toBeCloseTo(1.0, 6);
71
+ expect(ranking[1].id).toBe('B');
72
+ expect(ranking[1].rank).toBe(2);
73
+ expect(ranking[1].topsisScore).toBeCloseTo(0.0, 6);
74
+ });
75
+
76
+ it('NEGATIVE CONTROL: without registration the @civic_decision trait is a dead no-op', async () => {
77
+ const runtime = new HoloScriptRuntime(); // intentionally NOT registered
78
+ const solved: unknown[] = [];
79
+ runtime.on('civic_decision_solved', (e: unknown) => solved.push(e));
80
+
81
+ await runtime.executeNode(
82
+ civicDecisionOrb({ candidates: TWO_CANDIDATES, criteria: TWO_CANDIDATE_CRITERIA }) as never,
83
+ );
84
+ await flush();
85
+
86
+ expect(solved).toHaveLength(0);
87
+ });
88
+
89
+ it('persists the solver result into durable runtime state on ATTACH', async () => {
90
+ const runtime = new HoloScriptRuntime();
91
+ registerGovernmentCivicTraitHandlers(runtime);
92
+
93
+ await runtime.executeNode(
94
+ civicDecisionOrb({ candidates: TWO_CANDIDATES, criteria: TWO_CANDIDATE_CRITERIA }) as never,
95
+ );
96
+ await flush();
97
+
98
+ const state = runtime.getState() as Record<string, unknown>;
99
+ const persisted = state['civic_decision:civic'] as
100
+ | { winner?: string; candidateCount?: number }
101
+ | undefined;
102
+ expect(persisted).toBeDefined();
103
+ expect(persisted?.winner).toBe('A');
104
+ expect(persisted?.candidateCount).toBe(2);
105
+ });
106
+
107
+ it('emits civic_decision_error (does not throw through the runtime) for invalid config', async () => {
108
+ const runtime = new HoloScriptRuntime();
109
+ registerGovernmentCivicTraitHandlers(runtime);
110
+
111
+ const errors: Array<Record<string, unknown>> = [];
112
+ runtime.on('civic_decision_error', (e: unknown) => {
113
+ errors.push(e as Record<string, unknown>);
114
+ });
115
+
116
+ // candidate scores length (2) does not match criteria length (1) — the real
117
+ // solver throws "scores.length must match criteria.length", which the
118
+ // handler's try/catch turns into a civic_decision_error rather than a throw.
119
+ const mismatched: MCDACandidate[] = [{ id: 'A', scores: [1, 2] }];
120
+ await runtime.executeNode(
121
+ civicDecisionOrb({ candidates: mismatched, criteria: TWO_CANDIDATE_CRITERIA }) as never,
122
+ );
123
+ await flush();
124
+
125
+ expect(errors).toHaveLength(1);
126
+ expect(String(errors[0].error)).toContain('match');
127
+ });
128
+ });