collavre 0.23.0 → 0.24.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.
- checksums.yaml +4 -4
- data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-en.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko.png +0 -0
- data/app/assets/stylesheets/collavre/comments_popup.css +40 -0
- data/app/assets/stylesheets/collavre/landing.css +92 -0
- data/app/assets/stylesheets/collavre/popup.css +4 -2
- data/app/controllers/collavre/admin/settings_controller.rb +52 -67
- data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
- data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
- data/app/controllers/collavre/attachments_controller.rb +13 -3
- data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
- data/app/controllers/collavre/creatives_controller.rb +32 -99
- data/app/controllers/collavre/inbox_items_controller.rb +10 -4
- data/app/controllers/collavre/landing_controller.rb +5 -2
- data/app/controllers/collavre/topics_controller.rb +5 -0
- data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
- data/app/errors/collavre/api_error.rb +20 -0
- data/app/helpers/collavre/creatives_helper.rb +30 -8
- data/app/javascript/components/InlineLexicalEditor.jsx +2 -34
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
- data/app/javascript/components/creative_tree_row.js +8 -2
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
- data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
- data/app/javascript/controllers/comments/topics_controller.js +47 -0
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/index.js +3 -0
- data/app/javascript/controllers/search_popup_controller.js +17 -1
- data/app/javascript/controllers/topic_list_controller.js +78 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
- data/app/javascript/lib/common_popup.js +24 -5
- data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
- data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
- data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
- data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
- data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
- data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
- data/app/javascript/modules/creative_inline_payload.js +86 -0
- data/app/javascript/modules/creative_row_editor.js +121 -401
- data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
- data/app/javascript/modules/creative_save_queue.js +95 -0
- data/app/javascript/modules/creative_tree_dom.js +260 -0
- data/app/jobs/collavre/permission_cache_job.rb +20 -2
- data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
- data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
- data/app/models/collavre/channel.rb +16 -0
- data/app/models/collavre/comment/approvable.rb +16 -0
- data/app/models/collavre/comment/notifiable.rb +1 -1
- data/app/models/collavre/comment.rb +8 -1
- data/app/models/collavre/comment_version.rb +1 -1
- data/app/models/collavre/creative/describable.rb +15 -9
- data/app/models/collavre/creative/linkable.rb +9 -0
- data/app/models/collavre/creative/permissible.rb +97 -49
- data/app/models/collavre/creative.rb +73 -5
- data/app/models/collavre/creative_share.rb +78 -26
- data/app/models/collavre/inbox_item.rb +2 -2
- data/app/models/collavre/task.rb +12 -0
- data/app/models/collavre/user.rb +33 -9
- data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
- data/app/services/collavre/agent_type_classifier.rb +23 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
- data/app/services/collavre/ai_agent/message_builder.rb +1 -1
- data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
- data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
- data/app/services/collavre/ai_client.rb +53 -2
- data/app/services/collavre/auto_theme_generator.rb +14 -10
- data/app/services/collavre/creatives/children_index.rb +86 -0
- data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
- data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
- data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
- data/app/services/collavre/creatives/permission_checker.rb +1 -1
- data/app/services/collavre/creatives/permission_filter.rb +144 -17
- data/app/services/collavre/creatives/tree_builder.rb +105 -77
- data/app/services/collavre/http_client.rb +119 -0
- data/app/services/collavre/link_preview_fetcher.rb +129 -38
- data/app/services/collavre/markdown_importer.rb +30 -6
- data/app/services/collavre/mobile/event_summarizer.rb +1 -1
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
- data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
- data/app/services/collavre/system_events/context_builder.rb +1 -14
- data/app/services/collavre/tools/preview_attach_service.rb +5 -7
- data/app/views/collavre/comments/_comment.html.erb +4 -4
- data/app/views/collavre/comments/_comments_popup.html.erb +11 -3
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
- data/app/views/collavre/landing/show.html.erb +52 -10
- data/config/locales/comments.en.yml +1 -0
- data/config/locales/comments.ko.yml +1 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +5 -0
- data/config/locales/landing.en.yml +22 -10
- data/config/locales/landing.ko.yml +22 -10
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
- data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
- data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
- data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
- data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
- data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
- data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
- data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
- data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
- data/lib/collavre/version.rb +1 -1
- metadata +42 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Characterization tests for the inline creative payload mapping cluster
|
|
3
|
+
* extracted from creative_row_editor.js (slice 3 of the god-file decomposition).
|
|
4
|
+
*
|
|
5
|
+
* These pin the CURRENT behavior of the inverse pair that drives instant UI:
|
|
6
|
+
* - inlinePayloadFromTree: reads a payload OUT of a rendered tree row's dataset
|
|
7
|
+
* - updateRowFromData: writes a server/API payload INTO a row's dataset
|
|
8
|
+
*
|
|
9
|
+
* The fixture mirrors the real creative-tree render: a `creative-tree-row`
|
|
10
|
+
* custom element wrapping a `.creative-tree` node whose `data-id`/`data-parent-id`
|
|
11
|
+
* carry the tree identity.
|
|
12
|
+
*
|
|
13
|
+
* Intentionally behavior-preserving: any surprising result below (e.g. missing
|
|
14
|
+
* fields defaulting to 0/'' rather than being omitted, or `hasOwnProperty`-gated
|
|
15
|
+
* writes) documents existing behavior, not an endorsement of it.
|
|
16
|
+
*/
|
|
17
|
+
import { inlinePayloadFromTree, updateRowFromData } from '../creative_inline_payload';
|
|
18
|
+
|
|
19
|
+
// Build one row: <creative-tree-row><div.creative-tree/></creative-tree-row>
|
|
20
|
+
function makeRow(id, { parentId = '', level = 1 } = {}) {
|
|
21
|
+
const row = document.createElement('creative-tree-row');
|
|
22
|
+
row.setAttribute('creative-id', String(id));
|
|
23
|
+
row.setAttribute('level', String(level));
|
|
24
|
+
const tree = document.createElement('div');
|
|
25
|
+
tree.className = 'creative-tree';
|
|
26
|
+
tree.id = `creative-${id}`;
|
|
27
|
+
tree.dataset.id = String(id);
|
|
28
|
+
tree.dataset.parentId = parentId;
|
|
29
|
+
tree.dataset.level = String(level);
|
|
30
|
+
row.appendChild(tree);
|
|
31
|
+
return { row, tree };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('inlinePayloadFromTree', () => {
|
|
35
|
+
test('returns null for falsy tree', () => {
|
|
36
|
+
expect(inlinePayloadFromTree(null)).toBeNull();
|
|
37
|
+
expect(inlinePayloadFromTree(undefined)).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('returns null when tree has no wrapping creative-tree-row', () => {
|
|
41
|
+
const orphan = document.createElement('div');
|
|
42
|
+
orphan.dataset.id = '5';
|
|
43
|
+
expect(inlinePayloadFromTree(orphan)).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('returns null when row exists but tree has no data-id', () => {
|
|
47
|
+
const { tree } = makeRow('9');
|
|
48
|
+
delete tree.dataset.id;
|
|
49
|
+
expect(inlinePayloadFromTree(tree)).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('builds a fully-defaulted payload from a minimal row (only id)', () => {
|
|
53
|
+
const { tree } = makeRow('42', { parentId: '' });
|
|
54
|
+
expect(inlinePayloadFromTree(tree)).toEqual({
|
|
55
|
+
id: '42',
|
|
56
|
+
description: '',
|
|
57
|
+
description_raw_html: '',
|
|
58
|
+
origin_id: '',
|
|
59
|
+
parent_id: '',
|
|
60
|
+
progress: 0,
|
|
61
|
+
content_type: null,
|
|
62
|
+
markdown_editor: null,
|
|
63
|
+
markdown_source: null,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('reads all fields from a fully-populated row', () => {
|
|
68
|
+
const { row, tree } = makeRow('7', { parentId: '3' });
|
|
69
|
+
row.dataset.descriptionHtml = '<p>hi</p>';
|
|
70
|
+
row.dataset.descriptionRawHtml = '<p>raw</p>';
|
|
71
|
+
row.dataset.progressValue = '55';
|
|
72
|
+
row.dataset.originId = '99';
|
|
73
|
+
row.dataset.contentType = 'markdown';
|
|
74
|
+
row.dataset.markdownEditor = 'rich';
|
|
75
|
+
row.dataset.markdownSource = '# hi';
|
|
76
|
+
expect(inlinePayloadFromTree(tree)).toEqual({
|
|
77
|
+
id: '7',
|
|
78
|
+
description: '<p>hi</p>',
|
|
79
|
+
description_raw_html: '<p>raw</p>',
|
|
80
|
+
origin_id: '99',
|
|
81
|
+
parent_id: '3',
|
|
82
|
+
progress: 55,
|
|
83
|
+
content_type: 'markdown',
|
|
84
|
+
markdown_editor: 'rich',
|
|
85
|
+
markdown_source: '# hi',
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('description falls back to raw html when descriptionHtml absent', () => {
|
|
90
|
+
const { row, tree } = makeRow('8');
|
|
91
|
+
row.dataset.descriptionRawHtml = '<p>only-raw</p>';
|
|
92
|
+
const payload = inlinePayloadFromTree(tree);
|
|
93
|
+
expect(payload.description).toBe('<p>only-raw</p>');
|
|
94
|
+
expect(payload.description_raw_html).toBe('<p>only-raw</p>');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('non-numeric progress coerces to 0 (NaN guard)', () => {
|
|
98
|
+
const { row, tree } = makeRow('11');
|
|
99
|
+
row.dataset.progressValue = 'abc';
|
|
100
|
+
expect(inlinePayloadFromTree(tree).progress).toBe(0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('progress present but empty-string reads as 0', () => {
|
|
104
|
+
const { row, tree } = makeRow('12');
|
|
105
|
+
row.dataset.progressValue = '';
|
|
106
|
+
// Number('' ?? 0) === Number('') === 0
|
|
107
|
+
expect(inlinePayloadFromTree(tree).progress).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('updateRowFromData', () => {
|
|
112
|
+
test('no-ops on falsy row or data', () => {
|
|
113
|
+
const { row } = makeRow('1');
|
|
114
|
+
expect(() => updateRowFromData(null, { description: 'x' })).not.toThrow();
|
|
115
|
+
expect(() => updateRowFromData(row, null)).not.toThrow();
|
|
116
|
+
expect(row.dataset.descriptionHtml).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('writes description + raw html (raw falls back to description)', () => {
|
|
120
|
+
const { row } = makeRow('1');
|
|
121
|
+
updateRowFromData(row, { description: '<p>d</p>' });
|
|
122
|
+
expect(row.descriptionHtml).toBe('<p>d</p>');
|
|
123
|
+
expect(row.dataset.descriptionHtml).toBe('<p>d</p>');
|
|
124
|
+
expect(row.dataset.descriptionRawHtml).toBe('<p>d</p>');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('explicit description_raw_html is preserved distinctly', () => {
|
|
128
|
+
const { row } = makeRow('1');
|
|
129
|
+
updateRowFromData(row, { description: '<p>d</p>', description_raw_html: '<p>r</p>' });
|
|
130
|
+
expect(row.dataset.descriptionHtml).toBe('<p>d</p>');
|
|
131
|
+
expect(row.dataset.descriptionRawHtml).toBe('<p>r</p>');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('missing description writes empty string (not skipped)', () => {
|
|
135
|
+
const { row } = makeRow('1');
|
|
136
|
+
updateRowFromData(row, { progress: 10 });
|
|
137
|
+
expect(row.dataset.descriptionHtml).toBe('');
|
|
138
|
+
expect(row.dataset.descriptionRawHtml).toBe('');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('progress_html only written when non-null', () => {
|
|
142
|
+
const { row } = makeRow('1');
|
|
143
|
+
updateRowFromData(row, { description: 'd', progress_html: '<b>50%</b>' });
|
|
144
|
+
expect(row.progressHtml).toBe('<b>50%</b>');
|
|
145
|
+
expect(row.dataset.progressHtml).toBe('<b>50%</b>');
|
|
146
|
+
|
|
147
|
+
const { row: row2 } = makeRow('2');
|
|
148
|
+
updateRowFromData(row2, { description: 'd', progress_html: null });
|
|
149
|
+
expect(row2.dataset.progressHtml).toBeUndefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('progress written from own key; null clears dataset value', () => {
|
|
153
|
+
const { row } = makeRow('1');
|
|
154
|
+
updateRowFromData(row, { description: 'd', progress: 30 });
|
|
155
|
+
expect(row.dataset.progressValue).toBe('30');
|
|
156
|
+
|
|
157
|
+
// `?? ''` maps null/undefined to '', and setRowDatasetValue deletes on ''? no:
|
|
158
|
+
// value '' is a string -> stored as ''. null -> '' -> stored as ''.
|
|
159
|
+
const { row: row2 } = makeRow('2');
|
|
160
|
+
updateRowFromData(row2, { description: 'd', progress: null });
|
|
161
|
+
expect(row2.dataset.progressValue).toBe('');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('hasOwnProperty gating: absent keys leave dataset untouched', () => {
|
|
165
|
+
const { row } = makeRow('1');
|
|
166
|
+
row.dataset.originId = 'pre';
|
|
167
|
+
row.dataset.contentType = 'pre';
|
|
168
|
+
updateRowFromData(row, { description: 'd' });
|
|
169
|
+
// origin_id / content_type not in payload -> not overwritten
|
|
170
|
+
expect(row.dataset.originId).toBe('pre');
|
|
171
|
+
expect(row.dataset.contentType).toBe('pre');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('origin_id / content_type / markdown_source / markdown_editor round-trip', () => {
|
|
175
|
+
const { row } = makeRow('1');
|
|
176
|
+
updateRowFromData(row, {
|
|
177
|
+
description: 'd',
|
|
178
|
+
origin_id: '77',
|
|
179
|
+
content_type: 'markdown',
|
|
180
|
+
markdown_source: '# s',
|
|
181
|
+
markdown_editor: 'rich',
|
|
182
|
+
});
|
|
183
|
+
expect(row.dataset.originId).toBe('77');
|
|
184
|
+
expect(row.dataset.contentType).toBe('markdown');
|
|
185
|
+
expect(row.dataset.markdownSource).toBe('# s');
|
|
186
|
+
expect(row.dataset.markdownEditor).toBe('rich');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('has_children truthy sets attribute + property; falsy removes', () => {
|
|
190
|
+
const { row } = makeRow('1');
|
|
191
|
+
updateRowFromData(row, { description: 'd', has_children: true });
|
|
192
|
+
expect(row.hasAttribute('has-children')).toBe(true);
|
|
193
|
+
expect(row.hasChildren).toBe(true);
|
|
194
|
+
|
|
195
|
+
updateRowFromData(row, { description: 'd', has_children: false });
|
|
196
|
+
expect(row.hasAttribute('has-children')).toBe(false);
|
|
197
|
+
expect(row.hasChildren).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('calls requestUpdate when the row exposes it', () => {
|
|
201
|
+
const { row } = makeRow('1');
|
|
202
|
+
let called = 0;
|
|
203
|
+
row.requestUpdate = () => { called += 1; };
|
|
204
|
+
updateRowFromData(row, { description: 'd' });
|
|
205
|
+
expect(called).toBe(1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('round-trip: updateRowFromData then inlinePayloadFromTree', () => {
|
|
209
|
+
const { row, tree } = makeRow('50', { parentId: '2' });
|
|
210
|
+
updateRowFromData(row, {
|
|
211
|
+
description: '<p>body</p>',
|
|
212
|
+
description_raw_html: '<p>raw</p>',
|
|
213
|
+
progress: 40,
|
|
214
|
+
origin_id: '5',
|
|
215
|
+
content_type: 'markdown',
|
|
216
|
+
markdown_source: '# md',
|
|
217
|
+
markdown_editor: 'source',
|
|
218
|
+
});
|
|
219
|
+
expect(inlinePayloadFromTree(tree)).toEqual({
|
|
220
|
+
id: '50',
|
|
221
|
+
description: '<p>body</p>',
|
|
222
|
+
description_raw_html: '<p>raw</p>',
|
|
223
|
+
origin_id: '5',
|
|
224
|
+
parent_id: '2',
|
|
225
|
+
progress: 40,
|
|
226
|
+
content_type: 'markdown',
|
|
227
|
+
markdown_editor: 'source',
|
|
228
|
+
markdown_source: '# md',
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { jest } from '@jest/globals';
|
|
5
|
+
import { CreativeSaveQueue } from '../creative_save_queue';
|
|
6
|
+
|
|
7
|
+
// A controllable timer harness so debounce behavior is deterministic without
|
|
8
|
+
// wall-clock waits. Mirrors setTimeout/clearTimeout semantics closely enough
|
|
9
|
+
// for the queue: fire() runs the most recently scheduled, uncleared callback.
|
|
10
|
+
function fakeTimers() {
|
|
11
|
+
const scheduled = new Map();
|
|
12
|
+
let nextId = 1;
|
|
13
|
+
return {
|
|
14
|
+
setTimer: (fn, ms) => {
|
|
15
|
+
const id = nextId++;
|
|
16
|
+
scheduled.set(id, { fn, ms });
|
|
17
|
+
return id;
|
|
18
|
+
},
|
|
19
|
+
clearTimer: (id) => {
|
|
20
|
+
scheduled.delete(id);
|
|
21
|
+
},
|
|
22
|
+
fire: (id) => {
|
|
23
|
+
const entry = scheduled.get(id);
|
|
24
|
+
if (!entry) throw new Error(`timer ${id} not scheduled (cleared or never set)`);
|
|
25
|
+
scheduled.delete(id);
|
|
26
|
+
entry.fn();
|
|
27
|
+
},
|
|
28
|
+
pending: () => scheduled.size,
|
|
29
|
+
lastMs: () => [...scheduled.values()].map((e) => e.ms).pop(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A save request whose settlement we control from the test.
|
|
34
|
+
function deferred() {
|
|
35
|
+
let resolve;
|
|
36
|
+
let reject;
|
|
37
|
+
const promise = new Promise((res, rej) => {
|
|
38
|
+
resolve = res;
|
|
39
|
+
reject = rej;
|
|
40
|
+
});
|
|
41
|
+
return { promise, resolve, reject };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('CreativeSaveQueue', () => {
|
|
45
|
+
describe('debounce (schedule / cancelTimer)', () => {
|
|
46
|
+
test('schedule arms a timer with the configured debounce', () => {
|
|
47
|
+
const timers = fakeTimers();
|
|
48
|
+
const q = new CreativeSaveQueue({ debounceMs: 5000, setTimer: timers.setTimer, clearTimer: timers.clearTimer });
|
|
49
|
+
const flush = jest.fn();
|
|
50
|
+
|
|
51
|
+
q.schedule(flush);
|
|
52
|
+
|
|
53
|
+
expect(timers.pending()).toBe(1);
|
|
54
|
+
expect(timers.lastMs()).toBe(5000);
|
|
55
|
+
expect(flush).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('flush runs when the timer fires', () => {
|
|
59
|
+
const timers = fakeTimers();
|
|
60
|
+
const q = new CreativeSaveQueue({ setTimer: timers.setTimer, clearTimer: timers.clearTimer });
|
|
61
|
+
const flush = jest.fn();
|
|
62
|
+
|
|
63
|
+
q.schedule(flush);
|
|
64
|
+
timers.fire(1);
|
|
65
|
+
|
|
66
|
+
expect(flush).toHaveBeenCalledTimes(1);
|
|
67
|
+
expect(timers.pending()).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('re-scheduling cancels the previous timer so only the latest fires', () => {
|
|
71
|
+
const timers = fakeTimers();
|
|
72
|
+
const q = new CreativeSaveQueue({ setTimer: timers.setTimer, clearTimer: timers.clearTimer });
|
|
73
|
+
const first = jest.fn();
|
|
74
|
+
const second = jest.fn();
|
|
75
|
+
|
|
76
|
+
q.schedule(first);
|
|
77
|
+
q.schedule(second);
|
|
78
|
+
|
|
79
|
+
// Only one timer is live; the first was cleared.
|
|
80
|
+
expect(timers.pending()).toBe(1);
|
|
81
|
+
expect(() => timers.fire(1)).toThrow(); // first timer id was cleared
|
|
82
|
+
timers.fire(2);
|
|
83
|
+
expect(first).not.toHaveBeenCalled();
|
|
84
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('default timers (no injection) schedule against the real timer API without illegal-invocation', () => {
|
|
88
|
+
jest.useFakeTimers();
|
|
89
|
+
try {
|
|
90
|
+
// No setTimer/clearTimer injected — exercises the window-bound wrappers.
|
|
91
|
+
// A regression that called setTimeout as a method of the queue threw
|
|
92
|
+
// "Illegal invocation" in browsers; this guards the default path.
|
|
93
|
+
const q = new CreativeSaveQueue({ debounceMs: 5000 });
|
|
94
|
+
const flush = jest.fn();
|
|
95
|
+
|
|
96
|
+
expect(() => q.schedule(flush)).not.toThrow();
|
|
97
|
+
expect(flush).not.toHaveBeenCalled();
|
|
98
|
+
jest.advanceTimersByTime(5000);
|
|
99
|
+
expect(flush).toHaveBeenCalledTimes(1);
|
|
100
|
+
|
|
101
|
+
// cancelTimer against the real timer API must also not throw.
|
|
102
|
+
q.schedule(flush);
|
|
103
|
+
expect(() => q.cancelTimer()).not.toThrow();
|
|
104
|
+
jest.advanceTimersByTime(5000);
|
|
105
|
+
expect(flush).toHaveBeenCalledTimes(1); // canceled — no second flush
|
|
106
|
+
} finally {
|
|
107
|
+
jest.useRealTimers();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('cancelTimer prevents a scheduled flush and is idempotent', () => {
|
|
112
|
+
const timers = fakeTimers();
|
|
113
|
+
const q = new CreativeSaveQueue({ setTimer: timers.setTimer, clearTimer: timers.clearTimer });
|
|
114
|
+
const flush = jest.fn();
|
|
115
|
+
|
|
116
|
+
q.schedule(flush);
|
|
117
|
+
q.cancelTimer();
|
|
118
|
+
q.cancelTimer(); // no throw on second call
|
|
119
|
+
|
|
120
|
+
expect(timers.pending()).toBe(0);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('single-flight (runExclusive)', () => {
|
|
125
|
+
test('runs execute and tracks saving state across the request lifecycle', async () => {
|
|
126
|
+
const q = new CreativeSaveQueue();
|
|
127
|
+
const d = deferred();
|
|
128
|
+
const execute = jest.fn(() => d.promise);
|
|
129
|
+
|
|
130
|
+
expect(q.saving).toBe(false);
|
|
131
|
+
const p = q.runExclusive(execute);
|
|
132
|
+
|
|
133
|
+
expect(execute).toHaveBeenCalledTimes(1);
|
|
134
|
+
expect(q.saving).toBe(true);
|
|
135
|
+
expect(q.promise).toBe(p);
|
|
136
|
+
|
|
137
|
+
d.resolve('ok');
|
|
138
|
+
await p;
|
|
139
|
+
expect(q.saving).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('coalesces: a second call while saving returns the in-flight promise without re-running execute', async () => {
|
|
143
|
+
const q = new CreativeSaveQueue();
|
|
144
|
+
const d = deferred();
|
|
145
|
+
const execute = jest.fn(() => d.promise);
|
|
146
|
+
const second = jest.fn(() => deferred().promise);
|
|
147
|
+
|
|
148
|
+
const p1 = q.runExclusive(execute);
|
|
149
|
+
const p2 = q.runExclusive(second);
|
|
150
|
+
|
|
151
|
+
expect(p2).toBe(p1); // same in-flight promise
|
|
152
|
+
expect(second).not.toHaveBeenCalled(); // no double save
|
|
153
|
+
expect(execute).toHaveBeenCalledTimes(1);
|
|
154
|
+
|
|
155
|
+
d.resolve();
|
|
156
|
+
await p1;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('a fresh save runs after the previous one settles', async () => {
|
|
160
|
+
const q = new CreativeSaveQueue();
|
|
161
|
+
const d1 = deferred();
|
|
162
|
+
const execute1 = jest.fn(() => d1.promise);
|
|
163
|
+
const d2 = deferred();
|
|
164
|
+
const execute2 = jest.fn(() => d2.promise);
|
|
165
|
+
|
|
166
|
+
const p1 = q.runExclusive(execute1);
|
|
167
|
+
d1.resolve();
|
|
168
|
+
await p1;
|
|
169
|
+
expect(q.saving).toBe(false);
|
|
170
|
+
|
|
171
|
+
q.runExclusive(execute2);
|
|
172
|
+
expect(execute2).toHaveBeenCalledTimes(1);
|
|
173
|
+
expect(q.saving).toBe(true);
|
|
174
|
+
d2.resolve();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('execute returning null (nothing to persist) does not enter the saving state', async () => {
|
|
178
|
+
const q = new CreativeSaveQueue();
|
|
179
|
+
const execute = jest.fn(() => null);
|
|
180
|
+
|
|
181
|
+
const p = q.runExclusive(execute);
|
|
182
|
+
|
|
183
|
+
expect(execute).toHaveBeenCalledTimes(1);
|
|
184
|
+
expect(q.saving).toBe(false);
|
|
185
|
+
await expect(p).resolves.toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('runExclusive cancels a pending debounce timer before saving', () => {
|
|
189
|
+
const timers = fakeTimers();
|
|
190
|
+
const q = new CreativeSaveQueue({ setTimer: timers.setTimer, clearTimer: timers.clearTimer });
|
|
191
|
+
const flush = jest.fn();
|
|
192
|
+
|
|
193
|
+
q.schedule(flush);
|
|
194
|
+
expect(timers.pending()).toBe(1);
|
|
195
|
+
|
|
196
|
+
q.runExclusive(() => deferred().promise);
|
|
197
|
+
|
|
198
|
+
expect(timers.pending()).toBe(0); // debounce canceled — no redundant trailing save
|
|
199
|
+
expect(flush).not.toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('clears saving even when the request rejects', async () => {
|
|
203
|
+
const q = new CreativeSaveQueue();
|
|
204
|
+
const d = deferred();
|
|
205
|
+
const execute = jest.fn(() => d.promise);
|
|
206
|
+
|
|
207
|
+
const p = q.runExclusive(execute);
|
|
208
|
+
expect(q.saving).toBe(true);
|
|
209
|
+
|
|
210
|
+
d.reject(new Error('boom'));
|
|
211
|
+
await expect(p).rejects.toThrow('boom');
|
|
212
|
+
expect(q.saving).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('after a rejection a subsequent save can run', async () => {
|
|
216
|
+
const q = new CreativeSaveQueue();
|
|
217
|
+
const d1 = deferred();
|
|
218
|
+
const p1 = q.runExclusive(() => d1.promise);
|
|
219
|
+
d1.reject(new Error('fail'));
|
|
220
|
+
await expect(p1).rejects.toThrow();
|
|
221
|
+
|
|
222
|
+
const execute2 = jest.fn(() => deferred().promise);
|
|
223
|
+
q.runExclusive(execute2);
|
|
224
|
+
expect(execute2).toHaveBeenCalledTimes(1);
|
|
225
|
+
expect(q.saving).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
});
|