onboardme-sdk 0.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.
Files changed (69) hide show
  1. package/ARCHITECTURE-v2.md +225 -0
  2. package/dist/sdk.iife.js +348 -0
  3. package/package.json +22 -0
  4. package/src/__tests__/day1.test.ts +37 -0
  5. package/src/__tests__/day2.test.ts +447 -0
  6. package/src/__tests__/day3.test.ts +110 -0
  7. package/src/__tests__/day4.test.ts +115 -0
  8. package/src/__tests__/day5.test.ts +102 -0
  9. package/src/__tests__/snapshot-dom-collector.test.ts +153 -0
  10. package/src/__tests__/snapshot-sender.test.ts +111 -0
  11. package/src/__tests__/v2-integration.test.ts +305 -0
  12. package/src/__tests__/v2-positioner.test.ts +115 -0
  13. package/src/__tests__/v2-renderer.test.ts +189 -0
  14. package/src/__tests__/v2-types.test.ts +74 -0
  15. package/src/__tests__/week2-day1.test.ts +62 -0
  16. package/src/__tests__/week2-day2.test.ts +128 -0
  17. package/src/__tests__/week2-day3.test.ts +128 -0
  18. package/src/__tests__/week2-day4.test.ts +177 -0
  19. package/src/__tests__/week2-day5.test.ts +294 -0
  20. package/src/__tests__/week3-day1.test.ts +169 -0
  21. package/src/__tests__/week3-day2.test.ts +267 -0
  22. package/src/__tests__/week3-day3.test.ts +213 -0
  23. package/src/__tests__/week3-day4.test.ts +213 -0
  24. package/src/__tests__/week3-day5.test.ts +350 -0
  25. package/src/__tests__/week4-day1.test.ts +277 -0
  26. package/src/__tests__/week4-day2.test.ts +227 -0
  27. package/src/__tests__/week4-day3.test.ts +323 -0
  28. package/src/__tests__/week4-day4.test.ts +210 -0
  29. package/src/__tests__/week4-day5.test.ts +503 -0
  30. package/src/__tests__/week5-day1.test.ts +152 -0
  31. package/src/__tests__/week5-day2.test.ts +222 -0
  32. package/src/__tests__/week5-day3.test.ts +297 -0
  33. package/src/__tests__/week5-day4.test.ts +306 -0
  34. package/src/__tests__/week5-day5.test.ts +345 -0
  35. package/src/__tests__/week7-day5-api-flows.test.ts +353 -0
  36. package/src/auto-generate/context-collector.ts +47 -0
  37. package/src/auto-generate/flow-generator-client.ts +97 -0
  38. package/src/browser.ts +5 -0
  39. package/src/components/celebration.ts +44 -0
  40. package/src/components/checklist-css.ts +159 -0
  41. package/src/components/checklist.ts +295 -0
  42. package/src/components/modal-css.ts +96 -0
  43. package/src/components/modal.ts +171 -0
  44. package/src/components/shadow-host.ts +30 -0
  45. package/src/core/api-client.ts +39 -0
  46. package/src/core/api-flows.ts +204 -0
  47. package/src/core/config.ts +37 -0
  48. package/src/core/event-batcher.ts +169 -0
  49. package/src/core/sdk.ts +301 -0
  50. package/src/detection/user-detection.ts +55 -0
  51. package/src/index.ts +95 -0
  52. package/src/snapshot/dom-collector.ts +193 -0
  53. package/src/snapshot/sender.ts +105 -0
  54. package/src/storage/event-listener.ts +59 -0
  55. package/src/storage/progress-tracker.ts +78 -0
  56. package/src/styles/checklist-css.ts +159 -0
  57. package/src/styles/checklist.css +166 -0
  58. package/src/styles/modal-css.ts +96 -0
  59. package/src/styles/modal.css +102 -0
  60. package/src/utils/dom.ts +49 -0
  61. package/src/utils/fingerprint.ts +20 -0
  62. package/src/utils/logger.ts +17 -0
  63. package/src/v2/positioner.ts +105 -0
  64. package/src/v2/renderer.ts +287 -0
  65. package/src/v2/styles.ts +89 -0
  66. package/src/v2/types.ts +53 -0
  67. package/tsconfig.json +11 -0
  68. package/vite.config.ts +28 -0
  69. package/vitest.config.ts +7 -0
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Week 5 Day 2 — context-collector.ts
3
+ *
4
+ * Tests for:
5
+ * - src/auto-generate/context-collector.ts (collectContext)
6
+ *
7
+ * Verifies: title, h1-h3 headings, nav links, buttons, deduplication,
8
+ * trimming, empty filtering, max caps, [role="button"] support.
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach } from 'vitest';
12
+ import { collectContext } from '../auto-generate/context-collector.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Helpers
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function setBody(html: string) {
19
+ document.body.innerHTML = html;
20
+ }
21
+
22
+ beforeEach(() => {
23
+ document.title = '';
24
+ document.body.innerHTML = '';
25
+ });
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // title
29
+ // ---------------------------------------------------------------------------
30
+
31
+ describe('Week 5 Day 2 — collectContext: title', () => {
32
+ it('returns document.title', () => {
33
+ document.title = 'Acme App — Dashboard';
34
+ const ctx = collectContext();
35
+ expect(ctx.title).toBe('Acme App — Dashboard');
36
+ });
37
+
38
+ it('returns empty string when document.title is empty', () => {
39
+ document.title = '';
40
+ const ctx = collectContext();
41
+ expect(ctx.title).toBe('');
42
+ });
43
+ });
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // headings
47
+ // ---------------------------------------------------------------------------
48
+
49
+ describe('Week 5 Day 2 — collectContext: headings', () => {
50
+ it('collects h1 text', () => {
51
+ setBody('<h1>Welcome to Acme</h1>');
52
+ expect(collectContext().headings).toContain('Welcome to Acme');
53
+ });
54
+
55
+ it('collects h2 text', () => {
56
+ setBody('<h2>Getting Started</h2>');
57
+ expect(collectContext().headings).toContain('Getting Started');
58
+ });
59
+
60
+ it('collects h3 text', () => {
61
+ setBody('<h3>Step one</h3>');
62
+ expect(collectContext().headings).toContain('Step one');
63
+ });
64
+
65
+ it('does NOT collect h4 or deeper', () => {
66
+ setBody('<h4>Not included</h4><h5>Also not</h5>');
67
+ expect(collectContext().headings).toHaveLength(0);
68
+ });
69
+
70
+ it('collects multiple headings in DOM order', () => {
71
+ setBody('<h1>Alpha</h1><h2>Beta</h2><h3>Gamma</h3>');
72
+ const { headings } = collectContext();
73
+ expect(headings).toEqual(['Alpha', 'Beta', 'Gamma']);
74
+ });
75
+
76
+ it('trims whitespace from heading text', () => {
77
+ setBody('<h1> Padded heading </h1>');
78
+ expect(collectContext().headings[0]).toBe('Padded heading');
79
+ });
80
+
81
+ it('skips headings with only whitespace', () => {
82
+ setBody('<h1> </h1><h2>Real heading</h2>');
83
+ const { headings } = collectContext();
84
+ expect(headings).toEqual(['Real heading']);
85
+ });
86
+
87
+ it('deduplicates identical heading text', () => {
88
+ setBody('<h1>Duplicate</h1><h2>Duplicate</h2>');
89
+ const { headings } = collectContext();
90
+ expect(headings).toEqual(['Duplicate']);
91
+ });
92
+
93
+ it('caps at 10 headings', () => {
94
+ const html = Array.from({ length: 15 }, (_, i) => `<h2>Heading ${i}</h2>`).join('');
95
+ setBody(html);
96
+ expect(collectContext().headings).toHaveLength(10);
97
+ });
98
+
99
+ it('returns empty array when no headings exist', () => {
100
+ setBody('<p>No headings here</p>');
101
+ expect(collectContext().headings).toEqual([]);
102
+ });
103
+ });
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // navLinks
107
+ // ---------------------------------------------------------------------------
108
+
109
+ describe('Week 5 Day 2 — collectContext: navLinks', () => {
110
+ it('collects anchor text inside a nav element', () => {
111
+ setBody('<nav><a href="/home">Home</a><a href="/settings">Settings</a></nav>');
112
+ expect(collectContext().navLinks).toEqual(['Home', 'Settings']);
113
+ });
114
+
115
+ it('does NOT collect anchors outside of nav', () => {
116
+ setBody('<a href="/signup">Sign up</a>');
117
+ expect(collectContext().navLinks).toEqual([]);
118
+ });
119
+
120
+ it('trims whitespace from nav link text', () => {
121
+ setBody('<nav><a href="/"> Dashboard </a></nav>');
122
+ expect(collectContext().navLinks[0]).toBe('Dashboard');
123
+ });
124
+
125
+ it('skips nav links with only whitespace text', () => {
126
+ setBody('<nav><a href="/"> </a><a href="/help">Help</a></nav>');
127
+ expect(collectContext().navLinks).toEqual(['Help']);
128
+ });
129
+
130
+ it('deduplicates nav link text', () => {
131
+ setBody('<nav><a href="/a">Link</a><a href="/b">Link</a></nav>');
132
+ expect(collectContext().navLinks).toEqual(['Link']);
133
+ });
134
+
135
+ it('caps at 10 nav links', () => {
136
+ const links = Array.from({ length: 15 }, (_, i) => `<a href="/${i}">Link ${i}</a>`).join('');
137
+ setBody(`<nav>${links}</nav>`);
138
+ expect(collectContext().navLinks).toHaveLength(10);
139
+ });
140
+
141
+ it('returns empty array when no nav exists', () => {
142
+ setBody('<p>No nav</p>');
143
+ expect(collectContext().navLinks).toEqual([]);
144
+ });
145
+ });
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // buttons
149
+ // ---------------------------------------------------------------------------
150
+
151
+ describe('Week 5 Day 2 — collectContext: buttons', () => {
152
+ it('collects button element text', () => {
153
+ setBody('<button>Create project</button>');
154
+ expect(collectContext().buttons).toContain('Create project');
155
+ });
156
+
157
+ it('collects [role="button"] text', () => {
158
+ setBody('<div role="button">Invite team</div>');
159
+ expect(collectContext().buttons).toContain('Invite team');
160
+ });
161
+
162
+ it('trims whitespace from button text', () => {
163
+ setBody('<button> Upgrade </button>');
164
+ expect(collectContext().buttons[0]).toBe('Upgrade');
165
+ });
166
+
167
+ it('skips buttons with only whitespace text', () => {
168
+ setBody('<button> </button><button>Save</button>');
169
+ expect(collectContext().buttons).toEqual(['Save']);
170
+ });
171
+
172
+ it('deduplicates button text', () => {
173
+ setBody('<button>Delete</button><button>Delete</button>');
174
+ expect(collectContext().buttons).toEqual(['Delete']);
175
+ });
176
+
177
+ it('caps at 15 buttons', () => {
178
+ const html = Array.from({ length: 20 }, (_, i) => `<button>Btn ${i}</button>`).join('');
179
+ setBody(html);
180
+ expect(collectContext().buttons).toHaveLength(15);
181
+ });
182
+
183
+ it('returns empty array when no buttons exist', () => {
184
+ setBody('<p>No buttons</p>');
185
+ expect(collectContext().buttons).toEqual([]);
186
+ });
187
+ });
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Return shape
191
+ // ---------------------------------------------------------------------------
192
+
193
+ describe('Week 5 Day 2 — collectContext: return shape', () => {
194
+ it('always returns an object with all four ProductContext fields', () => {
195
+ const ctx = collectContext();
196
+ expect(ctx).toHaveProperty('title');
197
+ expect(ctx).toHaveProperty('headings');
198
+ expect(ctx).toHaveProperty('navLinks');
199
+ expect(ctx).toHaveProperty('buttons');
200
+ });
201
+
202
+ it('returns a complete snapshot from a realistic page fragment', () => {
203
+ document.title = 'Acme — Projects';
204
+ setBody(`
205
+ <h1>Your Projects</h1>
206
+ <h2>Recent Activity</h2>
207
+ <nav>
208
+ <a href="/dashboard">Dashboard</a>
209
+ <a href="/projects">Projects</a>
210
+ <a href="/settings">Settings</a>
211
+ </nav>
212
+ <button>New Project</button>
213
+ <button>Invite Member</button>
214
+ <div role="button">Upgrade Plan</div>
215
+ `);
216
+ const ctx = collectContext();
217
+ expect(ctx.title).toBe('Acme — Projects');
218
+ expect(ctx.headings).toEqual(['Your Projects', 'Recent Activity']);
219
+ expect(ctx.navLinks).toEqual(['Dashboard', 'Projects', 'Settings']);
220
+ expect(ctx.buttons).toEqual(['New Project', 'Invite Member', 'Upgrade Plan']);
221
+ });
222
+ });
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Week 5 Day 3 — flow-generator-client.ts
3
+ *
4
+ * Tests for:
5
+ * - src/auto-generate/flow-generator-client.ts
6
+ * fetchGeneratedFlows: cache hit, cache miss, expiry, server errors,
7
+ * network errors, cache save, body shape
8
+ * mergeFlows: append, dedup by id, empty cases
9
+ * loadCache / saveCache / cacheKey: storage helpers
10
+ */
11
+
12
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
13
+ import {
14
+ fetchGeneratedFlows,
15
+ mergeFlows,
16
+ loadCache,
17
+ saveCache,
18
+ cacheKey,
19
+ DEFAULT_CACHE_TTL_MS,
20
+ } from '../auto-generate/flow-generator-client.js';
21
+ import type { FlowConfig, ProductContext } from '@onboardme/types';
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Fixtures
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const ENDPOINT = 'http://localhost:3002/generate';
28
+ const PRODUCT_ID = 'test-product';
29
+
30
+ const mockContext: ProductContext = {
31
+ title: 'Acme App',
32
+ headings: ['Welcome'],
33
+ navLinks: ['Home'],
34
+ buttons: ['Get Started'],
35
+ };
36
+
37
+ const generatedFlow: FlowConfig = {
38
+ id: 'gen-flow-1',
39
+ name: 'AI Welcome Flow',
40
+ trigger: { type: 'auto' },
41
+ steps: [
42
+ {
43
+ id: 'step-1',
44
+ type: 'modal',
45
+ order: 1,
46
+ title: 'Welcome!',
47
+ body: 'Let us show you around.',
48
+ cta: 'Next',
49
+ },
50
+ ],
51
+ };
52
+
53
+ const manualFlow: FlowConfig = {
54
+ id: 'manual-flow-1',
55
+ name: 'Manual Flow',
56
+ trigger: { type: 'auto' },
57
+ steps: [],
58
+ };
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Helpers
62
+ // ---------------------------------------------------------------------------
63
+
64
+ function makeServerResponse(flows: FlowConfig[]) {
65
+ return vi.fn().mockResolvedValue({
66
+ ok: true,
67
+ json: () => Promise.resolve({ flows }),
68
+ });
69
+ }
70
+
71
+ beforeEach(() => {
72
+ localStorage.clear();
73
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
74
+ });
75
+
76
+ afterEach(() => {
77
+ vi.restoreAllMocks();
78
+ vi.unstubAllGlobals();
79
+ localStorage.clear();
80
+ });
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // cacheKey
84
+ // ---------------------------------------------------------------------------
85
+
86
+ describe('Week 5 Day 3 — cacheKey', () => {
87
+ it('returns the correct localStorage key', () => {
88
+ expect(cacheKey('my-product')).toBe('onboardme_autogen_my-product');
89
+ });
90
+
91
+ it('includes productId verbatim', () => {
92
+ expect(cacheKey('prod-123')).toBe('onboardme_autogen_prod-123');
93
+ });
94
+ });
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // saveCache / loadCache
98
+ // ---------------------------------------------------------------------------
99
+
100
+ describe('Week 5 Day 3 — saveCache + loadCache', () => {
101
+ it('saveCache writes flows to localStorage', () => {
102
+ saveCache(PRODUCT_ID, [generatedFlow]);
103
+ expect(localStorage.getItem(cacheKey(PRODUCT_ID))).not.toBeNull();
104
+ });
105
+
106
+ it('loadCache returns saved flows within TTL', () => {
107
+ saveCache(PRODUCT_ID, [generatedFlow]);
108
+ const result = loadCache(PRODUCT_ID, DEFAULT_CACHE_TTL_MS);
109
+ expect(result).toHaveLength(1);
110
+ expect(result![0].id).toBe('gen-flow-1');
111
+ });
112
+
113
+ it('loadCache returns null when no cache exists', () => {
114
+ expect(loadCache(PRODUCT_ID, DEFAULT_CACHE_TTL_MS)).toBeNull();
115
+ });
116
+
117
+ it('loadCache returns null when cache is expired', () => {
118
+ const expired = JSON.stringify({
119
+ flows: [generatedFlow],
120
+ cachedAt: Date.now() - DEFAULT_CACHE_TTL_MS - 1,
121
+ });
122
+ localStorage.setItem(cacheKey(PRODUCT_ID), expired);
123
+ expect(loadCache(PRODUCT_ID, DEFAULT_CACHE_TTL_MS)).toBeNull();
124
+ });
125
+
126
+ it('loadCache returns null on corrupt JSON', () => {
127
+ localStorage.setItem(cacheKey(PRODUCT_ID), 'not-json{{{');
128
+ expect(loadCache(PRODUCT_ID, DEFAULT_CACHE_TTL_MS)).toBeNull();
129
+ });
130
+
131
+ it('loadCache respects custom TTL', () => {
132
+ const shortTtl = 1_000; // 1 second
133
+ const expired = JSON.stringify({
134
+ flows: [generatedFlow],
135
+ cachedAt: Date.now() - 2_000, // 2s ago
136
+ });
137
+ localStorage.setItem(cacheKey(PRODUCT_ID), expired);
138
+ expect(loadCache(PRODUCT_ID, shortTtl)).toBeNull();
139
+ });
140
+ });
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // fetchGeneratedFlows — cache hit
144
+ // ---------------------------------------------------------------------------
145
+
146
+ describe('Week 5 Day 3 — fetchGeneratedFlows: cache hit', () => {
147
+ it('returns cached flows without calling fetch', async () => {
148
+ saveCache(PRODUCT_ID, [generatedFlow]);
149
+ const fetchMock = vi.fn();
150
+ vi.stubGlobal('fetch', fetchMock);
151
+
152
+ const result = await fetchGeneratedFlows(ENDPOINT, PRODUCT_ID, mockContext);
153
+ expect(fetchMock).not.toHaveBeenCalled();
154
+ expect(result[0].id).toBe('gen-flow-1');
155
+ });
156
+ });
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // fetchGeneratedFlows — cache miss (network fetch)
160
+ // ---------------------------------------------------------------------------
161
+
162
+ describe('Week 5 Day 3 — fetchGeneratedFlows: cache miss', () => {
163
+ it('POSTs to the endpoint when no cache exists', async () => {
164
+ const fetchMock = makeServerResponse([generatedFlow]);
165
+ vi.stubGlobal('fetch', fetchMock);
166
+
167
+ await fetchGeneratedFlows(ENDPOINT, PRODUCT_ID, mockContext);
168
+ expect(fetchMock).toHaveBeenCalledWith(
169
+ ENDPOINT,
170
+ expect.objectContaining({ method: 'POST' }),
171
+ );
172
+ });
173
+
174
+ it('sends productId and context in the request body', async () => {
175
+ const fetchMock = makeServerResponse([generatedFlow]);
176
+ vi.stubGlobal('fetch', fetchMock);
177
+
178
+ await fetchGeneratedFlows(ENDPOINT, PRODUCT_ID, mockContext);
179
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
180
+ expect(body.productId).toBe(PRODUCT_ID);
181
+ expect(body.context.title).toBe('Acme App');
182
+ });
183
+
184
+ it('returns the flows from the server response', async () => {
185
+ vi.stubGlobal('fetch', makeServerResponse([generatedFlow]));
186
+ const result = await fetchGeneratedFlows(ENDPOINT, PRODUCT_ID, mockContext);
187
+ expect(result).toHaveLength(1);
188
+ expect(result[0].id).toBe('gen-flow-1');
189
+ });
190
+
191
+ it('saves fetched flows to localStorage cache', async () => {
192
+ vi.stubGlobal('fetch', makeServerResponse([generatedFlow]));
193
+ await fetchGeneratedFlows(ENDPOINT, PRODUCT_ID, mockContext);
194
+ expect(localStorage.getItem(cacheKey(PRODUCT_ID))).not.toBeNull();
195
+ });
196
+
197
+ it('a subsequent call hits the cache (no second fetch)', async () => {
198
+ const fetchMock = makeServerResponse([generatedFlow]);
199
+ vi.stubGlobal('fetch', fetchMock);
200
+
201
+ await fetchGeneratedFlows(ENDPOINT, PRODUCT_ID, mockContext);
202
+ await fetchGeneratedFlows(ENDPOINT, PRODUCT_ID, mockContext);
203
+ expect(fetchMock).toHaveBeenCalledTimes(1);
204
+ });
205
+ });
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // fetchGeneratedFlows — error handling
209
+ // ---------------------------------------------------------------------------
210
+
211
+ describe('Week 5 Day 3 — fetchGeneratedFlows: error handling', () => {
212
+ it('returns [] on non-2xx server response', async () => {
213
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
214
+ const result = await fetchGeneratedFlows(ENDPOINT, PRODUCT_ID, mockContext);
215
+ expect(result).toEqual([]);
216
+ });
217
+
218
+ it('returns [] on network error', async () => {
219
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network failure')));
220
+ const result = await fetchGeneratedFlows(ENDPOINT, PRODUCT_ID, mockContext);
221
+ expect(result).toEqual([]);
222
+ });
223
+
224
+ it('never throws on any error', async () => {
225
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('crash')));
226
+ await expect(
227
+ fetchGeneratedFlows(ENDPOINT, PRODUCT_ID, mockContext),
228
+ ).resolves.toBeDefined();
229
+ });
230
+
231
+ it('returns [] when server returns missing flows key', async () => {
232
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
233
+ ok: true,
234
+ json: () => Promise.resolve({ something: 'else' }),
235
+ }));
236
+ const result = await fetchGeneratedFlows(ENDPOINT, PRODUCT_ID, mockContext);
237
+ expect(result).toEqual([]);
238
+ });
239
+
240
+ it('returns [] when server returns non-array flows', async () => {
241
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
242
+ ok: true,
243
+ json: () => Promise.resolve({ flows: 'not-an-array' }),
244
+ }));
245
+ const result = await fetchGeneratedFlows(ENDPOINT, PRODUCT_ID, mockContext);
246
+ expect(result).toEqual([]);
247
+ });
248
+
249
+ it('does NOT cache on server error', async () => {
250
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 503 }));
251
+ await fetchGeneratedFlows(ENDPOINT, PRODUCT_ID, mockContext);
252
+ expect(localStorage.getItem(cacheKey(PRODUCT_ID))).toBeNull();
253
+ });
254
+ });
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // mergeFlows
258
+ // ---------------------------------------------------------------------------
259
+
260
+ describe('Week 5 Day 3 — mergeFlows', () => {
261
+ it('appends generated flows after manual flows', () => {
262
+ const result = mergeFlows([manualFlow], [generatedFlow]);
263
+ expect(result[0].id).toBe('manual-flow-1');
264
+ expect(result[1].id).toBe('gen-flow-1');
265
+ });
266
+
267
+ it('skips generated flows whose id already exists in manual', () => {
268
+ const duplicate: FlowConfig = { ...generatedFlow, id: 'manual-flow-1' };
269
+ const result = mergeFlows([manualFlow], [duplicate]);
270
+ expect(result).toHaveLength(1);
271
+ expect(result[0].id).toBe('manual-flow-1');
272
+ });
273
+
274
+ it('returns manual unchanged when generated is empty', () => {
275
+ const result = mergeFlows([manualFlow], []);
276
+ expect(result).toEqual([manualFlow]);
277
+ });
278
+
279
+ it('returns generated when manual is empty', () => {
280
+ const result = mergeFlows([], [generatedFlow]);
281
+ expect(result).toEqual([generatedFlow]);
282
+ });
283
+
284
+ it('returns [] when both are empty', () => {
285
+ expect(mergeFlows([], [])).toEqual([]);
286
+ });
287
+
288
+ it('preserves order: all manual first, then novel generated', () => {
289
+ const manual1: FlowConfig = { ...manualFlow, id: 'm1' };
290
+ const manual2: FlowConfig = { ...manualFlow, id: 'm2' };
291
+ const gen1: FlowConfig = { ...generatedFlow, id: 'g1' };
292
+ const gen2: FlowConfig = { ...generatedFlow, id: 'g2' };
293
+
294
+ const result = mergeFlows([manual1, manual2], [gen1, gen2]);
295
+ expect(result.map((f) => f.id)).toEqual(['m1', 'm2', 'g1', 'g2']);
296
+ });
297
+ });