@littlebearapps/platform-admin-sdk 1.5.0 → 2.1.0
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/README.md +2 -2
- package/dist/templates.d.ts +1 -1
- package/dist/templates.js +197 -2
- package/package.json +1 -1
- package/templates/full/dashboard/src/components/patterns/ActivePatterns.tsx +62 -0
- package/templates/full/dashboard/src/components/patterns/PatternTabs.tsx +116 -0
- package/templates/full/dashboard/src/components/patterns/SystemPatterns.tsx +52 -0
- package/templates/full/dashboard/src/components/patterns/index.ts +3 -0
- package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
- package/templates/full/dashboard/src/components/reports/GapDetectionReport.tsx +69 -0
- package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
- package/templates/full/dashboard/src/components/reports/SdkAuditReport.tsx +72 -0
- package/templates/full/dashboard/src/components/reports/index.ts +2 -0
- package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
- package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
- package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
- package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
- package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
- package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
- package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
- package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
- package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
- package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
- package/templates/full/dashboard/src/lib/search/api.ts +258 -0
- package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
- package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
- package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
- package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
- package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
- package/templates/full/dashboard/src/pages/api/notifications/[id]/read.ts +37 -0
- package/templates/full/dashboard/src/pages/api/notifications/read-all.ts +28 -0
- package/templates/full/dashboard/src/pages/api/patterns/cache-refresh.ts +38 -0
- package/templates/full/dashboard/src/pages/api/patterns/discover.ts +36 -0
- package/templates/full/dashboard/src/pages/api/patterns/ready-for-review.ts +39 -0
- package/templates/full/dashboard/src/pages/api/patterns/stats.ts +39 -0
- package/templates/full/dashboard/src/pages/api/patterns/suggestions.ts +43 -0
- package/templates/full/dashboard/src/pages/api/reports/audit.ts +45 -0
- package/templates/full/dashboard/src/pages/api/reports/usage.ts +52 -0
- package/templates/full/dashboard/src/pages/api/search/reindex.ts +28 -0
- package/templates/full/dashboard/src/pages/api/search/stats.ts +27 -0
- package/templates/full/dashboard/src/pages/api/settings/index.ts +37 -0
- package/templates/full/dashboard/src/pages/api/settings/update.ts +41 -0
- package/templates/full/dashboard/src/pages/api/topology/index.ts +56 -0
- package/templates/full/scripts/ops/universal-backfill.ts +147 -0
- package/templates/shared/.github/workflows/contract-check.yml.hbs +42 -0
- package/templates/shared/.github/workflows/dashboard-deploy.yml.hbs +39 -0
- package/templates/shared/.github/workflows/security.yml +33 -0
- package/templates/shared/dashboard/src/components/Nav.astro.hbs +2 -0
- package/templates/shared/dashboard/src/components/infrastructure/AlertHistory.tsx +57 -0
- package/templates/shared/dashboard/src/components/infrastructure/InfrastructureStats.tsx +73 -0
- package/templates/shared/dashboard/src/components/infrastructure/ServiceRegistry.tsx +55 -0
- package/templates/shared/dashboard/src/components/infrastructure/UptimeStatus.tsx +56 -0
- package/templates/shared/dashboard/src/components/infrastructure/index.ts +4 -0
- package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
- package/templates/shared/dashboard/src/components/ui/Breadcrumbs.tsx +27 -0
- package/templates/shared/dashboard/src/components/ui/EmptyState.tsx +26 -0
- package/templates/shared/dashboard/src/components/ui/ErrorBoundary.tsx +42 -0
- package/templates/shared/dashboard/src/components/ui/LoadingSkeleton.tsx +18 -0
- package/templates/shared/dashboard/src/components/ui/PageShell.tsx +26 -0
- package/templates/shared/dashboard/src/components/ui/Toast.tsx +44 -0
- package/templates/shared/dashboard/src/components/ui/index.ts +6 -0
- package/templates/shared/dashboard/src/components/usage/AnomaliesWidget.tsx +68 -0
- package/templates/shared/dashboard/src/components/usage/HourlyUsageChart.tsx +55 -0
- package/templates/shared/dashboard/src/components/usage/PlanAllowanceDashboard.tsx +67 -0
- package/templates/shared/dashboard/src/components/usage/ProjectCostBreakdown.tsx +55 -0
- package/templates/shared/dashboard/src/components/usage/index.ts +4 -0
- package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
- package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
- package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
- package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
- package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
- package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
- package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
- package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
- package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
- package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
- package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
- package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
- package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
- package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
- package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
- package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
- package/templates/shared/dashboard/src/lib/cloudflare/costs.ts +21 -0
- package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
- package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
- package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
- package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
- package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
- package/templates/shared/dashboard/src/pages/api/costs/overview.ts +65 -0
- package/templates/shared/dashboard/src/pages/api/costs/providers.ts +47 -0
- package/templates/shared/dashboard/src/pages/api/infrastructure/services.ts +55 -0
- package/templates/shared/dashboard/src/pages/api/infrastructure/stats.ts +99 -0
- package/templates/shared/dashboard/src/pages/api/usage/allowances.ts +56 -0
- package/templates/shared/dashboard/src/pages/api/usage/anomalies.ts +45 -0
- package/templates/shared/dashboard/src/pages/api/usage/billing.ts +53 -0
- package/templates/shared/dashboard/src/pages/api/usage/granular.ts +50 -0
- package/templates/shared/dashboard/src/pages/api/usage/hourly.ts +45 -0
- package/templates/shared/dashboard/src/pages/api/usage/projects.ts +51 -0
- package/templates/shared/dashboard/src/pages/api/user/identity.ts +11 -0
- package/templates/shared/dashboard/src/pages/settings/notifications.astro +34 -0
- package/templates/shared/dashboard/src/pages/settings/thresholds.astro +39 -0
- package/templates/shared/dashboard/src/pages/settings/usage.astro +28 -0
- package/templates/shared/docs/architecture.md +89 -0
- package/templates/shared/docs/post-deploy-runbook.md +126 -0
- package/templates/shared/docs/troubleshooting.md +91 -0
- package/templates/shared/package.json.hbs +5 -0
- package/templates/shared/scripts/ops/backfill-cloudflare-daily.ts +145 -0
- package/templates/shared/scripts/ops/backfill-monthly-rollups.ts +125 -0
- package/templates/shared/scripts/ops/validate-controls.js +141 -0
- package/templates/shared/tests/contract/validate-schemas.test.ts +130 -0
- package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
- package/templates/shared/tests/fixtures/telemetry-envelope-invalid.json +9 -0
- package/templates/shared/tests/fixtures/telemetry-envelope-valid.json +27 -0
- package/templates/shared/tests/helpers/mock-d1.ts +61 -0
- package/templates/shared/tests/helpers/mock-kv.ts +37 -0
- package/templates/shared/tests/helpers/mock-storage.ts +166 -0
- package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
- package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
- package/templates/shared/tests/unit/billing.test.ts +331 -0
- package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
- package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
- package/templates/shared/tests/unit/control.test.ts +226 -0
- package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
- package/templates/shared/tests/unit/economics.test.ts +365 -0
- package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
- package/templates/shared/tests/unit/workers/batch-persistence.test.ts +133 -0
- package/templates/shared/tests/unit/workers/budget-enforcement.test.ts +214 -0
- package/templates/shared/vitest.config.ts +18 -0
- package/templates/standard/dashboard/src/components/health/CircuitBreakerEvents.tsx +69 -0
- package/templates/standard/dashboard/src/components/health/CircuitBreakerPanel.tsx +97 -0
- package/templates/standard/dashboard/src/components/health/index.ts +2 -0
- package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
- package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
- package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
- package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
- package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
- package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
- package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
- package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
- package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
- package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
- package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
- package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
- package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
- package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
- package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
- package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/mute.ts +49 -0
- package/templates/standard/dashboard/src/pages/api/errors/[fingerprint]/resolve.ts +36 -0
- package/templates/standard/dashboard/src/pages/api/errors/[fingerprint].ts +55 -0
- package/templates/standard/dashboard/src/pages/api/health/audit-history.ts +37 -0
- package/templates/standard/dashboard/src/pages/circuit-breakers.astro +13 -0
- package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
- package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
- package/templates/standard/tests/unit/error-collector/capture.test.ts +106 -0
- package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
- package/templates/standard/tests/unit/error-collector/fingerprint.test.ts +155 -0
- package/templates/standard/tests/unit/error-collector/github.test.ts +187 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit Tests for Usage Transformers
|
|
3
|
+
*
|
|
4
|
+
* Tests the data transformation logic that powers the UnifiedResourceTable.
|
|
5
|
+
* These functions are the core logic for ResourceRow rendering and filtering.
|
|
6
|
+
*
|
|
7
|
+
* @module tests/unit/components/usage-transformers
|
|
8
|
+
* @created 2026-01-05
|
|
9
|
+
* @task task-17.27 - Component tests for ResourceRow/ExpandedDetails
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from 'vitest';
|
|
13
|
+
import {
|
|
14
|
+
transformToUnifiedResources,
|
|
15
|
+
applyComparisonData,
|
|
16
|
+
filterResources,
|
|
17
|
+
sortResources,
|
|
18
|
+
} from '../../../dashboard/src/components/usage/transformers';
|
|
19
|
+
import type { UnifiedResource } from '../../../dashboard/src/components/usage/types';
|
|
20
|
+
|
|
21
|
+
describe('Usage Transformers', () => {
|
|
22
|
+
describe('transformToUnifiedResources', () => {
|
|
23
|
+
const baseCosts = {
|
|
24
|
+
workers: 5.0,
|
|
25
|
+
d1: 2.0,
|
|
26
|
+
kv: 1.0,
|
|
27
|
+
r2: 3.0,
|
|
28
|
+
vectorize: 0.5,
|
|
29
|
+
pages: 0.0,
|
|
30
|
+
queues: 0.0,
|
|
31
|
+
workflows: 0.0,
|
|
32
|
+
durableObjects: 0.0,
|
|
33
|
+
aiGateway: 1.5,
|
|
34
|
+
total: 13.0,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const projectMapping = (name: string) => {
|
|
38
|
+
if (name.includes('my-project')) return 'my-project';
|
|
39
|
+
if (name.includes('platform')) return 'platform';
|
|
40
|
+
return 'other';
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
it('transforms workers data correctly', () => {
|
|
44
|
+
const data = {
|
|
45
|
+
workers: [
|
|
46
|
+
{
|
|
47
|
+
scriptName: 'my-project-api',
|
|
48
|
+
requests: 100000,
|
|
49
|
+
cpuTime: 5000,
|
|
50
|
+
duration: 1000,
|
|
51
|
+
errors: 50,
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const resources = transformToUnifiedResources(data, baseCosts, projectMapping);
|
|
57
|
+
|
|
58
|
+
expect(resources).toHaveLength(1);
|
|
59
|
+
expect(resources[0].type).toBe('worker');
|
|
60
|
+
expect(resources[0].name).toBe('my-project-api');
|
|
61
|
+
expect(resources[0].project).toBe('my-project');
|
|
62
|
+
expect(resources[0].usage.value).toBe(100000);
|
|
63
|
+
expect(resources[0].usage.unit).toBe('requests');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('transforms D1 databases correctly', () => {
|
|
67
|
+
const data = {
|
|
68
|
+
d1: [
|
|
69
|
+
{
|
|
70
|
+
databaseId: 'db-123',
|
|
71
|
+
databaseName: 'my-project-db',
|
|
72
|
+
rowsRead: 50000,
|
|
73
|
+
rowsWritten: 1000,
|
|
74
|
+
queryCount: 5000,
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const resources = transformToUnifiedResources(data, baseCosts, projectMapping);
|
|
80
|
+
|
|
81
|
+
expect(resources).toHaveLength(1);
|
|
82
|
+
expect(resources[0].type).toBe('d1');
|
|
83
|
+
expect(resources[0].name).toBe('my-project-db');
|
|
84
|
+
expect(resources[0].usage.value).toBe(50000);
|
|
85
|
+
expect(resources[0].usage.unit).toBe('rows read');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('transforms KV namespaces with total operations', () => {
|
|
89
|
+
const data = {
|
|
90
|
+
kv: [
|
|
91
|
+
{
|
|
92
|
+
namespaceId: 'kv-123',
|
|
93
|
+
namespaceName: 'platform-cache',
|
|
94
|
+
reads: 10000,
|
|
95
|
+
writes: 500,
|
|
96
|
+
deletes: 50,
|
|
97
|
+
lists: 100,
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const resources = transformToUnifiedResources(data, baseCosts, projectMapping);
|
|
103
|
+
|
|
104
|
+
expect(resources).toHaveLength(1);
|
|
105
|
+
expect(resources[0].type).toBe('kv');
|
|
106
|
+
expect(resources[0].usage.value).toBe(10650); // 10000 + 500 + 50 + 100
|
|
107
|
+
expect(resources[0].usage.unit).toBe('operations');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('transforms R2 buckets with storage formatting (decimal GB)', () => {
|
|
111
|
+
const data = {
|
|
112
|
+
r2: [
|
|
113
|
+
{
|
|
114
|
+
bucketName: 'my-project-ai-logs',
|
|
115
|
+
storageBytes: 1000000000, // 1 GB (decimal, Cloudflare billing unit)
|
|
116
|
+
objectCount: 1000,
|
|
117
|
+
classAOperations: 500,
|
|
118
|
+
classBOperations: 2000,
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const resources = transformToUnifiedResources(data, baseCosts, projectMapping);
|
|
124
|
+
|
|
125
|
+
expect(resources).toHaveLength(1);
|
|
126
|
+
expect(resources[0].type).toBe('r2');
|
|
127
|
+
expect(resources[0].usage.value).toBe(1000000000);
|
|
128
|
+
// Uses decimal GB (1 GB = 1,000,000,000 bytes) to match Cloudflare billing
|
|
129
|
+
expect(resources[0].usage.formatted).toBe('1.00 GB');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('transforms AI Gateway as single entry', () => {
|
|
133
|
+
const data = {
|
|
134
|
+
aiGateway: {
|
|
135
|
+
totalRequests: 5000,
|
|
136
|
+
totalTokens: 1000000,
|
|
137
|
+
cachedRequests: 1000,
|
|
138
|
+
modelBreakdown: [{ model: 'gpt-4', requests: 5000, tokens: 1000000 }],
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const resources = transformToUnifiedResources(data, baseCosts, projectMapping);
|
|
143
|
+
|
|
144
|
+
expect(resources).toHaveLength(1);
|
|
145
|
+
expect(resources[0].type).toBe('ai-gateway');
|
|
146
|
+
expect(resources[0].name).toBe('AI Gateway');
|
|
147
|
+
expect(resources[0].usage.value).toBe(1000000);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('calculates costs based on actual usage (requests + CPU time)', () => {
|
|
151
|
+
const data = {
|
|
152
|
+
workers: [
|
|
153
|
+
{ scriptName: 'worker-1', requests: 1000, cpuTime: 100, duration: 10, errors: 0 },
|
|
154
|
+
{ scriptName: 'worker-2', requests: 2000, cpuTime: 200, duration: 20, errors: 0 },
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const resources = transformToUnifiedResources(data, baseCosts, projectMapping);
|
|
159
|
+
|
|
160
|
+
expect(resources).toHaveLength(2);
|
|
161
|
+
// Costs are calculated based on actual usage: $0.30/million requests + $0.02/million CPU ms
|
|
162
|
+
// worker-1: (1000/1M) * 0.30 + (100/1M) * 0.02 = 0.0003 + 0.000002 = 0.000302
|
|
163
|
+
// worker-2: (2000/1M) * 0.30 + (200/1M) * 0.02 = 0.0006 + 0.000004 = 0.000604
|
|
164
|
+
expect(resources[0].costCurrent).toBeCloseTo(0.000302, 6);
|
|
165
|
+
expect(resources[1].costCurrent).toBeCloseTo(0.000604, 6);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('handles empty data gracefully', () => {
|
|
169
|
+
const resources = transformToUnifiedResources({}, baseCosts, projectMapping);
|
|
170
|
+
expect(resources).toHaveLength(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('sets worker status based on error rate', () => {
|
|
174
|
+
const data = {
|
|
175
|
+
workers: [
|
|
176
|
+
{ scriptName: 'healthy', requests: 1000, cpuTime: 100, duration: 10, errors: 5 }, // 0.5% = healthy
|
|
177
|
+
{ scriptName: 'warning', requests: 1000, cpuTime: 100, duration: 10, errors: 15 }, // 1.5% = warning
|
|
178
|
+
{ scriptName: 'high', requests: 1000, cpuTime: 100, duration: 10, errors: 60 }, // 6% = high
|
|
179
|
+
{ scriptName: 'critical', requests: 1000, cpuTime: 100, duration: 10, errors: 150 }, // 15% = critical
|
|
180
|
+
],
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const resources = transformToUnifiedResources(data, baseCosts, projectMapping);
|
|
184
|
+
|
|
185
|
+
expect(resources.find((r) => r.name === 'healthy')?.status).toBe('healthy');
|
|
186
|
+
expect(resources.find((r) => r.name === 'warning')?.status).toBe('warning');
|
|
187
|
+
expect(resources.find((r) => r.name === 'high')?.status).toBe('high');
|
|
188
|
+
expect(resources.find((r) => r.name === 'critical')?.status).toBe('critical');
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('applyComparisonData', () => {
|
|
193
|
+
const currentResources: UnifiedResource[] = [
|
|
194
|
+
{
|
|
195
|
+
id: 'worker-api',
|
|
196
|
+
name: 'api',
|
|
197
|
+
type: 'worker',
|
|
198
|
+
project: 'platform',
|
|
199
|
+
usage: { value: 1000, unit: 'requests', formatted: '1K' },
|
|
200
|
+
costCurrent: 5.0,
|
|
201
|
+
costPrior: 0,
|
|
202
|
+
costDelta: 0,
|
|
203
|
+
costDeltaPct: null,
|
|
204
|
+
status: 'healthy',
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
id: 'd1-main',
|
|
208
|
+
name: 'main-db',
|
|
209
|
+
type: 'd1',
|
|
210
|
+
project: 'my-project',
|
|
211
|
+
usage: { value: 5000, unit: 'rows read', formatted: '5K' },
|
|
212
|
+
costCurrent: 2.0,
|
|
213
|
+
costPrior: 0,
|
|
214
|
+
costDelta: 0,
|
|
215
|
+
costDeltaPct: null,
|
|
216
|
+
status: 'healthy',
|
|
217
|
+
},
|
|
218
|
+
];
|
|
219
|
+
|
|
220
|
+
it('calculates cost delta percentage correctly', () => {
|
|
221
|
+
const priorResources: UnifiedResource[] = [
|
|
222
|
+
{
|
|
223
|
+
...currentResources[0],
|
|
224
|
+
costCurrent: 4.0, // Was $4, now $5 = +25%
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
...currentResources[1],
|
|
228
|
+
costCurrent: 2.5, // Was $2.5, now $2 = -20%
|
|
229
|
+
},
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
const result = applyComparisonData(currentResources, priorResources);
|
|
233
|
+
|
|
234
|
+
expect(result[0].costDelta).toBeCloseTo(1.0);
|
|
235
|
+
expect(result[0].costDeltaPct).toBeCloseTo(25);
|
|
236
|
+
|
|
237
|
+
expect(result[1].costDelta).toBeCloseTo(-0.5);
|
|
238
|
+
expect(result[1].costDeltaPct).toBeCloseTo(-20);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('marks new resources as NEW', () => {
|
|
242
|
+
const priorResources: UnifiedResource[] = [currentResources[0]];
|
|
243
|
+
|
|
244
|
+
const result = applyComparisonData(currentResources, priorResources);
|
|
245
|
+
|
|
246
|
+
expect(result[0].costDeltaPct).toBeCloseTo(0); // Matching resource
|
|
247
|
+
expect(result[1].costDeltaPct).toBe('NEW'); // New resource
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('handles zero prior cost correctly', () => {
|
|
251
|
+
const priorResources: UnifiedResource[] = [
|
|
252
|
+
{
|
|
253
|
+
...currentResources[0],
|
|
254
|
+
costCurrent: 0, // Was $0, now $5
|
|
255
|
+
},
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
const result = applyComparisonData([currentResources[0]], priorResources);
|
|
259
|
+
|
|
260
|
+
// When prior cost is below $0.01 threshold and current cost > 0, returns 'NEW'
|
|
261
|
+
// This avoids extreme/undefined percentages from near-zero baselines
|
|
262
|
+
expect(result[0].costDeltaPct).toBe('NEW');
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('filterResources', () => {
|
|
267
|
+
const testResources: UnifiedResource[] = [
|
|
268
|
+
{
|
|
269
|
+
id: 'worker-api',
|
|
270
|
+
name: 'my-project-api',
|
|
271
|
+
type: 'worker',
|
|
272
|
+
project: 'my-project',
|
|
273
|
+
usage: { value: 1000, unit: 'requests', formatted: '1K' },
|
|
274
|
+
costCurrent: 5.0,
|
|
275
|
+
costPrior: 4.0,
|
|
276
|
+
costDelta: 1.0,
|
|
277
|
+
costDeltaPct: 25,
|
|
278
|
+
status: 'healthy',
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
id: 'd1-main',
|
|
282
|
+
name: 'platform-db',
|
|
283
|
+
type: 'd1',
|
|
284
|
+
project: 'platform',
|
|
285
|
+
usage: { value: 5000, unit: 'rows read', formatted: '5K' },
|
|
286
|
+
costCurrent: 0,
|
|
287
|
+
costPrior: 0,
|
|
288
|
+
costDelta: 0,
|
|
289
|
+
costDeltaPct: 0,
|
|
290
|
+
status: 'healthy',
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
id: 'kv-cache',
|
|
294
|
+
name: 'my-project-cache',
|
|
295
|
+
type: 'kv',
|
|
296
|
+
project: 'my-project',
|
|
297
|
+
usage: { value: 500, unit: 'operations', formatted: '500' },
|
|
298
|
+
costCurrent: 1.0,
|
|
299
|
+
costPrior: 0,
|
|
300
|
+
costDelta: 1.0,
|
|
301
|
+
costDeltaPct: 'NEW' as const,
|
|
302
|
+
status: 'healthy',
|
|
303
|
+
},
|
|
304
|
+
];
|
|
305
|
+
|
|
306
|
+
it('filters by project', () => {
|
|
307
|
+
const result = filterResources(testResources, { project: 'my-project' });
|
|
308
|
+
|
|
309
|
+
expect(result).toHaveLength(2);
|
|
310
|
+
expect(result.every((r) => r.project === 'my-project')).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('filters by service type', () => {
|
|
314
|
+
const result = filterResources(testResources, { serviceTypes: ['worker', 'd1'] });
|
|
315
|
+
|
|
316
|
+
expect(result).toHaveLength(2);
|
|
317
|
+
expect(result.some((r) => r.type === 'worker')).toBe(true);
|
|
318
|
+
expect(result.some((r) => r.type === 'd1')).toBe(true);
|
|
319
|
+
expect(result.some((r) => r.type === 'kv')).toBe(false);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('filters by search query (case insensitive)', () => {
|
|
323
|
+
const result = filterResources(testResources, { searchQuery: 'COPILOT' });
|
|
324
|
+
|
|
325
|
+
expect(result).toHaveLength(2);
|
|
326
|
+
expect(result.every((r) => r.name.toLowerCase().includes('copilot'))).toBe(true);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('filters by onlyChanged (>5% change or NEW)', () => {
|
|
330
|
+
const result = filterResources(testResources, { onlyChanged: true });
|
|
331
|
+
|
|
332
|
+
expect(result).toHaveLength(2);
|
|
333
|
+
// First has 25% change, third is NEW
|
|
334
|
+
expect(result.some((r) => r.costDeltaPct === 25)).toBe(true);
|
|
335
|
+
expect(result.some((r) => r.costDeltaPct === 'NEW')).toBe(true);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('filters by nonZeroCost', () => {
|
|
339
|
+
const result = filterResources(testResources, { nonZeroCost: true });
|
|
340
|
+
|
|
341
|
+
expect(result).toHaveLength(2);
|
|
342
|
+
expect(result.every((r) => r.costCurrent > 0)).toBe(true);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('combines multiple filters', () => {
|
|
346
|
+
const result = filterResources(testResources, {
|
|
347
|
+
project: 'my-project',
|
|
348
|
+
nonZeroCost: true,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
expect(result).toHaveLength(2);
|
|
352
|
+
expect(result.every((r) => r.project === 'my-project' && r.costCurrent > 0)).toBe(true);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('returns all resources when no filters applied', () => {
|
|
356
|
+
const result = filterResources(testResources, {});
|
|
357
|
+
expect(result).toHaveLength(3);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe('sortResources', () => {
|
|
362
|
+
const testResources: UnifiedResource[] = [
|
|
363
|
+
{
|
|
364
|
+
id: '1',
|
|
365
|
+
name: 'bravo',
|
|
366
|
+
type: 'worker',
|
|
367
|
+
project: 'alpha',
|
|
368
|
+
usage: { value: 500, unit: 'requests', formatted: '500' },
|
|
369
|
+
costCurrent: 3.0,
|
|
370
|
+
costPrior: 2.0,
|
|
371
|
+
costDelta: 1.0,
|
|
372
|
+
costDeltaPct: 50,
|
|
373
|
+
status: 'warning',
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
id: '2',
|
|
377
|
+
name: 'alpha',
|
|
378
|
+
type: 'd1',
|
|
379
|
+
project: 'bravo',
|
|
380
|
+
usage: { value: 1000, unit: 'rows', formatted: '1K' },
|
|
381
|
+
costCurrent: 1.0,
|
|
382
|
+
costPrior: 1.0,
|
|
383
|
+
costDelta: 0,
|
|
384
|
+
costDeltaPct: 0,
|
|
385
|
+
status: 'healthy',
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
id: '3',
|
|
389
|
+
name: 'charlie',
|
|
390
|
+
type: 'kv',
|
|
391
|
+
project: 'charlie',
|
|
392
|
+
usage: { value: 200, unit: 'ops', formatted: '200' },
|
|
393
|
+
costCurrent: 5.0,
|
|
394
|
+
costPrior: 0,
|
|
395
|
+
costDelta: 5.0,
|
|
396
|
+
costDeltaPct: 'NEW' as const,
|
|
397
|
+
status: 'critical',
|
|
398
|
+
},
|
|
399
|
+
];
|
|
400
|
+
|
|
401
|
+
it('sorts by name ascending', () => {
|
|
402
|
+
const result = sortResources(testResources, 'name', 'asc');
|
|
403
|
+
|
|
404
|
+
expect(result[0].name).toBe('alpha');
|
|
405
|
+
expect(result[1].name).toBe('bravo');
|
|
406
|
+
expect(result[2].name).toBe('charlie');
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('sorts by name descending', () => {
|
|
410
|
+
const result = sortResources(testResources, 'name', 'desc');
|
|
411
|
+
|
|
412
|
+
expect(result[0].name).toBe('charlie');
|
|
413
|
+
expect(result[1].name).toBe('bravo');
|
|
414
|
+
expect(result[2].name).toBe('alpha');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('sorts by cost current', () => {
|
|
418
|
+
const result = sortResources(testResources, 'costCurrent', 'desc');
|
|
419
|
+
|
|
420
|
+
expect(result[0].costCurrent).toBe(5.0);
|
|
421
|
+
expect(result[1].costCurrent).toBe(3.0);
|
|
422
|
+
expect(result[2].costCurrent).toBe(1.0);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('sorts by usage value', () => {
|
|
426
|
+
const result = sortResources(testResources, 'usage', 'desc');
|
|
427
|
+
|
|
428
|
+
expect(result[0].usage.value).toBe(1000);
|
|
429
|
+
expect(result[1].usage.value).toBe(500);
|
|
430
|
+
expect(result[2].usage.value).toBe(200);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('sorts by costDeltaPct with NEW at end (ascending)', () => {
|
|
434
|
+
const result = sortResources(testResources, 'costDeltaPct', 'asc');
|
|
435
|
+
|
|
436
|
+
// 0, 50, NEW (infinity)
|
|
437
|
+
expect(result[0].costDeltaPct).toBe(0);
|
|
438
|
+
expect(result[1].costDeltaPct).toBe(50);
|
|
439
|
+
expect(result[2].costDeltaPct).toBe('NEW');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('sorts by costDeltaPct with NEW at start (descending)', () => {
|
|
443
|
+
const result = sortResources(testResources, 'costDeltaPct', 'desc');
|
|
444
|
+
|
|
445
|
+
// NEW first when descending
|
|
446
|
+
expect(result[0].costDeltaPct).toBe('NEW');
|
|
447
|
+
expect(result[1].costDeltaPct).toBe(50);
|
|
448
|
+
expect(result[2].costDeltaPct).toBe(0);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('sorts by status severity', () => {
|
|
452
|
+
const result = sortResources(testResources, 'status', 'desc');
|
|
453
|
+
|
|
454
|
+
expect(result[0].status).toBe('critical');
|
|
455
|
+
expect(result[1].status).toBe('warning');
|
|
456
|
+
expect(result[2].status).toBe('healthy');
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('sorts by type', () => {
|
|
460
|
+
const result = sortResources(testResources, 'type', 'asc');
|
|
461
|
+
|
|
462
|
+
expect(result[0].type).toBe('d1');
|
|
463
|
+
expect(result[1].type).toBe('kv');
|
|
464
|
+
expect(result[2].type).toBe('worker');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('preserves order for unknown columns', () => {
|
|
468
|
+
const result = sortResources(testResources, 'unknown', 'asc');
|
|
469
|
+
|
|
470
|
+
expect(result).toHaveLength(3);
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit Tests for PID Controller
|
|
3
|
+
*
|
|
4
|
+
* Tests the stateless PID controller for intelligent degradation throttling.
|
|
5
|
+
*
|
|
6
|
+
* @module tests/unit/control
|
|
7
|
+
* @created 2026-01-23
|
|
8
|
+
* @task Intelligent Degradation for Platform Usage
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
import {
|
|
13
|
+
computePID,
|
|
14
|
+
createPIDState,
|
|
15
|
+
calculateUtilisation,
|
|
16
|
+
shouldUpdatePID,
|
|
17
|
+
formatThrottleRate,
|
|
18
|
+
DEFAULT_PID_CONFIG,
|
|
19
|
+
type PIDState,
|
|
20
|
+
type PIDConfig,
|
|
21
|
+
} from '../../workers/lib/control';
|
|
22
|
+
|
|
23
|
+
describe('PID Controller', () => {
|
|
24
|
+
describe('createPIDState', () => {
|
|
25
|
+
it('creates fresh state with zero values', () => {
|
|
26
|
+
const state = createPIDState();
|
|
27
|
+
|
|
28
|
+
expect(state.integral).toBe(0);
|
|
29
|
+
expect(state.prevError).toBe(0);
|
|
30
|
+
expect(state.throttleRate).toBe(0);
|
|
31
|
+
expect(state.lastUpdate).toBeGreaterThan(0);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('computePID', () => {
|
|
36
|
+
it('returns zero throttle when usage is below setpoint', () => {
|
|
37
|
+
const state = createPIDState();
|
|
38
|
+
const input = { currentUsage: 0.5, deltaTimeMs: 60000 }; // 50% usage, setpoint is 70%
|
|
39
|
+
|
|
40
|
+
const output = computePID(state, input);
|
|
41
|
+
|
|
42
|
+
// Error is negative (0.5 - 0.7 = -0.2), so throttle should be 0 (clamped)
|
|
43
|
+
expect(output.throttleRate).toBe(0);
|
|
44
|
+
expect(output.debug.error).toBeCloseTo(-0.2, 10);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('increases throttle when usage exceeds setpoint', () => {
|
|
48
|
+
const state = createPIDState();
|
|
49
|
+
const input = { currentUsage: 0.9, deltaTimeMs: 60000 }; // 90% usage
|
|
50
|
+
|
|
51
|
+
const output = computePID(state, input);
|
|
52
|
+
|
|
53
|
+
// Error is positive (0.9 - 0.7 = 0.2), should have positive throttle
|
|
54
|
+
expect(output.throttleRate).toBeGreaterThan(0);
|
|
55
|
+
expect(output.debug.error).toBeCloseTo(0.2, 10);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('clamps throttle rate to [0, 1] range', () => {
|
|
59
|
+
const state = createPIDState();
|
|
60
|
+
// Extreme over-budget scenario
|
|
61
|
+
const input = { currentUsage: 5.0, deltaTimeMs: 60000 }; // 500% usage
|
|
62
|
+
|
|
63
|
+
const output = computePID(state, input);
|
|
64
|
+
|
|
65
|
+
expect(output.throttleRate).toBeLessThanOrEqual(1);
|
|
66
|
+
expect(output.throttleRate).toBeGreaterThanOrEqual(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('accumulates integral term over time', () => {
|
|
70
|
+
let state = createPIDState();
|
|
71
|
+
|
|
72
|
+
// Simulate sustained over-budget usage
|
|
73
|
+
for (let i = 0; i < 5; i++) {
|
|
74
|
+
const output = computePID(state, { currentUsage: 0.85, deltaTimeMs: 60000 });
|
|
75
|
+
state = output.newState;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Integral should have accumulated
|
|
79
|
+
expect(state.integral).toBeGreaterThan(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('clamps integral to prevent windup', () => {
|
|
83
|
+
let state = createPIDState();
|
|
84
|
+
|
|
85
|
+
// Extreme sustained over-budget
|
|
86
|
+
for (let i = 0; i < 100; i++) {
|
|
87
|
+
const output = computePID(state, { currentUsage: 2.0, deltaTimeMs: 60000 });
|
|
88
|
+
state = output.newState;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Integral should be clamped at integralMax (2.0)
|
|
92
|
+
expect(state.integral).toBeLessThanOrEqual(DEFAULT_PID_CONFIG.integralMax);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('calculates derivative term for rate of change', () => {
|
|
96
|
+
const state: PIDState = {
|
|
97
|
+
integral: 0,
|
|
98
|
+
prevError: 0.1, // Previous error was 0.1
|
|
99
|
+
lastUpdate: Date.now() - 60000,
|
|
100
|
+
throttleRate: 0,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Current error is 0.2 (rate of change = 0.1 over 60s)
|
|
104
|
+
const output = computePID(state, { currentUsage: 0.9, deltaTimeMs: 60000 });
|
|
105
|
+
|
|
106
|
+
// dTerm = kd * (error - prevError) / dt
|
|
107
|
+
// = 0.05 * (0.2 - 0.1) / 60 = 0.05 * 0.1 / 60 ≈ 0.000083
|
|
108
|
+
expect(output.debug.dTerm).toBeGreaterThan(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('uses custom config when provided', () => {
|
|
112
|
+
const state = createPIDState();
|
|
113
|
+
const customConfig: PIDConfig = {
|
|
114
|
+
kp: 1.0,
|
|
115
|
+
ki: 0,
|
|
116
|
+
kd: 0,
|
|
117
|
+
setpoint: 0.5,
|
|
118
|
+
outputMin: 0,
|
|
119
|
+
outputMax: 1,
|
|
120
|
+
integralMax: 2.0,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const input = { currentUsage: 0.8, deltaTimeMs: 60000 };
|
|
124
|
+
const output = computePID(state, input, customConfig);
|
|
125
|
+
|
|
126
|
+
// With kp=1, error=0.3, pTerm should be 0.3
|
|
127
|
+
expect(output.debug.pTerm).toBeCloseTo(0.3, 10);
|
|
128
|
+
expect(output.debug.error).toBeCloseTo(0.3, 10);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('preserves state continuity across updates', () => {
|
|
132
|
+
let state = createPIDState();
|
|
133
|
+
|
|
134
|
+
// First update with short deltaTime to avoid integral saturation
|
|
135
|
+
const output1 = computePID(state, { currentUsage: 0.8, deltaTimeMs: 1000 });
|
|
136
|
+
state = output1.newState;
|
|
137
|
+
|
|
138
|
+
// Second update should use previous error
|
|
139
|
+
const output2 = computePID(state, { currentUsage: 0.85, deltaTimeMs: 1000 });
|
|
140
|
+
|
|
141
|
+
expect(output2.newState.prevError).toBeCloseTo(0.15, 10); // 0.85 - 0.70
|
|
142
|
+
// Integral should accumulate (both updates have positive error)
|
|
143
|
+
expect(output2.newState.integral).toBeGreaterThan(output1.newState.integral);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('calculateUtilisation', () => {
|
|
148
|
+
it('calculates correct utilisation ratio', () => {
|
|
149
|
+
expect(calculateUtilisation(70, 100)).toBe(0.7);
|
|
150
|
+
expect(calculateUtilisation(100, 100)).toBe(1.0);
|
|
151
|
+
expect(calculateUtilisation(150, 100)).toBe(1.5);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('returns 0 when limit is 0 or negative', () => {
|
|
155
|
+
expect(calculateUtilisation(50, 0)).toBe(0);
|
|
156
|
+
expect(calculateUtilisation(50, -10)).toBe(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('handles zero usage', () => {
|
|
160
|
+
expect(calculateUtilisation(0, 100)).toBe(0);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('shouldUpdatePID', () => {
|
|
165
|
+
it('returns true when interval has passed', () => {
|
|
166
|
+
const lastUpdate = Date.now() - 65000; // 65 seconds ago
|
|
167
|
+
expect(shouldUpdatePID(lastUpdate, 60000)).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('returns false when interval has not passed', () => {
|
|
171
|
+
const lastUpdate = Date.now() - 30000; // 30 seconds ago
|
|
172
|
+
expect(shouldUpdatePID(lastUpdate, 60000)).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('returns true exactly at interval boundary', () => {
|
|
176
|
+
const lastUpdate = Date.now() - 60000; // Exactly 60 seconds ago
|
|
177
|
+
expect(shouldUpdatePID(lastUpdate, 60000)).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('formatThrottleRate', () => {
|
|
182
|
+
it('formats rate as percentage with one decimal', () => {
|
|
183
|
+
expect(formatThrottleRate(0)).toBe('0.0%');
|
|
184
|
+
expect(formatThrottleRate(0.5)).toBe('50.0%');
|
|
185
|
+
expect(formatThrottleRate(1)).toBe('100.0%');
|
|
186
|
+
expect(formatThrottleRate(0.123)).toBe('12.3%');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('PID Convergence', () => {
|
|
191
|
+
it('converges to stable throttle under constant load', () => {
|
|
192
|
+
let state = createPIDState();
|
|
193
|
+
const throttleRates: number[] = [];
|
|
194
|
+
|
|
195
|
+
// Simulate 10 minutes of constant 85% usage
|
|
196
|
+
for (let i = 0; i < 10; i++) {
|
|
197
|
+
const output = computePID(state, { currentUsage: 0.85, deltaTimeMs: 60000 });
|
|
198
|
+
state = output.newState;
|
|
199
|
+
throttleRates.push(output.throttleRate);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// After convergence, throttle rate should stabilise
|
|
203
|
+
const last3 = throttleRates.slice(-3);
|
|
204
|
+
const variance = Math.max(...last3) - Math.min(...last3);
|
|
205
|
+
expect(variance).toBeLessThan(0.05); // Less than 5% variance
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('responds quickly to sudden usage spike', () => {
|
|
209
|
+
let state = createPIDState();
|
|
210
|
+
|
|
211
|
+
// Normal usage for a while
|
|
212
|
+
for (let i = 0; i < 5; i++) {
|
|
213
|
+
const output = computePID(state, { currentUsage: 0.5, deltaTimeMs: 60000 });
|
|
214
|
+
state = output.newState;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const throttleBefore = state.throttleRate;
|
|
218
|
+
|
|
219
|
+
// Sudden spike to 150% usage
|
|
220
|
+
const output = computePID(state, { currentUsage: 1.5, deltaTimeMs: 60000 });
|
|
221
|
+
|
|
222
|
+
// Throttle should increase significantly
|
|
223
|
+
expect(output.throttleRate).toBeGreaterThan(throttleBefore + 0.2);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|