PNRxA commited on
Commit
ddc8b52
·
1 Parent(s): aabdd80

remove settings debounce in favour of save queuea

Browse files
web/e2e/key-login.spec.ts CHANGED
@@ -38,7 +38,7 @@ test.describe('API key login flow', () => {
38
  await page.click('button[type="submit"]')
39
 
40
  await expect(page).toHaveURL(/\/key-settings/)
41
- await expect(page.locator('h1')).toContainText('Poster Settings')
42
  await expect(page.locator('text=e2e-test-key')).toBeVisible()
43
  })
44
 
 
38
  await page.click('button[type="submit"]')
39
 
40
  await expect(page).toHaveURL(/\/key-settings/)
41
+ await expect(page.locator('h1')).toContainText('Image Settings')
42
  await expect(page.locator('text=e2e-test-key')).toBeVisible()
43
  })
44
 
web/e2e/key-settings.spec.ts CHANGED
@@ -50,27 +50,25 @@ test.describe('key settings (self-service)', () => {
50
 
51
  await loginWithApiKey(page, request)
52
 
53
- await expect(page.locator('h1')).toContainText('Poster Settings')
54
  await expect(page.locator('text=settings-test-key')).toBeVisible()
55
 
56
- // Settings form should be present with defaults
57
- const select = page.getByTestId('poster-source-select')
58
- await expect(select).toBeVisible()
59
- await expect(select).toHaveValue('t')
60
  })
61
 
62
  test('auto-saves and shows confirmation', async ({ page, request }) => {
63
  await loginWithApiKey(page, request)
64
 
65
  // Change a setting to trigger auto-save
66
- await page.getByTestId('poster-source-select').selectOption('f')
67
 
68
  // Wait for auto-save confirmation
69
  await expect(page.locator('text=Saved')).toBeVisible({ timeout: 5000 })
70
  })
71
 
72
- test('fanart options appear when fanart is selected', async ({ page, request }) => {
73
- // Ensure global source is "tmdb" so fanart options start hidden
74
  const adminToken = await ensureAdmin(request)
75
  await request.put('/api/admin/settings', {
76
  headers: {
@@ -82,32 +80,33 @@ test.describe('key settings (self-service)', () => {
82
 
83
  await loginWithApiKey(page, request)
84
 
85
- // Language should not be visible initially
86
- await expect(page.locator('label:has-text("Language")')).not.toBeVisible()
 
87
 
88
- // Select fanart
89
- await page.getByTestId('poster-source-select').selectOption('f')
90
 
91
- // Now language and textless should appear
92
- await expect(page.locator('label:has-text("Language")')).toBeVisible()
93
- await expect(page.locator('label:has-text("Prefer textless")')).toBeVisible()
94
  })
95
 
96
  test('settings persist after auto-save and reload', async ({ page, request }) => {
97
  await loginWithApiKey(page, request)
98
 
99
- // Change to fanart
100
- await page.getByTestId('poster-source-select').selectOption('f')
101
 
102
  // Wait for auto-save confirmation
103
  await expect(page.locator('text=Saved')).toBeVisible({ timeout: 5000 })
104
 
105
  // Reload
106
  await page.reload()
107
- await expect(page.locator('h1')).toContainText('Poster Settings')
108
 
109
  // Settings should persist
110
- await expect(page.getByTestId('poster-source-select')).toHaveValue('f')
111
  })
112
 
113
  test('rating display section is visible', async ({ page, request }) => {
@@ -140,11 +139,11 @@ test.describe('key settings (self-service)', () => {
140
 
141
  await loginWithApiKey(page, request)
142
 
143
- // Global is now "tmdb", key has no overrides → should show "tmdb"
144
- await expect(page.getByTestId('poster-source-select')).toHaveValue('t')
145
 
146
- // Change to fanart — auto-save triggers
147
- await page.getByTestId('poster-source-select').selectOption('f')
148
  await expect(page.locator('text=Saved')).toBeVisible({ timeout: 5000 })
149
 
150
  // Wait for "Using defaults" badge to disappear (confirms custom settings saved)
@@ -153,9 +152,9 @@ test.describe('key settings (self-service)', () => {
153
  // Reset to defaults
154
  await page.locator('button:has-text("Reset to defaults")').click()
155
 
156
- // Should be back to global default ("tmdb")
157
  await expect(page.locator('text=Using defaults')).toBeVisible({ timeout: 10000 })
158
- await expect(page.getByTestId('poster-source-select')).toHaveValue('t')
159
  })
160
 
161
  test('reset to defaults does not trigger a spurious auto-save', async ({ page, request }) => {
@@ -171,13 +170,13 @@ test.describe('key settings (self-service)', () => {
171
  await loginWithApiKey(page, request)
172
 
173
  // Change a setting so we have custom overrides
174
- await page.getByTestId('poster-source-select').selectOption('f')
175
  await expect(page.locator('text=Saved')).toBeVisible({ timeout: 5000 })
176
  await expect(page.locator('text=Using defaults')).not.toBeVisible()
177
 
178
  // Start tracking network requests after clicking reset
179
  const requestsAfterReset: string[] = []
180
- await page.route('**/api/self/settings', (route) => {
181
  requestsAfterReset.push(route.request().method())
182
  route.continue()
183
  })
@@ -188,8 +187,8 @@ test.describe('key settings (self-service)', () => {
188
  // Should show "Using defaults" badge
189
  await expect(page.locator('text=Using defaults')).toBeVisible({ timeout: 10000 })
190
 
191
- // Wait long enough for any debounced auto-save to fire (600ms debounce + margin)
192
- await page.waitForTimeout(2000)
193
 
194
  // Should have seen DELETE + GET, but no PUT
195
  const putCount = requestsAfterReset.filter(m => m === 'PUT').length
@@ -219,7 +218,7 @@ test.describe('key settings (self-service)', () => {
219
  await expect(page.locator('text=Saved')).toBeVisible({ timeout: 5000 })
220
 
221
  await page.reload()
222
- await expect(page.locator('h1')).toContainText('Poster Settings')
223
  await expect(page.getByTestId('poster-badge-direction-select')).toHaveValue('h')
224
  })
225
 
@@ -248,7 +247,7 @@ test.describe('key settings (self-service)', () => {
248
 
249
  // Reload and verify persistence
250
  await page.reload()
251
- await expect(page.locator('h1')).toContainText('Poster Settings')
252
 
253
  const reloadedSelects = page.locator('select').filter({ has: page.locator('option[value="i"]') })
254
  await expect(reloadedSelects.first()).toHaveValue('t')
 
50
 
51
  await loginWithApiKey(page, request)
52
 
53
+ await expect(page.locator('h1')).toContainText('Image Settings')
54
  await expect(page.locator('text=settings-test-key')).toBeVisible()
55
 
56
+ // Settings form should be present with fanart checkbox
57
+ await expect(page.getByTestId('fanart-checkbox')).toBeVisible()
 
 
58
  })
59
 
60
  test('auto-saves and shows confirmation', async ({ page, request }) => {
61
  await loginWithApiKey(page, request)
62
 
63
  // Change a setting to trigger auto-save
64
+ await page.getByTestId('fanart-checkbox').check()
65
 
66
  // Wait for auto-save confirmation
67
  await expect(page.locator('text=Saved')).toBeVisible({ timeout: 5000 })
68
  })
69
 
70
+ test('fanart options enabled when fanart is checked', async ({ page, request }) => {
71
+ // Ensure global source is "tmdb" so fanart is unchecked
72
  const adminToken = await ensureAdmin(request)
73
  await request.put('/api/admin/settings', {
74
  headers: {
 
80
 
81
  await loginWithApiKey(page, request)
82
 
83
+ // Language and textless should be visible but disabled initially
84
+ await expect(page.getByTestId('fanart-lang-select')).toBeDisabled()
85
+ await expect(page.getByTestId('textless-checkbox')).toBeDisabled()
86
 
87
+ // Check fanart
88
+ await page.getByTestId('fanart-checkbox').check()
89
 
90
+ // Now language and textless should be enabled
91
+ await expect(page.getByTestId('fanart-lang-select')).toBeEnabled()
92
+ await expect(page.getByTestId('textless-checkbox')).toBeEnabled()
93
  })
94
 
95
  test('settings persist after auto-save and reload', async ({ page, request }) => {
96
  await loginWithApiKey(page, request)
97
 
98
+ // Enable fanart
99
+ await page.getByTestId('fanart-checkbox').check()
100
 
101
  // Wait for auto-save confirmation
102
  await expect(page.locator('text=Saved')).toBeVisible({ timeout: 5000 })
103
 
104
  // Reload
105
  await page.reload()
106
+ await expect(page.locator('h1')).toContainText('Image Settings')
107
 
108
  // Settings should persist
109
+ await expect(page.getByTestId('fanart-checkbox')).toBeChecked()
110
  })
111
 
112
  test('rating display section is visible', async ({ page, request }) => {
 
139
 
140
  await loginWithApiKey(page, request)
141
 
142
+ // Global is now "tmdb", key has no overrides → fanart should be unchecked
143
+ await expect(page.getByTestId('fanart-checkbox')).not.toBeChecked()
144
 
145
+ // Enable fanart — auto-save triggers
146
+ await page.getByTestId('fanart-checkbox').check()
147
  await expect(page.locator('text=Saved')).toBeVisible({ timeout: 5000 })
148
 
149
  // Wait for "Using defaults" badge to disappear (confirms custom settings saved)
 
152
  // Reset to defaults
153
  await page.locator('button:has-text("Reset to defaults")').click()
154
 
155
+ // Should be back to global default (fanart unchecked)
156
  await expect(page.locator('text=Using defaults')).toBeVisible({ timeout: 10000 })
157
+ await expect(page.getByTestId('fanart-checkbox')).not.toBeChecked()
158
  })
159
 
160
  test('reset to defaults does not trigger a spurious auto-save', async ({ page, request }) => {
 
170
  await loginWithApiKey(page, request)
171
 
172
  // Change a setting so we have custom overrides
173
+ await page.getByTestId('fanart-checkbox').check()
174
  await expect(page.locator('text=Saved')).toBeVisible({ timeout: 5000 })
175
  await expect(page.locator('text=Using defaults')).not.toBeVisible()
176
 
177
  // Start tracking network requests after clicking reset
178
  const requestsAfterReset: string[] = []
179
+ await page.route('**/api/key/me/settings', (route) => {
180
  requestsAfterReset.push(route.request().method())
181
  route.continue()
182
  })
 
187
  // Should show "Using defaults" badge
188
  await expect(page.locator('text=Using defaults')).toBeVisible({ timeout: 10000 })
189
 
190
+ // Wait for any auto-save to fire
191
+ await page.waitForTimeout(1000)
192
 
193
  // Should have seen DELETE + GET, but no PUT
194
  const putCount = requestsAfterReset.filter(m => m === 'PUT').length
 
218
  await expect(page.locator('text=Saved')).toBeVisible({ timeout: 5000 })
219
 
220
  await page.reload()
221
+ await expect(page.locator('h1')).toContainText('Image Settings')
222
  await expect(page.getByTestId('poster-badge-direction-select')).toHaveValue('h')
223
  })
224
 
 
247
 
248
  // Reload and verify persistence
249
  await page.reload()
250
+ await expect(page.locator('h1')).toContainText('Image Settings')
251
 
252
  const reloadedSelects = page.locator('select').filter({ has: page.locator('option[value="i"]') })
253
  await expect(reloadedSelects.first()).toHaveValue('t')
web/e2e/settings.spec.ts CHANGED
@@ -164,8 +164,8 @@ test.describe('settings', () => {
164
  const newValue = currentValue === '7' ? '2' : '7'
165
  await limitInput.fill(newValue)
166
 
167
- // Wait for debounced update (500ms) + network
168
- await page.waitForTimeout(1500)
169
 
170
  const newSrc = await previewImg.getAttribute('src')
171
  // Blob URL should change when preview is re-fetched
 
164
  const newValue = currentValue === '7' ? '2' : '7'
165
  await limitInput.fill(newValue)
166
 
167
+ // Wait for network
168
+ await page.waitForTimeout(1000)
169
 
170
  const newSrc = await previewImg.getAttribute('src')
171
  // Blob URL should change when preview is re-fetched
web/src/__tests__/components/RenderSettingsForm.spec.ts CHANGED
@@ -95,7 +95,7 @@ describe('RenderSettingsForm', () => {
95
  expect(src).toContain('blob:')
96
  })
97
 
98
- it('updates preview after debounce when ratings_limit changes', async () => {
99
  const fetchPreview = makeFetchPreview()
100
  const wrapper = mountForm({}, fetchPreview)
101
  await flushPromises()
@@ -104,42 +104,14 @@ describe('RenderSettingsForm', () => {
104
  // Change the limit
105
  const limitInput = wrapper.find('input[type="number"]')
106
  await limitInput.setValue(5)
107
- await flushPromises()
108
-
109
- // Before debounce fires, fetchPreview should not have been called again
110
- expect(fetchPreview).not.toHaveBeenCalled()
111
 
112
- // Advance past debounce timer
113
- vi.advanceTimersByTime(600)
114
  await flushPromises()
115
 
116
  expect(fetchPreview).toHaveBeenCalledWith(5, expect.any(String), expect.any(String), expect.any(String), expect.any(String), expect.any(String))
117
  })
118
 
119
- it('debounces rapid changes — only last value triggers fetch', async () => {
120
- const fetchPreview = makeFetchPreview()
121
- const wrapper = mountForm({}, fetchPreview)
122
- await flushPromises()
123
- fetchPreview.mockClear()
124
-
125
- const limitInput = wrapper.find('input[type="number"]')
126
-
127
- // Rapid changes
128
- await limitInput.setValue(1)
129
- vi.advanceTimersByTime(200)
130
- await limitInput.setValue(4)
131
- vi.advanceTimersByTime(200)
132
- await limitInput.setValue(7)
133
-
134
- // Advance past final debounce
135
- vi.advanceTimersByTime(600)
136
- await flushPromises()
137
-
138
- // Should only have been called once with the final value
139
- expect(fetchPreview).toHaveBeenCalledTimes(1)
140
- expect(fetchPreview).toHaveBeenCalledWith(7, expect.any(String), expect.any(String), expect.any(String), expect.any(String), expect.any(String))
141
- })
142
-
143
  it('shows loading state while preview loads', async () => {
144
  // Use a fetch that never resolves to keep loading state
145
  const fetchPreview = vi.fn().mockReturnValue(new Promise(() => {}))
 
95
  expect(src).toContain('blob:')
96
  })
97
 
98
+ it('updates preview when ratings_limit changes', async () => {
99
  const fetchPreview = makeFetchPreview()
100
  const wrapper = mountForm({}, fetchPreview)
101
  await flushPromises()
 
104
  // Change the limit
105
  const limitInput = wrapper.find('input[type="number"]')
106
  await limitInput.setValue(5)
 
 
 
 
107
 
108
+ // Advance past preview debounce timer
109
+ vi.advanceTimersByTime(500)
110
  await flushPromises()
111
 
112
  expect(fetchPreview).toHaveBeenCalledWith(5, expect.any(String), expect.any(String), expect.any(String), expect.any(String), expect.any(String))
113
  })
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  it('shows loading state while preview loads', async () => {
116
  // Use a fetch that never resolves to keep loading state
117
  const fetchPreview = vi.fn().mockReturnValue(new Promise(() => {}))
web/src/components/RenderSettingsForm.vue CHANGED
@@ -153,8 +153,6 @@ watch(() => props.settings, (s) => {
153
  applySettings(s)
154
  nextTick(() => {
155
  syncing = false
156
- // Cancel any save timer queued by watchers during the sync
157
- if (saveTimer) { clearTimeout(saveTimer); saveTimer = null }
158
  })
159
  })
160
 
@@ -164,8 +162,13 @@ function revertEdits() {
164
  nextTick(() => { syncing = false })
165
  }
166
 
 
 
167
  async function autoSave() {
168
- if (saving.value) return
 
 
 
169
  saving.value = true
170
  error.value = ''
171
  showCheck.value = false
@@ -204,25 +207,25 @@ async function autoSave() {
204
  revertEdits()
205
  } finally {
206
  saving.value = false
 
 
 
 
207
  }
208
  }
209
 
210
- let saveTimer: ReturnType<typeof setTimeout> | null = null
211
-
212
- // Auto-save on any setting change (debounced)
213
  watch(
214
  [editSource, editLang, editTextless, editRatingsLimit, editRatingsOrder, editPosterPosition, editLogoRatingsLimit, editBackdropRatingsLimit, editPosterBadgeStyle, editLogoBadgeStyle, editBackdropBadgeStyle, editPosterLabelStyle, editLogoLabelStyle, editBackdropLabelStyle, editPosterBadgeDirection],
215
  () => {
216
  if (syncing) return
217
- if (saveTimer) clearTimeout(saveTimer)
218
- saveTimer = setTimeout(autoSave, 600)
219
  },
220
  { deep: true },
221
  )
222
 
223
  async function handleReset() {
224
  if (!props.resetSettings) return
225
- if (saveTimer) { clearTimeout(saveTimer); saveTimer = null }
226
  saving.value = true
227
  error.value = ''
228
  showCheck.value = false
@@ -230,17 +233,7 @@ async function handleReset() {
230
  try {
231
  const ok = await props.resetSettings()
232
  if (ok) {
233
- const updated = await props.loadSettings()
234
- if (updated) {
235
- syncing = true
236
- currentSettings.value = updated
237
- applySettings(updated)
238
- nextTick(() => {
239
- syncing = false
240
- // Cancel any save timer queued by watchers during the sync
241
- if (saveTimer) { clearTimeout(saveTimer); saveTimer = null }
242
- })
243
- }
244
  showCheck.value = true
245
  checkTimeout = setTimeout(() => (showCheck.value = false), 1500)
246
  } else {
@@ -373,7 +366,6 @@ onBeforeUnmount(() => {
373
  if (posterPreviewTimer) clearTimeout(posterPreviewTimer)
374
  if (logoPreviewTimer) clearTimeout(logoPreviewTimer)
375
  if (backdropPreviewTimer) clearTimeout(backdropPreviewTimer)
376
- if (saveTimer) clearTimeout(saveTimer)
377
  if (posterPreview.value.src) URL.revokeObjectURL(posterPreview.value.src)
378
  if (logoPreview.value.src) URL.revokeObjectURL(logoPreview.value.src)
379
  if (backdropPreview.value.src) URL.revokeObjectURL(backdropPreview.value.src)
 
153
  applySettings(s)
154
  nextTick(() => {
155
  syncing = false
 
 
156
  })
157
  })
158
 
 
162
  nextTick(() => { syncing = false })
163
  }
164
 
165
+ let pendingSave = false
166
+
167
  async function autoSave() {
168
+ if (saving.value) {
169
+ pendingSave = true
170
+ return
171
+ }
172
  saving.value = true
173
  error.value = ''
174
  showCheck.value = false
 
207
  revertEdits()
208
  } finally {
209
  saving.value = false
210
+ if (pendingSave) {
211
+ pendingSave = false
212
+ autoSave()
213
+ }
214
  }
215
  }
216
 
217
+ // Auto-save on any setting change
 
 
218
  watch(
219
  [editSource, editLang, editTextless, editRatingsLimit, editRatingsOrder, editPosterPosition, editLogoRatingsLimit, editBackdropRatingsLimit, editPosterBadgeStyle, editLogoBadgeStyle, editBackdropBadgeStyle, editPosterLabelStyle, editLogoLabelStyle, editBackdropLabelStyle, editPosterBadgeDirection],
220
  () => {
221
  if (syncing) return
222
+ autoSave()
 
223
  },
224
  { deep: true },
225
  )
226
 
227
  async function handleReset() {
228
  if (!props.resetSettings) return
 
229
  saving.value = true
230
  error.value = ''
231
  showCheck.value = false
 
233
  try {
234
  const ok = await props.resetSettings()
235
  if (ok) {
236
+ await props.loadSettings()
 
 
 
 
 
 
 
 
 
 
237
  showCheck.value = true
238
  checkTimeout = setTimeout(() => (showCheck.value = false), 1500)
239
  } else {
 
366
  if (posterPreviewTimer) clearTimeout(posterPreviewTimer)
367
  if (logoPreviewTimer) clearTimeout(logoPreviewTimer)
368
  if (backdropPreviewTimer) clearTimeout(backdropPreviewTimer)
 
369
  if (posterPreview.value.src) URL.revokeObjectURL(posterPreview.value.src)
370
  if (logoPreview.value.src) URL.revokeObjectURL(logoPreview.value.src)
371
  if (backdropPreview.value.src) URL.revokeObjectURL(backdropPreview.value.src)