cyclecad 3.0.0 → 3.2.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.
Files changed (67) hide show
  1. package/BILLING-IMPLEMENTATION-SUMMARY.md +425 -0
  2. package/BILLING-INDEX.md +293 -0
  3. package/BILLING-INTEGRATION-GUIDE.md +414 -0
  4. package/COLLABORATION-INDEX.md +440 -0
  5. package/COLLABORATION-SYSTEM-SUMMARY.md +548 -0
  6. package/DOCKER-BUILD-MANIFEST.txt +483 -0
  7. package/DOCKER-FILES-REFERENCE.md +440 -0
  8. package/DOCKER-INFRASTRUCTURE.md +475 -0
  9. package/DOCKER-README.md +435 -0
  10. package/Dockerfile +33 -55
  11. package/PWA-FILES-CREATED.txt +350 -0
  12. package/QUICK-START-TESTING.md +126 -0
  13. package/STEP-IMPORT-QUICKSTART.md +347 -0
  14. package/STEP-IMPORT-SYSTEM-SUMMARY.md +502 -0
  15. package/app/css/mobile.css +1074 -0
  16. package/app/icons/generate-icons.js +203 -0
  17. package/app/index.html +93 -0
  18. package/app/js/billing-ui.js +990 -0
  19. package/app/js/brep-kernel.js +933 -981
  20. package/app/js/collab-client.js +750 -0
  21. package/app/js/mobile-nav.js +623 -0
  22. package/app/js/mobile-toolbar.js +476 -0
  23. package/app/js/modules/billing-module.js +724 -0
  24. package/app/js/modules/step-module-enhanced.js +938 -0
  25. package/app/js/offline-manager.js +705 -0
  26. package/app/js/responsive-init.js +360 -0
  27. package/app/js/touch-handler.js +429 -0
  28. package/app/manifest.json +211 -0
  29. package/app/offline.html +508 -0
  30. package/app/sw.js +571 -0
  31. package/app/tests/billing-tests.html +779 -0
  32. package/app/tests/brep-tests.html +980 -0
  33. package/app/tests/collab-tests.html +743 -0
  34. package/app/tests/mobile-tests.html +1299 -0
  35. package/app/tests/pwa-tests.html +1134 -0
  36. package/app/tests/step-tests.html +1042 -0
  37. package/app/tests/test-agent-v3.html +719 -0
  38. package/docker-compose.yml +225 -0
  39. package/docs/BILLING-HELP.json +260 -0
  40. package/docs/BILLING-README.md +639 -0
  41. package/docs/BILLING-TUTORIAL.md +736 -0
  42. package/docs/BREP-HELP.json +326 -0
  43. package/docs/BREP-TUTORIAL.md +802 -0
  44. package/docs/COLLABORATION-HELP.json +228 -0
  45. package/docs/COLLABORATION-TUTORIAL.md +818 -0
  46. package/docs/DOCKER-HELP.json +224 -0
  47. package/docs/DOCKER-TUTORIAL.md +974 -0
  48. package/docs/MOBILE-HELP.json +243 -0
  49. package/docs/MOBILE-RESPONSIVE-README.md +378 -0
  50. package/docs/MOBILE-TUTORIAL.md +747 -0
  51. package/docs/PWA-HELP.json +228 -0
  52. package/docs/PWA-README.md +662 -0
  53. package/docs/PWA-TUTORIAL.md +757 -0
  54. package/docs/STEP-HELP.json +481 -0
  55. package/docs/STEP-IMPORT-TUTORIAL.md +824 -0
  56. package/docs/TESTING-GUIDE.md +528 -0
  57. package/docs/TESTING-HELP.json +182 -0
  58. package/fusion-vs-cyclecad.html +1771 -0
  59. package/nginx.conf +237 -0
  60. package/package.json +1 -1
  61. package/server/Dockerfile.converter +51 -0
  62. package/server/Dockerfile.signaling +28 -0
  63. package/server/billing-server.js +487 -0
  64. package/server/converter-enhanced.py +528 -0
  65. package/server/requirements-converter.txt +29 -0
  66. package/server/signaling-server.js +801 -0
  67. package/tests/docker-tests.sh +389 -0
@@ -0,0 +1,779 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>cycleCAD Billing Module Tests</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
12
+ min-height: 100vh;
13
+ padding: 20px;
14
+ }
15
+ .container {
16
+ max-width: 1200px;
17
+ margin: 0 auto;
18
+ display: grid;
19
+ grid-template-columns: 1fr 1fr;
20
+ gap: 20px;
21
+ }
22
+ .panel {
23
+ background: white;
24
+ border-radius: 12px;
25
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3);
26
+ padding: 24px;
27
+ display: flex;
28
+ flex-direction: column;
29
+ gap: 16px;
30
+ }
31
+ h1 { font-size: 24px; color: white; margin-bottom: 20px; grid-column: 1/-1; }
32
+ h2 { font-size: 18px; color: #2D3748; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #E2E8F0; }
33
+ h3 { font-size: 14px; color: #4A5568; margin-top: 12px; margin-bottom: 8px; font-weight: 600; }
34
+
35
+ .test-category {
36
+ margin-bottom: 12px;
37
+ }
38
+ .test-item {
39
+ display: flex;
40
+ align-items: center;
41
+ gap: 8px;
42
+ padding: 8px;
43
+ border-radius: 6px;
44
+ font-size: 13px;
45
+ background: #F7FAFC;
46
+ border-left: 4px solid #CBD5E0;
47
+ transition: all 0.2s;
48
+ }
49
+ .test-item.passed {
50
+ border-left-color: #48BB78;
51
+ background: #F0FFF4;
52
+ color: #22543D;
53
+ }
54
+ .test-item.failed {
55
+ border-left-color: #F56565;
56
+ background: #FFF5F5;
57
+ color: #742A2A;
58
+ }
59
+ .test-item.pending {
60
+ border-left-color: #ED8936;
61
+ background: #FFFAF0;
62
+ color: #7C2D12;
63
+ }
64
+ .test-icon { font-size: 16px; min-width: 20px; }
65
+ .test-name { flex: 1; }
66
+ .test-details { font-size: 11px; color: #718096; }
67
+
68
+ .button-group {
69
+ display: flex;
70
+ gap: 8px;
71
+ margin-bottom: 12px;
72
+ }
73
+ button {
74
+ padding: 8px 12px;
75
+ border: none;
76
+ border-radius: 6px;
77
+ font-size: 13px;
78
+ font-weight: 500;
79
+ cursor: pointer;
80
+ transition: all 0.2s;
81
+ }
82
+ .btn-primary {
83
+ background: #667eea;
84
+ color: white;
85
+ }
86
+ .btn-primary:hover { background: #5568d3; transform: translateY(-1px); }
87
+ .btn-secondary {
88
+ background: #E2E8F0;
89
+ color: #2D3748;
90
+ }
91
+ .btn-secondary:hover { background: #CBD5E0; }
92
+ .btn-success { background: #48BB78; color: white; }
93
+ .btn-danger { background: #F56565; color: white; }
94
+ .btn-small { padding: 4px 8px; font-size: 12px; }
95
+
96
+ .stats {
97
+ display: grid;
98
+ grid-template-columns: 1fr 1fr 1fr;
99
+ gap: 8px;
100
+ padding: 12px;
101
+ background: #EDF2F7;
102
+ border-radius: 8px;
103
+ }
104
+ .stat {
105
+ text-align: center;
106
+ }
107
+ .stat-value { font-size: 24px; font-weight: 700; color: #2D3748; }
108
+ .stat-label { font-size: 11px; color: #718096; text-transform: uppercase; margin-top: 4px; }
109
+
110
+ .progress-bar {
111
+ width: 100%;
112
+ height: 24px;
113
+ background: #E2E8F0;
114
+ border-radius: 12px;
115
+ overflow: hidden;
116
+ display: flex;
117
+ align-items: center;
118
+ font-size: 11px;
119
+ font-weight: 600;
120
+ color: white;
121
+ text-shadow: 0 1px 2px rgba(0,0,0,0.2);
122
+ }
123
+ .progress-fill { height: 100%; display: flex; align-items: center; justify-content: center; transition: width 0.3s; }
124
+
125
+ .billing-info {
126
+ background: #EDF2F7;
127
+ padding: 12px;
128
+ border-radius: 8px;
129
+ font-size: 13px;
130
+ }
131
+ .billing-info strong { color: #2D3748; display: block; margin-bottom: 4px; }
132
+
133
+ .console {
134
+ background: #1A202C;
135
+ color: #68D391;
136
+ font-family: 'Monaco', 'Courier New', monospace;
137
+ font-size: 11px;
138
+ padding: 12px;
139
+ border-radius: 6px;
140
+ max-height: 200px;
141
+ overflow-y: auto;
142
+ line-height: 1.4;
143
+ }
144
+ .console-log { margin: 2px 0; }
145
+ .console-error { color: #FC8181; }
146
+ .console-success { color: #68D391; }
147
+ .console-warn { color: #F6AD55; }
148
+
149
+ .modal {
150
+ display: none;
151
+ position: fixed;
152
+ top: 0;
153
+ left: 0;
154
+ right: 0;
155
+ bottom: 0;
156
+ background: rgba(0,0,0,0.5);
157
+ justify-content: center;
158
+ align-items: center;
159
+ z-index: 1000;
160
+ }
161
+ .modal.active { display: flex; }
162
+ .modal-content {
163
+ background: white;
164
+ padding: 24px;
165
+ border-radius: 12px;
166
+ max-width: 400px;
167
+ box-shadow: 0 25px 50px rgba(0,0,0,0.3);
168
+ }
169
+ .modal-content h3 { margin-bottom: 12px; color: #2D3748; }
170
+ .modal-content p { margin-bottom: 8px; font-size: 13px; color: #4A5568; }
171
+
172
+ @media (max-width: 900px) {
173
+ .container { grid-template-columns: 1fr; }
174
+ }
175
+ </style>
176
+ </head>
177
+ <body>
178
+ <h1>cycleCAD Billing Module Test Suite</h1>
179
+
180
+ <div class="container">
181
+ <!-- Test Controls -->
182
+ <div class="panel">
183
+ <h2>Test Controls</h2>
184
+
185
+ <div class="button-group">
186
+ <button class="btn-primary" onclick="runAllTests()">Run All Tests</button>
187
+ <button class="btn-secondary" onclick="clearTests()">Clear Results</button>
188
+ </div>
189
+
190
+ <h3>Categories</h3>
191
+ <div class="test-category">
192
+ <button class="btn-secondary btn-small" onclick="runCategory('tier')">Tier Detection (5)</button>
193
+ </div>
194
+ <div class="test-category">
195
+ <button class="btn-secondary btn-small" onclick="runCategory('limits')">Limit Checking (8)</button>
196
+ </div>
197
+ <div class="test-category">
198
+ <button class="btn-secondary btn-small" onclick="runCategory('usage')">Usage Tracking (6)</button>
199
+ </div>
200
+ <div class="test-category">
201
+ <button class="btn-secondary btn-small" onclick="runCategory('trial')">Trial Period (4)</button>
202
+ </div>
203
+ <div class="test-category">
204
+ <button class="btn-secondary btn-small" onclick="runCategory('promo')">Promo Codes (3)</button>
205
+ </div>
206
+ <div class="test-category">
207
+ <button class="btn-secondary btn-small" onclick="runCategory('grace')">Grace Period (2)</button>
208
+ </div>
209
+ <div class="test-category">
210
+ <button class="btn-secondary btn-small" onclick="runCategory('features')">Feature Gates (4)</button>
211
+ </div>
212
+ <div class="test-category">
213
+ <button class="btn-secondary btn-small" onclick="runCategory('export')">Export & CSV (2)</button>
214
+ </div>
215
+ <div class="test-category">
216
+ <button class="btn-secondary btn-small" onclick="runCategory('offline')">Offline Mode (2)</button>
217
+ </div>
218
+
219
+ <h3>Stats</h3>
220
+ <div class="stats">
221
+ <div class="stat">
222
+ <div class="stat-value" id="total-count">0</div>
223
+ <div class="stat-label">Total</div>
224
+ </div>
225
+ <div class="stat">
226
+ <div class="stat-value" id="passed-count" style="color: #48BB78;">0</div>
227
+ <div class="stat-label">Passed</div>
228
+ </div>
229
+ <div class="stat">
230
+ <div class="stat-value" id="failed-count" style="color: #F56565;">0</div>
231
+ <div class="stat-label">Failed</div>
232
+ </div>
233
+ </div>
234
+
235
+ <h3>Overall Progress</h3>
236
+ <div class="progress-bar">
237
+ <div class="progress-fill" id="progress-fill" style="width: 0%; background: #667eea;">0%</div>
238
+ </div>
239
+
240
+ <h3>Export</h3>
241
+ <div class="button-group">
242
+ <button class="btn-secondary btn-small" onclick="exportJSON()">JSON</button>
243
+ <button class="btn-secondary btn-small" onclick="exportHTML()">HTML Report</button>
244
+ </div>
245
+ </div>
246
+
247
+ <!-- Test Results -->
248
+ <div class="panel">
249
+ <h2>Test Results</h2>
250
+ <div id="test-results" style="flex: 1; overflow-y: auto;">
251
+ <p style="color: #718096; font-size: 13px;">Run tests to see results</p>
252
+ </div>
253
+ </div>
254
+
255
+ <!-- Console Output -->
256
+ <div class="panel" style="grid-column: 1/-1;">
257
+ <h2>Console Output</h2>
258
+ <div class="console" id="console-output">
259
+ <div class="console-log">[Ready for testing]</div>
260
+ </div>
261
+ </div>
262
+ </div>
263
+
264
+ <!-- Modal for viewing tier details -->
265
+ <div class="modal" id="tier-modal">
266
+ <div class="modal-content">
267
+ <h3>Tier Information</h3>
268
+ <div id="modal-body"></div>
269
+ <button class="btn-primary" style="margin-top: 12px; width: 100%;" onclick="document.getElementById('tier-modal').classList.remove('active')">Close</button>
270
+ </div>
271
+ </div>
272
+
273
+ <script>
274
+ // Mock BillingModule for testing
275
+ const BillingModule = {
276
+ state: {
277
+ userId: 'test-user-123',
278
+ tier: 'free',
279
+ status: 'active',
280
+ billingCycle: 'monthly',
281
+ usage: {
282
+ projects: 1,
283
+ totalParts: 50,
284
+ storageGB: 0.5,
285
+ aiRequestsToday: 5,
286
+ aiRequests: 100,
287
+ stepImportsThisMonth: 2,
288
+ stepImportBytesThisMonth: 10 * 1024 * 1024
289
+ },
290
+ trialEndsAt: null,
291
+ offlineMode: false
292
+ },
293
+ tiers: {
294
+ free: { id: 'free', name: 'Free', limits: { projects: 3, partsPerProject: 100, storageGB: 1, aiRequestsPerDay: 20 } },
295
+ pro: { id: 'pro', name: 'Pro', limits: { projects: Infinity, partsPerProject: Infinity, storageGB: 50, aiRequestsPerDay: 500 } },
296
+ enterprise: { id: 'enterprise', name: 'Enterprise', limits: { projects: Infinity, partsPerProject: Infinity, storageGB: 500, aiRequestsPerDay: Infinity } }
297
+ },
298
+ getCurrentTier() {
299
+ return this.tiers[this.state.tier];
300
+ },
301
+ checkLimit(feature) {
302
+ const tier = this.tiers[this.state.tier];
303
+ const limits = tier.limits;
304
+ const usage = this.state.usage;
305
+ const mapping = {
306
+ 'projects': { limit: limits.projects, current: usage.projects },
307
+ 'parts': { limit: limits.partsPerProject, current: usage.totalParts },
308
+ 'storage': { limit: limits.storageGB, current: usage.storageGB },
309
+ 'ai-requests': { limit: limits.aiRequestsPerDay, current: usage.aiRequestsToday }
310
+ };
311
+ const config = mapping[feature] || {};
312
+ return {
313
+ allowed: config.limit === Infinity || config.current < config.limit,
314
+ current: config.current || 0,
315
+ limit: config.limit
316
+ };
317
+ },
318
+ trackUsage(feature, amount = 1) {
319
+ if (feature === 'ai-request') this.state.usage.aiRequestsToday++;
320
+ else if (feature === 'project-created') this.state.usage.projects++;
321
+ else if (feature === 'storage-added') this.state.usage.storageGB += amount;
322
+ },
323
+ getUsage() {
324
+ return { ...this.state.usage };
325
+ },
326
+ hasFeature(feature) {
327
+ return this.state.tier !== 'free';
328
+ },
329
+ saveState() {
330
+ localStorage.setItem('billing_state', JSON.stringify(this.state));
331
+ }
332
+ };
333
+
334
+ // Test suite
335
+ const tests = [];
336
+
337
+ class TestSuite {
338
+ constructor() {
339
+ this.results = [];
340
+ }
341
+
342
+ test(name, category, fn) {
343
+ tests.push({ name, category, fn });
344
+ }
345
+
346
+ async run() {
347
+ this.results = [];
348
+ for (const test of tests) {
349
+ try {
350
+ await Promise.resolve(test.fn());
351
+ this.results.push({ name: test.name, category: test.category, status: 'passed' });
352
+ logConsole(`✓ ${test.name}`, 'success');
353
+ } catch (e) {
354
+ this.results.push({ name: test.name, category: test.category, status: 'failed', error: e.message });
355
+ logConsole(`✗ ${test.name}: ${e.message}`, 'error');
356
+ }
357
+ }
358
+ renderResults();
359
+ updateStats();
360
+ }
361
+
362
+ runCategory(category) {
363
+ const categoryTests = tests.filter(t => t.category === category);
364
+ this.results = [];
365
+ categoryTests.forEach(test => {
366
+ try {
367
+ test.fn();
368
+ this.results.push({ name: test.name, category: test.category, status: 'passed' });
369
+ } catch (e) {
370
+ this.results.push({ name: test.name, category: test.category, status: 'failed', error: e.message });
371
+ }
372
+ });
373
+ renderResults();
374
+ updateStats();
375
+ }
376
+ }
377
+
378
+ const suite = new TestSuite();
379
+
380
+ // ========== TIER DETECTION ==========
381
+ suite.test('Free tier is default', 'tier', () => {
382
+ const tier = BillingModule.getCurrentTier();
383
+ if (tier.id !== 'free') throw new Error('Default tier should be free');
384
+ });
385
+
386
+ suite.test('Pro tier has correct limits', 'tier', () => {
387
+ BillingModule.state.tier = 'pro';
388
+ const tier = BillingModule.getCurrentTier();
389
+ if (tier.limits.projects !== Infinity) throw new Error('Pro projects should be unlimited');
390
+ if (tier.limits.storageGB !== 50) throw new Error('Pro storage should be 50GB');
391
+ });
392
+
393
+ suite.test('Enterprise tier has unlimited features', 'tier', () => {
394
+ BillingModule.state.tier = 'enterprise';
395
+ const tier = BillingModule.getCurrentTier();
396
+ if (tier.limits.projects !== Infinity) throw new Error('Enterprise projects should be unlimited');
397
+ if (tier.limits.aiRequestsPerDay !== Infinity) throw new Error('Enterprise AI should be unlimited');
398
+ });
399
+
400
+ suite.test('Tier switching works', 'tier', () => {
401
+ BillingModule.state.tier = 'free';
402
+ if (BillingModule.state.tier !== 'free') throw new Error('Free tier not set');
403
+ BillingModule.state.tier = 'pro';
404
+ if (BillingModule.state.tier !== 'pro') throw new Error('Pro tier not set');
405
+ });
406
+
407
+ suite.test('Invalid tier defaults to free', 'tier', () => {
408
+ BillingModule.state.tier = 'invalid';
409
+ const tier = BillingModule.tiers['invalid'] || BillingModule.tiers.free;
410
+ if (tier.id !== 'free') throw new Error('Invalid tier should fallback to free');
411
+ });
412
+
413
+ // ========== LIMIT CHECKING ==========
414
+ suite.test('Detect under limit (projects)', 'limits', () => {
415
+ BillingModule.state.tier = 'free';
416
+ BillingModule.state.usage.projects = 2;
417
+ const check = BillingModule.checkLimit('projects');
418
+ if (!check.allowed) throw new Error('Should allow project creation under limit');
419
+ });
420
+
421
+ suite.test('Detect at limit (projects)', 'limits', () => {
422
+ BillingModule.state.tier = 'free';
423
+ BillingModule.state.usage.projects = 3;
424
+ const check = BillingModule.checkLimit('projects');
425
+ if (check.allowed) throw new Error('Should reject project creation at limit');
426
+ });
427
+
428
+ suite.test('Detect over limit (parts)', 'limits', () => {
429
+ BillingModule.state.tier = 'free';
430
+ BillingModule.state.usage.totalParts = 101;
431
+ const check = BillingModule.checkLimit('parts');
432
+ if (check.allowed) throw new Error('Should reject part creation over limit');
433
+ });
434
+
435
+ suite.test('Unlimited tier allows anything', 'limits', () => {
436
+ BillingModule.state.tier = 'pro';
437
+ BillingModule.state.usage.projects = 10000;
438
+ const check = BillingModule.checkLimit('projects');
439
+ if (!check.allowed) throw new Error('Pro should allow unlimited projects');
440
+ });
441
+
442
+ suite.test('Storage limit warning at 80%', 'limits', () => {
443
+ BillingModule.state.tier = 'free';
444
+ BillingModule.state.usage.storageGB = 0.8;
445
+ const check = BillingModule.checkLimit('storage');
446
+ if (check.percentUsed !== 80) throw new Error('Should calculate 80% usage');
447
+ });
448
+
449
+ suite.test('AI requests reset daily', 'limits', () => {
450
+ BillingModule.state.tier = 'free';
451
+ BillingModule.state.usage.aiRequestsToday = 0;
452
+ for (let i = 0; i < 20; i++) BillingModule.trackUsage('ai-request');
453
+ const check = BillingModule.checkLimit('ai-requests');
454
+ if (check.allowed) throw new Error('Should reject 21st AI request');
455
+ });
456
+
457
+ suite.test('Limits return correct percentages', 'limits', () => {
458
+ BillingModule.state.tier = 'free';
459
+ BillingModule.state.usage.storageGB = 0.5;
460
+ const check = BillingModule.checkLimit('storage');
461
+ if (check.percentUsed !== 50) throw new Error(`Expected 50%, got ${check.percentUsed}%`);
462
+ });
463
+
464
+ suite.test('Free tier storage limit is 1GB', 'limits', () => {
465
+ BillingModule.state.tier = 'free';
466
+ const tier = BillingModule.getCurrentTier();
467
+ if (tier.limits.storageGB !== 1) throw new Error('Free storage should be 1GB');
468
+ });
469
+
470
+ // ========== USAGE TRACKING ==========
471
+ suite.test('Track project creation', 'usage', () => {
472
+ BillingModule.state.usage.projects = 0;
473
+ BillingModule.trackUsage('project-created');
474
+ if (BillingModule.state.usage.projects !== 1) throw new Error('Project count not incremented');
475
+ });
476
+
477
+ suite.test('Track storage usage', 'usage', () => {
478
+ BillingModule.state.usage.storageGB = 1.0;
479
+ BillingModule.trackUsage('storage-added', 0.5);
480
+ if (BillingModule.state.usage.storageGB !== 1.5) throw new Error('Storage not incremented correctly');
481
+ });
482
+
483
+ suite.test('Track AI requests', 'usage', () => {
484
+ BillingModule.state.usage.aiRequestsToday = 0;
485
+ BillingModule.trackUsage('ai-request');
486
+ if (BillingModule.state.usage.aiRequestsToday !== 1) throw new Error('AI request count not incremented');
487
+ });
488
+
489
+ suite.test('Get usage returns all metrics', 'usage', () => {
490
+ const usage = BillingModule.getUsage();
491
+ if (!usage.projects || usage.aiRequestsToday === undefined) throw new Error('Usage missing metrics');
492
+ });
493
+
494
+ suite.test('Prevent concurrent overages', 'usage', () => {
495
+ BillingModule.state.tier = 'free';
496
+ BillingModule.state.usage.projects = 2;
497
+ const check1 = BillingModule.checkLimit('projects');
498
+ const check2 = BillingModule.checkLimit('projects');
499
+ if (!check1.allowed || !check2.allowed) throw new Error('Both should be allowed before limit');
500
+ BillingModule.state.usage.projects = 3;
501
+ const check3 = BillingModule.checkLimit('projects');
502
+ if (check3.allowed) throw new Error('Should reject at exactly limit');
503
+ });
504
+
505
+ suite.test('Usage persists after save', 'usage', () => {
506
+ BillingModule.state.usage.projects = 5;
507
+ BillingModule.saveState();
508
+ const saved = JSON.parse(localStorage.getItem('billing_state'));
509
+ if (saved.usage.projects !== 5) throw new Error('Usage not persisted');
510
+ });
511
+
512
+ // ========== TRIAL PERIOD ==========
513
+ suite.test('Trial period countdown', 'trial', () => {
514
+ const now = Date.now();
515
+ const trialEnd = now + (14 * 24 * 60 * 60 * 1000); // 14 days
516
+ BillingModule.state.trialEndsAt = trialEnd;
517
+ const daysLeft = Math.ceil((trialEnd - now) / (1000 * 60 * 60 * 24));
518
+ if (daysLeft < 14 || daysLeft > 14) throw new Error('Trial countdown incorrect');
519
+ });
520
+
521
+ suite.test('Trial expiration detection', 'trial', () => {
522
+ const now = Date.now();
523
+ BillingModule.state.trialEndsAt = now - 1000; // Expired 1s ago
524
+ const daysLeft = Math.ceil((BillingModule.state.trialEndsAt - now) / (1000 * 60 * 60 * 24));
525
+ if (daysLeft >= 0) throw new Error('Expired trial should have negative days');
526
+ });
527
+
528
+ suite.test('Trial auto-converts to paid', 'trial', () => {
529
+ BillingModule.state.tier = 'free';
530
+ BillingModule.state.status = 'trialing';
531
+ BillingModule.state.trialEndsAt = Date.now() - 1000; // Expired
532
+ // Simulate conversion
533
+ BillingModule.state.tier = 'pro';
534
+ BillingModule.state.status = 'active';
535
+ if (BillingModule.state.tier !== 'pro') throw new Error('Trial not converted to paid');
536
+ });
537
+
538
+ suite.test('Trial can be canceled', 'trial', () => {
539
+ BillingModule.state.status = 'trialing';
540
+ BillingModule.state.tier = 'free'; // Downgrade on cancel
541
+ BillingModule.state.status = 'canceled';
542
+ if (BillingModule.state.tier !== 'free' || BillingModule.state.status !== 'canceled') throw new Error('Trial cancel failed');
543
+ });
544
+
545
+ // ========== PROMO CODES ==========
546
+ suite.test('Valid promo code accepted', 'promo', () => {
547
+ const promoCodes = {
548
+ 'STUDENT20': { discount: 0.20, valid: true },
549
+ 'NONPROFIT30': { discount: 0.30, valid: true }
550
+ };
551
+ const code = 'STUDENT20';
552
+ if (!promoCodes[code]?.valid) throw new Error('Promo code validation failed');
553
+ });
554
+
555
+ suite.test('Invalid promo code rejected', 'promo', () => {
556
+ const promoCodes = {};
557
+ const code = 'FAKECODE123';
558
+ if (promoCodes[code]) throw new Error('Invalid code should not exist');
559
+ });
560
+
561
+ suite.test('Promo code discount applied', 'promo', () => {
562
+ const basePrice = 4900; // €49.00 in cents
563
+ const discount = 0.20;
564
+ const finalPrice = basePrice * (1 - discount);
565
+ if (finalPrice !== 3920) throw new Error(`Expected 3920, got ${finalPrice}`);
566
+ });
567
+
568
+ // ========== GRACE PERIOD ==========
569
+ suite.test('Grace period starts on payment failure', 'grace', () => {
570
+ BillingModule.state.status = 'payment_failed';
571
+ const gracePeriodEnds = Date.now() + (7 * 24 * 60 * 60 * 1000);
572
+ if (!gracePeriodEnds) throw new Error('Grace period not started');
573
+ });
574
+
575
+ suite.test('Access maintained during grace period', 'grace', () => {
576
+ BillingModule.state.status = 'payment_failed';
577
+ BillingModule.state.tier = 'pro'; // Should remain pro
578
+ if (BillingModule.state.tier !== 'pro') throw new Error('Access lost during grace period');
579
+ });
580
+
581
+ // ========== FEATURE GATES ==========
582
+ suite.test('Free tier blocks Pro features', 'features', () => {
583
+ BillingModule.state.tier = 'free';
584
+ const hasFeature = BillingModule.hasFeature('cam-operations');
585
+ if (hasFeature) throw new Error('Free tier should not have CAM operations');
586
+ });
587
+
588
+ suite.test('Pro tier unlocks features', 'features', () => {
589
+ BillingModule.state.tier = 'pro';
590
+ const hasFeature = BillingModule.hasFeature('api-access');
591
+ if (!hasFeature) throw new Error('Pro tier should have API access');
592
+ });
593
+
594
+ suite.test('Feature gate enforcement works', 'features', () => {
595
+ BillingModule.state.tier = 'free';
596
+ const canUseCAM = BillingModule.hasFeature('cam-operations');
597
+ BillingModule.state.tier = 'pro';
598
+ const canUseCAMPro = BillingModule.hasFeature('cam-operations');
599
+ if (canUseCAM === canUseCAMPro) throw new Error('Feature gate not enforcing tier difference');
600
+ });
601
+
602
+ suite.test('Enterprise unlocks everything', 'features', () => {
603
+ BillingModule.state.tier = 'enterprise';
604
+ const hasSSO = BillingModule.hasFeature('sso');
605
+ const hasCustomBranding = BillingModule.hasFeature('custom-branding');
606
+ if (!hasSSO || !hasCustomBranding) throw new Error('Enterprise should have all features');
607
+ });
608
+
609
+ // ========== EXPORT & CSV ==========
610
+ suite.test('Export usage to CSV format', 'export', () => {
611
+ BillingModule.state.usage.projects = 2;
612
+ BillingModule.state.usage.storageGB = 0.5;
613
+ const csv = 'Projects,2\nStorage,0.5';
614
+ if (!csv.includes('Projects')) throw new Error('CSV missing projects field');
615
+ });
616
+
617
+ suite.test('CSV includes all metrics', 'export', () => {
618
+ const metrics = ['projects', 'totalParts', 'storageGB', 'aiRequests'];
619
+ const csv = metrics.map(m => `${m},${BillingModule.state.usage[m]}`).join('\n');
620
+ metrics.forEach(m => {
621
+ if (!csv.includes(m)) throw new Error(`CSV missing ${m}`);
622
+ });
623
+ });
624
+
625
+ // ========== OFFLINE MODE ==========
626
+ suite.test('Offline mode uses cached data', 'offline', () => {
627
+ BillingModule.state.offlineMode = true;
628
+ BillingModule.saveState();
629
+ const usage = BillingModule.getUsage();
630
+ if (!usage) throw new Error('Offline mode should provide usage data');
631
+ });
632
+
633
+ suite.test('Sync resumes when online', 'offline', () => {
634
+ BillingModule.state.offlineMode = true;
635
+ BillingModule.state.offlineMode = false; // Go online
636
+ const tier = BillingModule.getCurrentTier();
637
+ if (!tier) throw new Error('Should sync tier when online');
638
+ });
639
+
640
+ // ========== Helper Functions ==========
641
+ function logConsole(message, type = 'log') {
642
+ const console = document.getElementById('console-output');
643
+ const log = document.createElement('div');
644
+ log.className = `console-log console-${type}`;
645
+ log.textContent = message;
646
+ console.appendChild(log);
647
+ console.scrollTop = console.scrollHeight;
648
+ }
649
+
650
+ function renderResults() {
651
+ const resultsDiv = document.getElementById('test-results');
652
+ const grouped = {};
653
+
654
+ suite.results.forEach(result => {
655
+ if (!grouped[result.category]) grouped[result.category] = [];
656
+ grouped[result.category].push(result);
657
+ });
658
+
659
+ let html = '';
660
+ Object.keys(grouped).sort().forEach(category => {
661
+ html += `<h3>${category.replace(/\b\w/g, c => c.toUpperCase())}</h3>`;
662
+ grouped[category].forEach(result => {
663
+ const icon = result.status === 'passed' ? '✓' : '✗';
664
+ const detail = result.error ? ` - ${result.error}` : '';
665
+ html += `
666
+ <div class="test-item ${result.status}">
667
+ <span class="test-icon">${icon}</span>
668
+ <span class="test-name">${result.name}</span>
669
+ ${detail ? `<span class="test-details">${detail}</span>` : ''}
670
+ </div>
671
+ `;
672
+ });
673
+ });
674
+
675
+ resultsDiv.innerHTML = html || '<p style="color: #718096;">No results yet</p>';
676
+ }
677
+
678
+ function updateStats() {
679
+ const total = suite.results.length;
680
+ const passed = suite.results.filter(r => r.status === 'passed').length;
681
+ const failed = suite.results.filter(r => r.status === 'failed').length;
682
+ const percentage = total > 0 ? Math.round((passed / total) * 100) : 0;
683
+
684
+ document.getElementById('total-count').textContent = total;
685
+ document.getElementById('passed-count').textContent = passed;
686
+ document.getElementById('failed-count').textContent = failed;
687
+
688
+ const fill = document.getElementById('progress-fill');
689
+ fill.style.width = percentage + '%';
690
+ fill.textContent = percentage + '%';
691
+ }
692
+
693
+ function clearTests() {
694
+ suite.results = [];
695
+ document.getElementById('test-results').innerHTML = '<p style="color: #718096;">Tests cleared</p>';
696
+ updateStats();
697
+ logConsole('[Tests cleared]');
698
+ }
699
+
700
+ async function runAllTests() {
701
+ logConsole('[Starting all tests...]');
702
+ await suite.run();
703
+ logConsole(`[${suite.results.length} tests completed]`);
704
+ }
705
+
706
+ function runCategory(category) {
707
+ logConsole(`[Running ${category} tests...]`);
708
+ suite.runCategory(category);
709
+ logConsole(`[${suite.results.filter(r => r.category === category).length} ${category} tests completed]`);
710
+ }
711
+
712
+ function exportJSON() {
713
+ const data = {
714
+ timestamp: new Date().toISOString(),
715
+ totalTests: suite.results.length,
716
+ passed: suite.results.filter(r => r.status === 'passed').length,
717
+ failed: suite.results.filter(r => r.status === 'failed').length,
718
+ results: suite.results
719
+ };
720
+ const json = JSON.stringify(data, null, 2);
721
+ const blob = new Blob([json], { type: 'application/json' });
722
+ const url = URL.createObjectURL(blob);
723
+ const a = document.createElement('a');
724
+ a.href = url;
725
+ a.download = `billing-tests-${Date.now()}.json`;
726
+ a.click();
727
+ URL.revokeObjectURL(url);
728
+ }
729
+
730
+ function exportHTML() {
731
+ const passed = suite.results.filter(r => r.status === 'passed').length;
732
+ const failed = suite.results.filter(r => r.status === 'failed').length;
733
+ const html = `
734
+ <!DOCTYPE html>
735
+ <html>
736
+ <head><title>Billing Tests Report</title><style>
737
+ body { font-family: sans-serif; padding: 20px; background: #f5f5f5; }
738
+ h1 { color: #333; }
739
+ .stats { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px; margin: 20px 0; }
740
+ .stat { background: white; padding: 20px; border-radius: 8px; text-align: center; }
741
+ .stat-value { font-size: 32px; font-weight: bold; }
742
+ .passed { color: #28a745; }
743
+ .failed { color: #dc3545; }
744
+ .test { background: white; padding: 12px; margin: 10px 0; border-left: 4px solid; border-radius: 4px; }
745
+ .test.passed { border-color: #28a745; }
746
+ .test.failed { border-color: #dc3545; }
747
+ </style></head>
748
+ <body>
749
+ <h1>cycleCAD Billing Tests Report</h1>
750
+ <p>Generated: ${new Date().toISOString()}</p>
751
+ <div class="stats">
752
+ <div class="stat"><div class="stat-value">${suite.results.length}</div><div>Total</div></div>
753
+ <div class="stat"><div class="stat-value passed">${passed}</div><div>Passed</div></div>
754
+ <div class="stat"><div class="stat-value failed">${failed}</div><div>Failed</div></div>
755
+ </div>
756
+ <h2>Results</h2>
757
+ ${suite.results.map(r => `
758
+ <div class="test ${r.status}">
759
+ <strong>${r.category}: ${r.name}</strong>
760
+ <p>${r.status.toUpperCase()} ${r.error ? '- ' + r.error : ''}</p>
761
+ </div>
762
+ `).join('')}
763
+ </body>
764
+ </html>
765
+ `;
766
+ const blob = new Blob([html], { type: 'text/html' });
767
+ const url = URL.createObjectURL(blob);
768
+ const a = document.createElement('a');
769
+ a.href = url;
770
+ a.download = `billing-tests-report-${Date.now()}.html`;
771
+ a.click();
772
+ URL.revokeObjectURL(url);
773
+ }
774
+
775
+ // Initialize
776
+ logConsole('[Billing Test Suite Ready - Click "Run All Tests" to begin]');
777
+ </script>
778
+ </body>
779
+ </html>