collavre 0.24.0 → 0.24.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9658b35d00bfa088037c3bc4ff2720913c69f43746e924545805f08482b091e9
4
- data.tar.gz: e614bb81be4080199ee4dbf9ae248ce43ca1f88cbff11678514ecde1d66d69f6
3
+ metadata.gz: '080f4e1eadfbbb142d360670a3477f3c19f191d586ea47200b95e6566b63d64d'
4
+ data.tar.gz: 593a65708b18711b54283e4390af4b7b39267a0e20f9a98b2d3df3c3aede0a5e
5
5
  SHA512:
6
- metadata.gz: be8a6c28498c6878c9eb1ae6287bdd97adfb395452b1a0296e1a7cce5e159763b904f93926bfff8c1ad3b4db077abfd0b5c77f2aa1fada447e65d291cc0bab24
7
- data.tar.gz: a6fde45bf58c0707916f3f02e0d2293244b2c8598de2f8913555183d77c88590df29ec67f00a2a3c093378a3f2c7b0995975aaf61fb5f9d95a6c345b0fc85568
6
+ metadata.gz: 57e481a2c4284f2f705cd363f7417c6803d47af951a8e37bea75ef45366142442aec2231f572b2e021319d85283fba78ce613ae96df4e536e16188a5873fe8f3
7
+ data.tar.gz: aefdf3ea1327707b3e230089d5fe8787b428b92ba86d80ba32d244037be9aad82f5c1f36de99ccff779ec323210a1394bfc076e623d99ff46c44645f9bfc8330
@@ -1452,23 +1452,6 @@ body.chat-fullscreen {
1452
1452
  fill-opacity: 0.2;
1453
1453
  }
1454
1454
 
1455
- .comment-topics-list {
1456
- display: flex;
1457
- flex-wrap: nowrap;
1458
- align-items: center;
1459
- gap: 0.5em;
1460
- padding: var(--space-1) 0;
1461
- overflow-x: auto;
1462
- overflow-y: hidden;
1463
- scrollbar-width: none; /* Firefox */
1464
- -ms-overflow-style: none; /* IE/Edge */
1465
- min-height: 36px; /* Prevent height collapse during scroll */
1466
- }
1467
-
1468
- .comment-topics-list::-webkit-scrollbar {
1469
- display: none; /* Chrome/Safari */
1470
- }
1471
-
1472
1455
  .topic-tag {
1473
1456
  display: inline-flex;
1474
1457
  align-items: center;
@@ -1651,6 +1634,11 @@ body.chat-fullscreen {
1651
1634
  flex-shrink: 0;
1652
1635
  }
1653
1636
 
1637
+ /* Class rule above outranks the UA [hidden] rule, so restate it. */
1638
+ .topic-creation-container[hidden] {
1639
+ display: none;
1640
+ }
1641
+
1654
1642
  .add-topic-btn {
1655
1643
  display: inline-flex;
1656
1644
  align-items: center;
@@ -343,4 +343,30 @@ describe('FormController - Review Quote Chips', () => {
343
343
  expect(controller.textareaTarget.value).toBe('fb2')
344
344
  })
345
345
  })
346
+
347
+ describe('active chip re-click - programmatic scroll', () => {
348
+ // Re-clicking the active chip scrolls its quoted comment into view. That is a
349
+ // programmatic list scroll, so it must drop the prev-message anchor the same way
350
+ // scrollToBottom/highlightComment do — otherwise a stale anchor inside the corridor
351
+ // survives and the next previous-message click skips the comment now in view.
352
+ test('re-clicking the active chip notifies the list of a programmatic scroll', () => {
353
+ const commentEl = document.createElement('div')
354
+ commentEl.setAttribute('data-comment-id', '1')
355
+ commentEl.scrollIntoView = jest.fn() // jsdom has no scrollIntoView
356
+ document.body.appendChild(commentEl)
357
+
358
+ const notifyProgrammaticScroll = jest.fn()
359
+ jest
360
+ .spyOn(controller, 'listController', 'get')
361
+ .mockReturnValue({ notifyProgrammaticScroll })
362
+
363
+ controller.appendReviewQuote(1, 'first') // freshly appended quote is active
364
+ container.querySelector('.review-quote-chip-text').click()
365
+
366
+ expect(commentEl.scrollIntoView).toHaveBeenCalled()
367
+ expect(notifyProgrammaticScroll).toHaveBeenCalled()
368
+
369
+ commentEl.remove()
370
+ })
371
+ })
346
372
  })
@@ -0,0 +1,230 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ /**
6
+ * Covers the previous-message wiring in list_controller: anchor lifecycle across
7
+ * connect/disconnect, list reloads, and the scroll action itself.
8
+ *
9
+ * jsdom has no layout engine, so item geometry is stubbed. `scrollTo` records
10
+ * the request without moving `scrollTop` — that models the smooth-scroll
11
+ * animation still being in flight, which is the state the original bug needed.
12
+ */
13
+
14
+ import { jest } from '@jest/globals'
15
+ import ListController from '../list_controller'
16
+
17
+ const ITEM_HEIGHT = 100
18
+
19
+ function buildController({ itemCount = 5 } = {}) {
20
+ const state = { scrollTop: 0 }
21
+
22
+ const element = document.createElement('div')
23
+ const list = document.createElement('div')
24
+ element.appendChild(list)
25
+ document.body.appendChild(element)
26
+
27
+ for (let i = 0; i < itemCount; i++) {
28
+ const item = document.createElement('div')
29
+ item.className = 'comment-item'
30
+ item.dataset.commentId = `c${i}`
31
+ Object.defineProperty(item, 'offsetTop', { get: () => i * ITEM_HEIGHT })
32
+ item.getBoundingClientRect = () => ({ top: i * ITEM_HEIGHT - state.scrollTop })
33
+ list.appendChild(item)
34
+ }
35
+
36
+ list.getBoundingClientRect = () => ({ top: 0 })
37
+ Object.defineProperty(list, 'offsetTop', { get: () => 0 })
38
+ list.scrollTo = jest.fn()
39
+
40
+ // Stand the controller up without a Stimulus application: `element` is a
41
+ // getter on Controller.prototype, so it has to be shadowed rather than set.
42
+ const controller = Object.create(ListController.prototype)
43
+ Object.defineProperty(controller, 'element', { value: element })
44
+ Object.defineProperty(controller, 'listTarget', { value: list })
45
+ controller.connect()
46
+
47
+ return {
48
+ controller,
49
+ list,
50
+ element,
51
+ // Apply the last requested scroll, i.e. let the smooth animation finish.
52
+ settle() {
53
+ const call = list.scrollTo.mock.calls.at(-1)
54
+ if (call) state.scrollTop = call[0].top
55
+ },
56
+ // Move the list without going through the button, as a user gesture would.
57
+ scrollTo(top) {
58
+ state.scrollTop = top
59
+ },
60
+ lastScrollTop() {
61
+ return list.scrollTo.mock.calls.at(-1)?.[0].top
62
+ },
63
+ }
64
+ }
65
+
66
+ describe('list_controller previous-message navigation', () => {
67
+ afterEach(() => {
68
+ document.body.innerHTML = ''
69
+ })
70
+
71
+ test('connect installs a navigator', () => {
72
+ const { controller } = buildController()
73
+ expect(controller.prevMsgNavigator).toBeTruthy()
74
+ })
75
+
76
+ test('walks backwards one message per click when settled', () => {
77
+ const h = buildController()
78
+ h.scrollTo(4 * ITEM_HEIGHT) // parked on the last item
79
+
80
+ h.controller.scrollToPreviousMessage()
81
+ expect(h.lastScrollTop()).toBe(3 * ITEM_HEIGHT)
82
+ h.settle()
83
+
84
+ h.controller.scrollToPreviousMessage()
85
+ expect(h.lastScrollTop()).toBe(2 * ITEM_HEIGHT)
86
+ })
87
+
88
+ test('keeps stepping back when clicked mid-animation', () => {
89
+ const h = buildController()
90
+ h.scrollTo(4 * ITEM_HEIGHT)
91
+
92
+ // Three clicks with no settle between them: the list never reaches the
93
+ // requested offset, which is exactly when the old geometry-only code
94
+ // bounced back to the item it had just left.
95
+ h.controller.scrollToPreviousMessage()
96
+ expect(h.lastScrollTop()).toBe(3 * ITEM_HEIGHT)
97
+ h.controller.scrollToPreviousMessage()
98
+ expect(h.lastScrollTop()).toBe(2 * ITEM_HEIGHT)
99
+ h.controller.scrollToPreviousMessage()
100
+ expect(h.lastScrollTop()).toBe(1 * ITEM_HEIGHT)
101
+ })
102
+
103
+ test('releases stick-to-bottom so incoming comments do not yank the view', () => {
104
+ const h = buildController()
105
+ h.controller.stickToBottom = true
106
+ h.scrollTo(4 * ITEM_HEIGHT)
107
+
108
+ h.controller.scrollToPreviousMessage()
109
+
110
+ expect(h.controller.stickToBottom).toBe(false)
111
+ })
112
+
113
+ test('does nothing when the list is empty', () => {
114
+ const h = buildController({ itemCount: 0 })
115
+
116
+ h.controller.scrollToPreviousMessage()
117
+
118
+ expect(h.list.scrollTo).not.toHaveBeenCalled()
119
+ })
120
+
121
+ test('loads older comments once past the oldest message', () => {
122
+ const h = buildController()
123
+ h.controller.allOlderLoaded = false
124
+ h.controller.loadOlderComments = jest.fn()
125
+
126
+ h.controller.scrollToPreviousMessage() // lands on the oldest item
127
+ h.settle()
128
+ h.controller.scrollToPreviousMessage() // nothing older in the DOM
129
+
130
+ expect(h.controller.loadOlderComments).toHaveBeenCalled()
131
+ })
132
+
133
+ test('stops at the oldest message when everything is already loaded', () => {
134
+ const h = buildController()
135
+ h.controller.allOlderLoaded = true
136
+ h.controller.loadOlderComments = jest.fn()
137
+
138
+ h.controller.scrollToPreviousMessage()
139
+ h.settle()
140
+ const before = h.list.scrollTo.mock.calls.length
141
+ h.controller.scrollToPreviousMessage()
142
+
143
+ expect(h.controller.loadOlderComments).not.toHaveBeenCalled()
144
+ expect(h.list.scrollTo.mock.calls.length).toBe(before)
145
+ })
146
+
147
+ describe.each(['wheel', 'touchstart', 'keydown', 'pointerdown'])(
148
+ 'user input via %s',
149
+ (eventName) => {
150
+ test('drops the anchor so the next click resolves from where the user landed', () => {
151
+ const h = buildController()
152
+ h.scrollTo(4 * ITEM_HEIGHT)
153
+
154
+ h.controller.scrollToPreviousMessage() // anchor = c3
155
+ h.settle()
156
+
157
+ // User scrolls back down to the bottom themselves.
158
+ h.list.dispatchEvent(new Event(eventName))
159
+ h.scrollTo(4 * ITEM_HEIGHT)
160
+
161
+ h.controller.scrollToPreviousMessage()
162
+
163
+ // Resolved from geometry (c4 at top -> c3), not from the stale anchor.
164
+ expect(h.lastScrollTop()).toBe(3 * ITEM_HEIGHT)
165
+ })
166
+ }
167
+ )
168
+
169
+ test('a programmatic jump to the bottom drops the anchor', () => {
170
+ // form_controller (after sending) and the fullscreen handlers call
171
+ // scrollToBottom() with no user gesture. A small jump can leave the anchor
172
+ // inside the corridor, where geometry cannot tell it from our own in-flight
173
+ // scroll, so the next click would step off the stale anchor (c3 -> c2)
174
+ // instead of resolving from where the list actually is (c4 -> c3).
175
+ const h = buildController()
176
+ h.scrollTo(4 * ITEM_HEIGHT)
177
+
178
+ h.controller.scrollToPreviousMessage() // anchor = c3, in flight
179
+ expect(h.controller.prevMsgNavigator.anchorId).toBe('c3')
180
+
181
+ h.controller.scrollToBottom()
182
+ expect(h.controller.prevMsgNavigator.anchorId).toBeNull()
183
+
184
+ h.controller.scrollToPreviousMessage()
185
+ expect(h.lastScrollTop()).toBe(3 * ITEM_HEIGHT) // c3 from geometry, not c2
186
+ })
187
+
188
+ test('a permalink highlight jump drops the anchor', () => {
189
+ const h = buildController()
190
+ h.scrollTo(4 * ITEM_HEIGHT)
191
+
192
+ h.controller.scrollToPreviousMessage() // anchor = c3
193
+ expect(h.controller.prevMsgNavigator.anchorId).toBe('c3')
194
+
195
+ // highlightComment looks the target up by DOM id and scrolls it into view
196
+ // (jsdom has no scrollIntoView, so stub it).
197
+ const target = h.list.querySelector('[data-comment-id="c1"]')
198
+ target.id = 'comment_c1'
199
+ target.scrollIntoView = () => {}
200
+ h.controller.highlightComment('c1')
201
+
202
+ expect(h.controller.prevMsgNavigator.anchorId).toBeNull()
203
+ })
204
+
205
+ test('disconnect unhooks the user-input listeners', () => {
206
+ const h = buildController()
207
+ h.scrollTo(4 * ITEM_HEIGHT)
208
+ h.controller.scrollToPreviousMessage() // anchor = c3
209
+ h.settle()
210
+
211
+ h.controller.disconnect()
212
+ h.list.dispatchEvent(new Event('wheel'))
213
+
214
+ // Anchor survived because the listener is gone.
215
+ expect(h.controller.prevMsgNavigator.anchorId).toBe('c3')
216
+ })
217
+
218
+ test('reloading the list drops the anchor', () => {
219
+ const h = buildController()
220
+ h.scrollTo(4 * ITEM_HEIGHT)
221
+ h.controller.scrollToPreviousMessage()
222
+ expect(h.controller.prevMsgNavigator.anchorId).toBe('c3')
223
+
224
+ h.controller.creativeId = '1'
225
+ h.controller.fetchComments = jest.fn(() => new Promise(() => {}))
226
+ h.controller.loadInitialComments()
227
+
228
+ expect(h.controller.prevMsgNavigator.anchorId).toBeNull()
229
+ })
230
+ })
@@ -0,0 +1,129 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import PrevMessageNavigator from '../prev_message_navigator'
6
+
7
+ // Items are ordered oldest -> newest, matching DOM order of `.comment-item`.
8
+ // `top` is the item's viewport-relative top, as measured by the controller.
9
+ function items(...tops) {
10
+ return tops.map((top, i) => ({ id: String(i), top }))
11
+ }
12
+
13
+ describe('PrevMessageNavigator', () => {
14
+ const VIEWPORT_TOP = 100
15
+
16
+ let nav
17
+
18
+ beforeEach(() => {
19
+ nav = new PrevMessageNavigator()
20
+ })
21
+
22
+ describe('without an anchor (geometry fallback)', () => {
23
+ test('steps to the previous item when the current one is flush with the top', () => {
24
+ // item 2 is exactly at the viewport top -> previous message is item 1
25
+ expect(nav.resolveTargetIndex(items(-200, -100, 100, 300), VIEWPORT_TOP)).toBe(1)
26
+ })
27
+
28
+ test('scrolls the partially visible item to the top first', () => {
29
+ // item 2 is cut off above the fold -> bring item 2 fully into view
30
+ expect(nav.resolveTargetIndex(items(-200, -100, 130, 330), VIEWPORT_TOP)).toBe(2)
31
+ })
32
+
33
+ test('starts from the last item when everything is scrolled above the fold', () => {
34
+ expect(nav.resolveTargetIndex(items(-300, -200, -100), VIEWPORT_TOP)).toBe(2)
35
+ })
36
+
37
+ test('returns -1 at the oldest item so the caller can load older comments', () => {
38
+ expect(nav.resolveTargetIndex(items(100, 300), VIEWPORT_TOP)).toBe(-1)
39
+ })
40
+
41
+ test('returns -1 for an empty list', () => {
42
+ expect(nav.resolveTargetIndex([], VIEWPORT_TOP)).toBe(-1)
43
+ })
44
+ })
45
+
46
+ describe('with an anchor (rapid clicks)', () => {
47
+ test('keeps stepping backwards while the smooth scroll is still in flight', () => {
48
+ // First click: item 2 is flush with the top, so we head for item 1.
49
+ expect(nav.resolveTargetIndex(items(-200, -100, 100, 300), VIEWPORT_TOP)).toBe(1)
50
+ nav.commit('1', -100)
51
+
52
+ // Second click lands mid-animation: item 1 has not reached the top yet, so
53
+ // geometry alone would send us back down to item 1. The anchor must win.
54
+ expect(nav.resolveTargetIndex(items(-150, -50, 150, 350), VIEWPORT_TOP)).toBe(0)
55
+ })
56
+
57
+ test('keeps stepping when the first click started from a partially visible item', () => {
58
+ // First click from between messages: item 2 is cut off *below* the top, so
59
+ // we scroll it up into place and the anchor starts below the viewport top.
60
+ expect(nav.resolveTargetIndex(items(-200, -100, 130, 330), VIEWPORT_TOP)).toBe(2)
61
+ nav.commit('2', 130)
62
+
63
+ // Second click mid-animation: item 2 is on its way down to the top. The
64
+ // anchor must still win, otherwise we re-target item 2 and stall.
65
+ expect(nav.resolveTargetIndex(items(-215, -115, 115, 315), VIEWPORT_TOP)).toBe(1)
66
+ })
67
+
68
+ test('survives older comments being prepended (anchor is id based)', () => {
69
+ // Prepending shifts scrollTop but leaves the anchor where it is on screen,
70
+ // so it is still on its way from -300 up to the viewport top.
71
+ nav.commit('c5', -300)
72
+ const prepended = [
73
+ { id: 'c3', top: -500 },
74
+ { id: 'c4', top: -300 },
75
+ { id: 'c5', top: -100 },
76
+ ]
77
+ expect(nav.resolveTargetIndex(prepended, VIEWPORT_TOP)).toBe(1)
78
+ })
79
+
80
+ test('falls back to geometry when the anchored comment is gone', () => {
81
+ nav.commit('deleted-comment', -100)
82
+ expect(nav.resolveTargetIndex(items(-200, -100, 100, 300), VIEWPORT_TOP)).toBe(1)
83
+ })
84
+
85
+ test('returns -1 when the anchor is already the oldest item', () => {
86
+ nav.commit('0', -100)
87
+ expect(nav.resolveTargetIndex(items(100, 300), VIEWPORT_TOP)).toBe(-1)
88
+ })
89
+
90
+ test('ignores an anchor the list has scrolled away from', () => {
91
+ // A programmatic jump (new message arriving, permalink highlight) moves the
92
+ // list without any user gesture, so nothing drops the anchor. It is now far
93
+ // above where it started, which our own animation can never produce - that
94
+ // only ever walks the anchor down towards the viewport top.
95
+ nav.commit('0', -100)
96
+ expect(nav.resolveTargetIndex(items(-900, -700, 100, 300), VIEWPORT_TOP)).toBe(1)
97
+ })
98
+
99
+ test('a stale anchor stops wedging the button at the oldest message', () => {
100
+ // Regression: anchored on the oldest item, every later click returned -1
101
+ // forever once there was nothing more to load, so the button went dead.
102
+ nav.commit('0', -100)
103
+ nav.resolveTargetIndex(items(100, 300), VIEWPORT_TOP)
104
+
105
+ // The list is jumped back to the bottom; clicking must resume stepping.
106
+ expect(nav.resolveTargetIndex(items(-900, -700, 100, 300), VIEWPORT_TOP)).toBe(1)
107
+ })
108
+ })
109
+
110
+ describe('anchor invalidation', () => {
111
+ test('drops the anchor when the user moves the list', () => {
112
+ nav.commit('1', -100)
113
+ nav.notifyUserInput()
114
+ expect(nav.anchorId).toBeNull()
115
+ })
116
+
117
+ test('reset clears the anchor', () => {
118
+ nav.commit('1', -100)
119
+ nav.reset()
120
+ expect(nav.anchorId).toBeNull()
121
+ })
122
+
123
+ test('after a user scroll, geometry drives the next click again', () => {
124
+ nav.commit('1', -100)
125
+ nav.notifyUserInput()
126
+ expect(nav.resolveTargetIndex(items(-200, -100, 130, 330), VIEWPORT_TOP)).toBe(2)
127
+ })
128
+ })
129
+ })
@@ -0,0 +1,178 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+
7
+ // Every topic mutation used to alert a hardcoded English literal on failure.
8
+ // These tests pin that each failure path reaches the user through the localized
9
+ // string handed down by the ERB partial, and that the English literals survive
10
+ // only as the last-resort fallback when the partial supplied nothing.
11
+ jest.unstable_mockModule('../../../lib/utils/dialog', () => ({
12
+ confirmDialog: jest.fn(),
13
+ alertDialog: jest.fn(),
14
+ }))
15
+
16
+ const { Application } = await import('@hotwired/stimulus')
17
+ const TopicsController = (await import('../topics_controller')).default
18
+ const { alertDialog, confirmDialog } = await import('../../../lib/utils/dialog')
19
+
20
+ // Mirrors the data attributes emitted by _comments_popup.html.erb.
21
+ const LOCALIZED = {
22
+ topicSetAgentError: '에이전트를 지정할 수 없습니다.',
23
+ topicCreateError: '토픽을 만들 수 없습니다.',
24
+ topicUpdateError: '토픽을 수정할 수 없습니다.',
25
+ topicDeleteError: '토픽을 삭제할 수 없습니다.',
26
+ topicArchiveError: '토픽을 보관할 수 없습니다.',
27
+ topicRestoreError: '토픽을 복원할 수 없습니다.',
28
+ }
29
+
30
+ const ENGLISH_FALLBACK = {
31
+ set_agent_error: 'Unable to assign the agent to this topic.',
32
+ create_error: 'Unable to create the topic.',
33
+ update_error: 'Unable to update the topic.',
34
+ delete_error: 'Unable to delete the topic.',
35
+ archive_error: 'Unable to archive the topic.',
36
+ restore_error: 'Unable to restore the topic.',
37
+ }
38
+
39
+ describe('TopicsController topic action failures', () => {
40
+ let application
41
+ let controller
42
+ let element
43
+
44
+ const mount = async (dataset = LOCALIZED) => {
45
+ const container = document.createElement('div')
46
+ container.innerHTML = `
47
+ <div id="topics" data-controller="comments--topics">
48
+ <div data-comments--topics-target="list"></div>
49
+ </div>
50
+ `
51
+ document.body.appendChild(container)
52
+
53
+ element = document.getElementById('topics')
54
+ Object.assign(element.dataset, dataset)
55
+
56
+ application = Application.start()
57
+ application.register('comments--topics', TopicsController)
58
+
59
+ const meta = document.createElement('meta')
60
+ meta.name = 'csrf-token'
61
+ meta.content = 'test-csrf'
62
+ document.head.appendChild(meta)
63
+
64
+ await new Promise((resolve) => setTimeout(resolve, 0))
65
+ controller = application.getControllerForElementAndIdentifier(element, 'comments--topics')
66
+ controller.creativeIdValue = '42'
67
+ controller.loadTopics = jest.fn()
68
+ controller.renderTopics = jest.fn()
69
+ controller.restoreSelection = jest.fn()
70
+ controller.flushSaveLastTopic = jest.fn()
71
+ controller.topics = [{ id: 1, name: 'Main' }]
72
+ return controller
73
+ }
74
+
75
+ // deleteTopic/archiveTopic/unarchiveTopic read the id off event.currentTarget.
76
+ const eventFor = (id) => ({
77
+ stopPropagation: jest.fn(),
78
+ currentTarget: { dataset: { id: String(id) } },
79
+ })
80
+
81
+ const failingFetch = () => jest.fn().mockResolvedValue({ ok: false, json: async () => ({}) })
82
+
83
+ afterEach(() => {
84
+ document.body.innerHTML = ''
85
+ document.head.innerHTML = ''
86
+ application.stop()
87
+ jest.clearAllMocks()
88
+ })
89
+
90
+ describe('with localized strings from the partial', () => {
91
+ beforeEach(async () => {
92
+ await mount()
93
+ confirmDialog.mockResolvedValue(true)
94
+ })
95
+
96
+ test('deleteTopic surfaces the localized delete error', async () => {
97
+ global.fetch = failingFetch()
98
+
99
+ await controller.deleteTopic(eventFor(1))
100
+
101
+ expect(alertDialog).toHaveBeenCalledWith(LOCALIZED.topicDeleteError)
102
+ })
103
+
104
+ test('archiveTopic surfaces the localized archive error', async () => {
105
+ global.fetch = failingFetch()
106
+
107
+ await controller.archiveTopic(eventFor(1))
108
+
109
+ expect(alertDialog).toHaveBeenCalledWith(LOCALIZED.topicArchiveError)
110
+ })
111
+
112
+ test('unarchiveTopic surfaces the localized restore error', async () => {
113
+ global.fetch = failingFetch()
114
+
115
+ await controller.unarchiveTopic(eventFor(1))
116
+
117
+ expect(alertDialog).toHaveBeenCalledWith(LOCALIZED.topicRestoreError)
118
+ })
119
+
120
+ // updateTopic additionally reloads to discard the optimistic rename.
121
+ test('updateTopic surfaces the localized update error and reloads', async () => {
122
+ global.fetch = failingFetch()
123
+
124
+ await controller.updateTopic('1', 'Renamed')
125
+
126
+ expect(alertDialog).toHaveBeenCalledWith(LOCALIZED.topicUpdateError)
127
+ expect(controller.loadTopics).toHaveBeenCalled()
128
+ })
129
+
130
+ test('createTopic surfaces the localized create error', async () => {
131
+ global.fetch = failingFetch()
132
+
133
+ await controller.createTopic('New topic')
134
+
135
+ expect(alertDialog).toHaveBeenCalledWith(LOCALIZED.topicCreateError)
136
+ // The `finally` block must clear the guard even on the failure path,
137
+ // otherwise blur handling stays wedged after one failed create.
138
+ expect(controller.creating).toBe(false)
139
+ })
140
+ })
141
+
142
+ describe('without localized strings (controller mounted bare)', () => {
143
+ beforeEach(async () => {
144
+ await mount({})
145
+ confirmDialog.mockResolvedValue(true)
146
+ })
147
+
148
+ test.each(Object.entries(ENGLISH_FALLBACK))(
149
+ '_i18n("%s") falls back to the English literal',
150
+ (key, expected) => {
151
+ expect(controller._i18n(key)).toBe(expected)
152
+ }
153
+ )
154
+
155
+ // An unknown key must not render as `undefined` in a dialog; returning the
156
+ // key itself keeps the failure legible.
157
+ test('_i18n returns the key itself when it is unknown', () => {
158
+ expect(controller._i18n('no_such_key')).toBe('no_such_key')
159
+ })
160
+ })
161
+
162
+ describe('_i18n with localized strings', () => {
163
+ beforeEach(async () => {
164
+ await mount()
165
+ })
166
+
167
+ test.each([
168
+ ['set_agent_error', LOCALIZED.topicSetAgentError],
169
+ ['create_error', LOCALIZED.topicCreateError],
170
+ ['update_error', LOCALIZED.topicUpdateError],
171
+ ['delete_error', LOCALIZED.topicDeleteError],
172
+ ['archive_error', LOCALIZED.topicArchiveError],
173
+ ['restore_error', LOCALIZED.topicRestoreError],
174
+ ])('_i18n("%s") prefers the value from the partial', (key, expected) => {
175
+ expect(controller._i18n(key)).toBe(expected)
176
+ })
177
+ })
178
+ })