collavre 0.22.0 → 0.23.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/README.md +1 -0
- data/app/assets/stylesheets/collavre/actiontext.css +251 -90
- data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
- data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
- data/app/assets/stylesheets/collavre/creatives.css +11 -2
- data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
- data/app/assets/stylesheets/collavre/tables.css +91 -0
- data/app/channels/collavre/inbox_badge_channel.rb +30 -0
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
- data/app/controllers/collavre/creatives_controller.rb +16 -5
- data/app/controllers/collavre/tasks_controller.rb +13 -4
- data/app/controllers/collavre/topics_controller.rb +49 -1
- data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
- data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/javascript/collavre.js +2 -0
- data/app/javascript/components/ImageResizer.jsx +9 -3
- data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
- data/app/javascript/components/creative_tree_row.js +20 -3
- data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
- data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
- data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
- data/app/javascript/controllers/comment_controller.js +5 -4
- data/app/javascript/controllers/comment_version_controller.js +2 -1
- data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
- data/app/javascript/controllers/comments/form_controller.js +21 -5
- data/app/javascript/controllers/comments/list_controller.js +18 -17
- data/app/javascript/controllers/comments/presence_controller.js +2 -1
- data/app/javascript/controllers/comments/topics_controller.js +14 -8
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
- data/app/javascript/controllers/creatives/import_controller.js +2 -1
- data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
- data/app/javascript/controllers/creatives/tree_controller.js +142 -1
- data/app/javascript/controllers/image_lightbox_controller.js +2 -1
- data/app/javascript/controllers/inbox_badge_controller.js +33 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/share_modal_controller.js +4 -3
- data/app/javascript/controllers/topic_search_controller.js +2 -1
- data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
- data/app/javascript/creatives/topic_move_members_popup.js +156 -0
- data/app/javascript/creatives/tree_renderer.js +11 -0
- data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
- data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
- data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
- data/app/javascript/lib/api/api_error.js +108 -0
- data/app/javascript/lib/api/queue_manager.js +38 -4
- data/app/javascript/lib/common_popup.js +18 -5
- data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
- data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
- data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
- data/app/javascript/lib/editor/code_languages.js +173 -0
- data/app/javascript/lib/editor/code_token_theme.js +41 -0
- data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
- data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
- data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
- data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
- data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
- data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
- data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
- data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
- data/app/javascript/lib/lexical/selection_boundary.js +58 -0
- data/app/javascript/lib/lexical/table_transformer.js +182 -0
- data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
- data/app/javascript/lib/turbo_confirm.js +46 -0
- data/app/javascript/lib/typo_correction.js +146 -0
- data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
- data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
- data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
- data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
- data/app/javascript/lib/utils/confirm_dialog.js +10 -0
- data/app/javascript/lib/utils/dialog.js +300 -0
- data/app/javascript/lib/utils/markdown.js +154 -67
- data/app/javascript/lib/utils/sanitize_description.js +31 -0
- data/app/javascript/lib/utils/table_download.js +15 -0
- data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
- data/app/javascript/modules/creative_row_editor.js +110 -70
- data/app/javascript/modules/export_to_markdown.js +2 -1
- data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
- data/app/javascript/modules/slide_view.js +11 -2
- data/app/javascript/modules/typo_corrector.js +534 -0
- data/app/jobs/collavre/ai_agent_job.rb +7 -4
- data/app/jobs/collavre/compress_job.rb +6 -2
- data/app/models/collavre/comment/broadcastable.rb +46 -7
- data/app/models/collavre/comment/notifiable.rb +14 -4
- data/app/models/collavre/comment.rb +79 -31
- data/app/models/collavre/creative/describable.rb +89 -10
- data/app/models/collavre/task.rb +15 -0
- data/app/models/collavre/user.rb +57 -1
- data/app/services/collavre/ai_client.rb +28 -10
- data/app/services/collavre/auto_theme_generator.rb +1 -1
- data/app/services/collavre/creatives/index_query.rb +85 -16
- data/app/services/collavre/creatives/tree_builder.rb +2 -1
- data/app/services/collavre/gemini_parent_recommender.rb +1 -1
- data/app/services/collavre/inbox_reply_service.rb +5 -0
- data/app/services/collavre/markdown_converter.rb +13 -3
- data/app/services/collavre/mobile/event_summarizer.rb +40 -0
- data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
- data/app/services/collavre/orchestration/arbiter.rb +16 -0
- data/app/services/collavre/orchestration/matcher.rb +79 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
- data/app/services/collavre/tools/creative_batch_service.rb +3 -2
- data/app/services/collavre/tools/creative_create_service.rb +8 -8
- data/app/services/collavre/tools/creative_update_service.rb +23 -8
- data/app/services/collavre/typo_corrector.rb +188 -0
- data/app/views/collavre/comments/_comment.html.erb +5 -0
- data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
- data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
- data/app/views/collavre/creatives/index.html.erb +14 -1
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/users/show.html.erb +3 -0
- data/app/views/collavre/users/typo_correction.html.erb +50 -0
- data/app/views/layouts/collavre/slide.html.erb +1 -0
- data/config/locales/comments.en.yml +15 -0
- data/config/locales/comments.ko.yml +15 -0
- data/config/locales/integrations.en.yml +1 -1
- data/config/locales/integrations.ko.yml +1 -1
- data/config/locales/mobile.en.yml +16 -0
- data/config/locales/mobile.ko.yml +16 -0
- data/config/locales/orchestration.en.yml +1 -0
- data/config/locales/orchestration.ko.yml +1 -0
- data/config/locales/users.en.yml +15 -0
- data/config/locales/users.ko.yml +15 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
- data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
- data/db/seeds.rb +51 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/generators/collavre/install/install_generator.rb +1 -0
- metadata +55 -2
- data/app/services/collavre/tools/description_normalizable.rb +0 -16
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { TypoCorrector, initTypoCorrector } from '../typo_corrector'
|
|
5
|
+
|
|
6
|
+
// Exercises the DOM orchestration that the pure-logic tests don't reach:
|
|
7
|
+
// auto-applying high-confidence edits into the textarea (caret preserved),
|
|
8
|
+
// painting the two highlight states onto the backdrop, and resolving an edit
|
|
9
|
+
// through _chooseValue. Network is bypassed by calling _applyResult directly.
|
|
10
|
+
|
|
11
|
+
const settings = {
|
|
12
|
+
enabled: true,
|
|
13
|
+
threshold: 80,
|
|
14
|
+
onVoice: true,
|
|
15
|
+
onSoftKeyboard: true,
|
|
16
|
+
onPhysicalKeyboard: false,
|
|
17
|
+
inChat: true,
|
|
18
|
+
inEditor: false,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function mount(value) {
|
|
22
|
+
document.body.innerHTML = '<form><textarea>' + value + '</textarea></form>'
|
|
23
|
+
const textarea = document.querySelector('textarea')
|
|
24
|
+
textarea.value = value
|
|
25
|
+
const tc = new TypoCorrector(textarea, { settings })
|
|
26
|
+
return { textarea, tc }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('TypoCorrector DOM orchestration', () => {
|
|
30
|
+
afterEach(() => { document.body.innerHTML = '' })
|
|
31
|
+
|
|
32
|
+
test('wraps textarea in a backdrop overlay', () => {
|
|
33
|
+
const { textarea } = mount('hello')
|
|
34
|
+
expect(textarea.parentNode.classList.contains('typo-input-wrap')).toBe(true)
|
|
35
|
+
expect(textarea.parentNode.querySelector('.typo-highlights')).not.toBeNull()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('auto-applies a high-confidence edit into the textarea value', () => {
|
|
39
|
+
const { textarea, tc } = mount('잇습니다 그리고')
|
|
40
|
+
tc._applyResult({
|
|
41
|
+
edits: [{ original: '잇습니다', suggestion: '있습니다', confidence: 0.95 }],
|
|
42
|
+
threshold: 80,
|
|
43
|
+
})
|
|
44
|
+
expect(textarea.value).toBe('있습니다 그리고')
|
|
45
|
+
// The applied edit is highlighted with the "applied" (resolved) state.
|
|
46
|
+
const mark = textarea.parentNode.querySelector('.typo-mark-applied')
|
|
47
|
+
expect(mark).not.toBeNull()
|
|
48
|
+
expect(mark.textContent).toBe('있습니다')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('low-confidence edit becomes a candidate highlight, text untouched', () => {
|
|
52
|
+
const { textarea, tc } = mount('teh cat')
|
|
53
|
+
tc._applyResult({
|
|
54
|
+
edits: [{ original: 'teh', suggestion: 'the', confidence: 0.4 }],
|
|
55
|
+
threshold: 80,
|
|
56
|
+
})
|
|
57
|
+
expect(textarea.value).toBe('teh cat') // not auto-applied
|
|
58
|
+
const mark = textarea.parentNode.querySelector('.typo-mark-candidate')
|
|
59
|
+
expect(mark).not.toBeNull()
|
|
60
|
+
expect(mark.textContent).toBe('teh')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('choosing the suggestion applies it and clears the highlight', () => {
|
|
64
|
+
const { textarea, tc } = mount('teh cat')
|
|
65
|
+
tc._applyResult({
|
|
66
|
+
edits: [{ original: 'teh', suggestion: 'the', confidence: 0.4 }],
|
|
67
|
+
threshold: 80,
|
|
68
|
+
})
|
|
69
|
+
const edit = tc.edits[0]
|
|
70
|
+
tc._ensurePopup()
|
|
71
|
+
tc._activeEdit = edit
|
|
72
|
+
tc._chooseValue('the')
|
|
73
|
+
expect(textarea.value).toBe('the cat')
|
|
74
|
+
expect(textarea.parentNode.querySelector('.typo-mark')).toBeNull()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('keeping (current value) just clears the highlight without changing text', () => {
|
|
78
|
+
const { textarea, tc } = mount('teh cat')
|
|
79
|
+
tc._applyResult({
|
|
80
|
+
edits: [{ original: 'teh', suggestion: 'the', confidence: 0.4 }],
|
|
81
|
+
threshold: 80,
|
|
82
|
+
})
|
|
83
|
+
const edit = tc.edits[0]
|
|
84
|
+
tc._ensurePopup()
|
|
85
|
+
tc._activeEdit = edit
|
|
86
|
+
tc._chooseValue('teh') // keep
|
|
87
|
+
expect(textarea.value).toBe('teh cat')
|
|
88
|
+
expect(tc.edits).toHaveLength(0)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('typing past a highlighted span drops the stale highlight on repaint', () => {
|
|
92
|
+
const { textarea, tc } = mount('teh cat')
|
|
93
|
+
tc._applyResult({
|
|
94
|
+
edits: [{ original: 'teh', suggestion: 'the', confidence: 0.4 }],
|
|
95
|
+
threshold: 80,
|
|
96
|
+
})
|
|
97
|
+
expect(tc.edits).toHaveLength(1)
|
|
98
|
+
textarea.value = 'text cat' // user edited the span
|
|
99
|
+
tc._repaint()
|
|
100
|
+
expect(tc.edits).toHaveLength(0)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('device classification uses the input-time stamp, surviving the debounce (P1)', () => {
|
|
104
|
+
// Before the fix, the device was classified at detect time (after the 600ms
|
|
105
|
+
// debounce), so the keydown→detect gap read ~600ms and every physical
|
|
106
|
+
// keypress was misread as a soft keyboard — bypassing the default-off gate.
|
|
107
|
+
const realNow = performance.now
|
|
108
|
+
let t = 1000
|
|
109
|
+
performance.now = () => t
|
|
110
|
+
try {
|
|
111
|
+
const { textarea, tc } = mount('')
|
|
112
|
+
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })) // physical keydown @1000
|
|
113
|
+
t = 1002
|
|
114
|
+
textarea.value = 'a'
|
|
115
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true })) // input @1002 → lastInputAt
|
|
116
|
+
t = 1700 // debounce fires ~700ms later, when _detect calls _currentDevice
|
|
117
|
+
expect(tc._currentDevice()).toBe('physical_keyboard')
|
|
118
|
+
} finally {
|
|
119
|
+
performance.now = realNow
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('auto-applied edit anchors to the corrected span, not an earlier twin (P2)', () => {
|
|
124
|
+
// "the teh" → correct the 2nd word. The highlight/undo must bind the second
|
|
125
|
+
// word (offset 4), not the pre-existing "the" at offset 0.
|
|
126
|
+
const { textarea, tc } = mount('the teh')
|
|
127
|
+
tc._applyResult({
|
|
128
|
+
edits: [{ original: 'teh', suggestion: 'the', confidence: 0.95, offset: 4 }],
|
|
129
|
+
threshold: 80,
|
|
130
|
+
})
|
|
131
|
+
expect(textarea.value).toBe('the the')
|
|
132
|
+
const edit = tc.edits.find((e) => e.state === 'applied')
|
|
133
|
+
expect(edit.start).toBe(4)
|
|
134
|
+
expect(edit.end).toBe(7)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('popup renders option labels as text, never as HTML (DOM-XSS safe)', () => {
|
|
138
|
+
const { tc } = mount('teh cat')
|
|
139
|
+
tc._applyResult({ edits: [{ original: 'teh', suggestion: 'the', confidence: 0.4 }], threshold: 80 })
|
|
140
|
+
tc._ensurePopup()
|
|
141
|
+
tc._activeEdit = tc.edits[0]
|
|
142
|
+
const payload = '<img src=x onerror=alert(1)>'
|
|
143
|
+
tc.popup.setItems([{ value: payload, label: payload, role: 'custom' }])
|
|
144
|
+
const list = tc.popupEl.querySelector('.typo-popup-list')
|
|
145
|
+
expect(list.querySelector('img')).toBeNull() // not parsed as markup
|
|
146
|
+
expect(list.textContent).toContain(payload) // shown verbatim as text
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('undo of an auto-applied edit rewrites the corrected span, not an earlier twin (P2)', () => {
|
|
150
|
+
const { textarea, tc } = mount('the teh')
|
|
151
|
+
tc._applyResult({
|
|
152
|
+
edits: [{ original: 'teh', suggestion: 'the', confidence: 0.95, offset: 4 }],
|
|
153
|
+
threshold: 80,
|
|
154
|
+
})
|
|
155
|
+
const edit = tc.edits.find((e) => e.state === 'applied')
|
|
156
|
+
tc._ensurePopup()
|
|
157
|
+
tc._activeEdit = edit
|
|
158
|
+
tc._chooseValue('teh') // undo back to the original
|
|
159
|
+
expect(textarea.value).toBe('the teh')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('auto-apply does not re-trigger detection, so the undo affordance survives (P2)', () => {
|
|
163
|
+
const { textarea, tc } = mount('잇습니다')
|
|
164
|
+
let detectCalls = 0
|
|
165
|
+
tc._scheduleDetect = () => { detectCalls += 1 }
|
|
166
|
+
tc._applyResult({
|
|
167
|
+
edits: [{ original: '잇습니다', suggestion: '있습니다', confidence: 0.95 }],
|
|
168
|
+
threshold: 80,
|
|
169
|
+
})
|
|
170
|
+
// The synthetic `input` event from our own auto-apply must be swallowed: a
|
|
171
|
+
// re-detect would return [] on the now-clean text and wipe the highlight.
|
|
172
|
+
expect(detectCalls).toBe(0)
|
|
173
|
+
expect(textarea.parentNode.querySelector('.typo-mark-applied')).not.toBeNull()
|
|
174
|
+
|
|
175
|
+
// A *real* keystroke afterwards still schedules detection.
|
|
176
|
+
textarea.value = '있습니다 어쩌구'
|
|
177
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
|
178
|
+
expect(detectCalls).toBe(1)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('clicking a mark opens the popup and the opening mousedown does not close it', () => {
|
|
182
|
+
// Regression: the popup opens on a mousedown. CommonPopup.showAt registered
|
|
183
|
+
// its document-level outside-click mousedown listener synchronously, so the
|
|
184
|
+
// very mousedown that opened the popup kept bubbling to document, hit that
|
|
185
|
+
// listener (target = the mark, outside the popup), and called hide() — the
|
|
186
|
+
// popup opened and instantly closed, so the user never saw it.
|
|
187
|
+
const { textarea, tc } = mount('teh cat')
|
|
188
|
+
tc._applyResult({
|
|
189
|
+
edits: [{ original: 'teh', suggestion: 'the', confidence: 0.4 }],
|
|
190
|
+
threshold: 80,
|
|
191
|
+
})
|
|
192
|
+
const mark = textarea.parentNode.querySelector('.typo-mark-candidate')
|
|
193
|
+
expect(mark).not.toBeNull()
|
|
194
|
+
mark.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
|
195
|
+
expect(tc.popup.isOpen()).toBe(true)
|
|
196
|
+
expect(tc.popupEl.style.display).toBe('block')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('a mark re-opens the popup on a second click while it is still open', () => {
|
|
200
|
+
// Regression: showAt registers the document outside-click listener one frame
|
|
201
|
+
// after opening. Clicking the (still-open) mark again sends a mousedown that
|
|
202
|
+
// bubbles to that live listener, which hid the popup — and showAt's fresh rAF
|
|
203
|
+
// only re-flips visibility, never display, so the popup stayed display:none
|
|
204
|
+
// and every later click repeated the cycle (permanently dead after click 1).
|
|
205
|
+
// Use queued rAF so the listener registers AFTER the opening mousedown, as in
|
|
206
|
+
// a real browser.
|
|
207
|
+
const rafs = []
|
|
208
|
+
const realRaf = window.requestAnimationFrame
|
|
209
|
+
window.requestAnimationFrame = (cb) => { rafs.push(cb); return rafs.length }
|
|
210
|
+
const flush = () => rafs.splice(0).forEach((cb) => cb())
|
|
211
|
+
try {
|
|
212
|
+
const { textarea, tc } = mount('teh cat')
|
|
213
|
+
tc._applyResult({
|
|
214
|
+
edits: [{ original: 'teh', suggestion: 'the', confidence: 0.4 }],
|
|
215
|
+
threshold: 80,
|
|
216
|
+
})
|
|
217
|
+
const mark = () => textarea.parentNode.querySelector('.typo-mark-candidate')
|
|
218
|
+
mark().dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
|
219
|
+
flush() // showAt's rAF registers the outside-click listener
|
|
220
|
+
expect(tc.popup.isOpen()).toBe(true)
|
|
221
|
+
|
|
222
|
+
mark().dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
|
223
|
+
flush()
|
|
224
|
+
expect(tc.popup.isOpen()).toBe(true)
|
|
225
|
+
} finally {
|
|
226
|
+
window.requestAnimationFrame = realRaf
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('opening the popup focuses and selects the input (desktop) for instant replace', () => {
|
|
231
|
+
// showAt keeps the popup visibility:hidden until its own rAF, and a hidden
|
|
232
|
+
// element is not focusable — so focus/select are deferred into a rAF that
|
|
233
|
+
// runs after showAt's. Flush rAF synchronously to exercise that path.
|
|
234
|
+
const realRaf = window.requestAnimationFrame
|
|
235
|
+
window.requestAnimationFrame = (cb) => { cb(); return 0 }
|
|
236
|
+
try {
|
|
237
|
+
const { textarea, tc } = mount('teh cat')
|
|
238
|
+
tc._applyResult({
|
|
239
|
+
edits: [{ original: 'teh', suggestion: 'the', confidence: 0.4 }],
|
|
240
|
+
threshold: 80,
|
|
241
|
+
})
|
|
242
|
+
const mark = textarea.parentNode.querySelector('.typo-mark-candidate')
|
|
243
|
+
mark.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
|
244
|
+
expect(document.activeElement).toBe(tc.popupInput)
|
|
245
|
+
expect(tc.popupInput.value).toBe('teh') // current word, pre-filled
|
|
246
|
+
} finally {
|
|
247
|
+
window.requestAnimationFrame = realRaf
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('popup input carries a localized aria-label', () => {
|
|
252
|
+
document.body.innerHTML = '<form><textarea></textarea></form>'
|
|
253
|
+
const textarea = document.querySelector('textarea')
|
|
254
|
+
const tc = new TypoCorrector(textarea, { settings, labels: { inputLabel: '교정' } })
|
|
255
|
+
tc._ensurePopup()
|
|
256
|
+
expect(tc.popupInput.getAttribute('aria-label')).toBe('교정')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('form reset clears stale marks off the now-empty composer', () => {
|
|
260
|
+
// form.reset() empties the textarea without an `input` event, so without the
|
|
261
|
+
// reset listener the previous draft's marks would linger and stay clickable.
|
|
262
|
+
const realRaf = window.requestAnimationFrame
|
|
263
|
+
window.requestAnimationFrame = (cb) => { cb(); return 0 }
|
|
264
|
+
try {
|
|
265
|
+
document.body.innerHTML = '<form><textarea></textarea></form>'
|
|
266
|
+
const form = document.querySelector('form')
|
|
267
|
+
const textarea = document.querySelector('textarea')
|
|
268
|
+
const tc = new TypoCorrector(textarea, { settings })
|
|
269
|
+
textarea.value = 'teh cat'
|
|
270
|
+
tc._applyResult({ edits: [{ original: 'teh', suggestion: 'the', confidence: 0.4 }], threshold: 80 })
|
|
271
|
+
expect(textarea.parentNode.querySelector('.typo-mark')).not.toBeNull()
|
|
272
|
+
|
|
273
|
+
form.reset()
|
|
274
|
+
expect(tc.edits).toHaveLength(0)
|
|
275
|
+
expect(textarea.parentNode.querySelector('.typo-mark')).toBeNull()
|
|
276
|
+
} finally {
|
|
277
|
+
window.requestAnimationFrame = realRaf
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test('destroy unwraps the textarea and clears the bind marker (Turbo before-cache)', () => {
|
|
282
|
+
// Turbo caches the DOM but not JS listeners; the snapshot must not keep the
|
|
283
|
+
// injected backdrop or typoBound, or the restored page would duplicate the
|
|
284
|
+
// overlay / skip re-init and leave correction dead until a full reload.
|
|
285
|
+
document.body.innerHTML = '<form><textarea></textarea></form>'
|
|
286
|
+
const form = document.querySelector('form')
|
|
287
|
+
const textarea = document.querySelector('textarea')
|
|
288
|
+
textarea.dataset.typoBound = 'true'
|
|
289
|
+
const tc = new TypoCorrector(textarea, { settings })
|
|
290
|
+
expect(textarea.parentNode.classList.contains('typo-input-wrap')).toBe(true)
|
|
291
|
+
|
|
292
|
+
// The combobox popup lives on document.body, so destroy() must remove it too;
|
|
293
|
+
// otherwise the Turbo snapshot serializes an orphan popup and the next
|
|
294
|
+
// corrector appends a duplicate.
|
|
295
|
+
tc._ensurePopup()
|
|
296
|
+
expect(document.body.querySelector('.typo-popup')).not.toBeNull()
|
|
297
|
+
|
|
298
|
+
tc.destroy()
|
|
299
|
+
expect(textarea.dataset.typoBound).toBeUndefined()
|
|
300
|
+
expect(textarea.parentNode).toBe(form) // unwrapped back to the form
|
|
301
|
+
expect(form.querySelector('.typo-input-wrap')).toBeNull()
|
|
302
|
+
expect(form.querySelector('.typo-backdrop')).toBeNull()
|
|
303
|
+
expect(document.body.querySelector('.typo-popup')).toBeNull()
|
|
304
|
+
expect(tc.popupEl).toBeNull()
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('earlier corrections stay clickable across detection rounds (not just the last)', () => {
|
|
308
|
+
// Each detection round used to replace this.edits wholesale, and the server
|
|
309
|
+
// is stateless — it never re-reports an already-corrected word. So a second
|
|
310
|
+
// round (triggered by typing more) wiped the first correction's undo mark,
|
|
311
|
+
// leaving only the most recent correction clickable. Earlier corrections must
|
|
312
|
+
// keep their mark so the user can still undo them.
|
|
313
|
+
const { textarea, tc } = mount('잇습니다 hello')
|
|
314
|
+
|
|
315
|
+
// Round 1: auto-correct 잇습니다 → 있습니다 (high confidence).
|
|
316
|
+
tc._applyResult({
|
|
317
|
+
edits: [{ original: '잇습니다', suggestion: '있습니다', confidence: 0.95 }],
|
|
318
|
+
threshold: 80,
|
|
319
|
+
})
|
|
320
|
+
expect(textarea.value).toBe('있습니다 hello')
|
|
321
|
+
expect(textarea.parentNode.querySelectorAll('.typo-mark')).toHaveLength(1)
|
|
322
|
+
|
|
323
|
+
// User types another typo; a fresh detection round returns only the NEW word.
|
|
324
|
+
textarea.value = '있습니다 hello teh'
|
|
325
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
|
326
|
+
tc._applyResult({
|
|
327
|
+
edits: [{ original: 'teh', suggestion: 'the', confidence: 0.95 }],
|
|
328
|
+
threshold: 80,
|
|
329
|
+
})
|
|
330
|
+
expect(textarea.value).toBe('있습니다 hello the')
|
|
331
|
+
|
|
332
|
+
// Both corrections must be present and anchored to their own word.
|
|
333
|
+
const marks = [...textarea.parentNode.querySelectorAll('.typo-mark')]
|
|
334
|
+
expect(marks.map((m) => m.textContent).sort()).toEqual(['the', '있습니다'])
|
|
335
|
+
// And each mark resolves to its own edit (not all pointing at the last one).
|
|
336
|
+
const byWord = Object.fromEntries(tc.edits.map((e) => [e.currentValue, e]))
|
|
337
|
+
expect(textarea.value.slice(byWord['있습니다'].start, byWord['있습니다'].end)).toBe('있습니다')
|
|
338
|
+
expect(textarea.value.slice(byWord['the'].start, byWord['the'].end)).toBe('the')
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
describe('initTypoCorrector endpoint wiring', () => {
|
|
343
|
+
afterEach(() => { document.body.innerHTML = '' })
|
|
344
|
+
|
|
345
|
+
function mountPopup(endpoint) {
|
|
346
|
+
document.body.innerHTML =
|
|
347
|
+
'<div id="comments-popup" data-typo-enabled="true" data-typo-threshold="80"' +
|
|
348
|
+
(endpoint == null ? '' : ' data-typo-endpoint="' + endpoint + '"') + '></div>' +
|
|
349
|
+
'<form id="new-comment-form"><textarea></textarea></form>'
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// The engine can be mounted at a subpath (e.g. /collavre), so a root-relative
|
|
353
|
+
// default would 404. The endpoint must come from the engine-rendered dataset.
|
|
354
|
+
test('uses the mounted engine endpoint from the dataset', () => {
|
|
355
|
+
mountPopup('/collavre/typo_corrections')
|
|
356
|
+
const tc = initTypoCorrector()
|
|
357
|
+
expect(tc.endpoint).toBe('/collavre/typo_corrections')
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test('falls back to the root-relative path when no endpoint is provided', () => {
|
|
361
|
+
mountPopup(null)
|
|
362
|
+
const tc = initTypoCorrector()
|
|
363
|
+
expect(tc.endpoint).toBe('/typo_corrections')
|
|
364
|
+
})
|
|
365
|
+
})
|