From 68ad519bd9fc68db3ed5fd4018b83eb09c9db744 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Fri, 23 Oct 2026 15:52:58 +0300 Subject: [PATCH] fixed hidden quiet hours fields blocking save --- .../Seeker/SeekerConfigControllerTests.cs | 1 + .../Configuration/Seeker/SeekerConfigTests.cs | 20 ++++++++ .../Configuration/Seeker/SeekerConfig.cs | 2 +- .../settings/seeker/seeker.component.spec.ts | 47 +++++++++++++++++++ .../settings/seeker/seeker.component.ts | 15 ++++-- e2e/tests/seeker/seeker-config.api.spec.ts | 4 +- 6 files changed, 82 insertions(+), 7 deletions(-) diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerConfigControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerConfigControllerTests.cs index 63090380..7bd79400 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerConfigControllerTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SeekerConfigControllerTests.cs @@ -252,6 +252,7 @@ public class SeekerConfigControllerTests : IDisposable { SearchEnabled = true, SearchInterval = 5, + ProactiveSearchEnabled = true, QuietHoursEnabled = true, QuietHoursStart = new TimeOnly(7, 0), QuietHoursEnd = new TimeOnly(7, 0), diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Seeker/SeekerConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Seeker/SeekerConfigTests.cs index da049828..f73aa9d5 100644 --- a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Seeker/SeekerConfigTests.cs +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Seeker/SeekerConfigTests.cs @@ -107,6 +107,8 @@ public sealed class SeekerConfigTests { SeekerConfig config = new() { + ProactiveSearchEnabled = true, + QuietHoursEnabled = true, QuietHoursStart = new TimeOnly(5, 0), QuietHoursEnd = new TimeOnly(5, 0) }; @@ -114,6 +116,24 @@ public sealed class SeekerConfigTests Should.Throw(() => config.Validate()); } + [Theory] + [InlineData(false, true, true)] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + public void Validate_WithEqualQuietHoursThatNeverApply_DoesNotThrow(bool searchEnabled, bool proactiveSearchEnabled, bool quietHoursEnabled) + { + SeekerConfig config = new() + { + SearchEnabled = searchEnabled, + ProactiveSearchEnabled = proactiveSearchEnabled, + QuietHoursEnabled = quietHoursEnabled, + QuietHoursStart = new TimeOnly(5, 0), + QuietHoursEnd = new TimeOnly(5, 0) + }; + + Should.NotThrow(() => config.Validate()); + } + [Fact] public void IsQuietTime_WhenDisabled_ReturnsFalse() { diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Seeker/SeekerConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Seeker/SeekerConfig.cs index d8c97ee9..d85fd57d 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/Seeker/SeekerConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Seeker/SeekerConfig.cs @@ -90,7 +90,7 @@ public sealed record SeekerConfig : IConfig throw new CustomValidationException($"{nameof(PostReleaseGraceHours)} must be between 0 and 72"); } - if (QuietHoursStart == QuietHoursEnd) + if (SearchEnabled && ProactiveSearchEnabled && QuietHoursEnabled && QuietHoursStart == QuietHoursEnd) { throw new CustomValidationException($"{nameof(QuietHoursStart)} and {nameof(QuietHoursEnd)} must differ"); } diff --git a/code/frontend/src/app/features/settings/seeker/seeker.component.spec.ts b/code/frontend/src/app/features/settings/seeker/seeker.component.spec.ts index be125686..a614f712 100644 --- a/code/frontend/src/app/features/settings/seeker/seeker.component.spec.ts +++ b/code/frontend/src/app/features/settings/seeker/seeker.component.spec.ts @@ -608,6 +608,53 @@ describe('SeekerComponent', () => { expect(saveButton(fixture).disabled).toBe(false); }); + it('blocks saving while a visible quiet hours time is cleared', () => { + const { fixture, component } = setup(); + + component.seekerForm.quietHoursStart().value.set(''); + fixture.detectChanges(); + + expect(component.hasErrors()).toBe(true); + expect(saveButton(fixture).disabled).toBe(true); + }); + + it('allows saving a cleared quiet hours time once quiet hours are off', () => { + const { fixture, component } = setup(); + + component.seekerForm.quietHoursStart().value.set(''); + component.seekerForm.quietHoursEnabled().value.set(false); + fixture.detectChanges(); + + expect(component.hasErrors()).toBe(false); + expect(saveButton(fixture).disabled).toBe(false); + }); + + it('allows saving a cleared quiet hours time once proactive search is off', () => { + const { fixture, component } = setup(); + + component.seekerForm.quietHoursEnd().value.set(''); + component.seekerForm.proactiveSearchEnabled().value.set(false); + fixture.detectChanges(); + + expect(component.hasErrors()).toBe(false); + expect(saveButton(fixture).disabled).toBe(false); + }); + + it('sends default quiet hours for cleared times', () => { + const { fixture, component, updateConfig } = setup(); + + component.seekerForm.quietHoursStart().value.set(''); + component.seekerForm.quietHoursEnd().value.set(''); + component.seekerForm.quietHoursEnabled().value.set(false); + fixture.detectChanges(); + + component.save(); + + expect(updateConfig).toHaveBeenCalledWith( + expect.objectContaining({ quietHoursStart: '23:00', quietHoursEnd: '07:00' }), + ); + }); + it('confirms before turning round robin off and leaves it on when cancelled', async () => { const { fixture, component, confirm } = setup(); diff --git a/code/frontend/src/app/features/settings/seeker/seeker.component.ts b/code/frontend/src/app/features/settings/seeker/seeker.component.ts index 397d4258..9fcab49c 100644 --- a/code/frontend/src/app/features/settings/seeker/seeker.component.ts +++ b/code/frontend/src/app/features/settings/seeker/seeker.component.ts @@ -144,14 +144,19 @@ export class SeekerComponent implements HasPendingChanges { readonly seekerForm = form(this.model, (p) => { min(p.postReleaseGraceHours, 0); max(p.postReleaseGraceHours, 72); - required(p.quietHoursStart, { message: 'Start time is required' }); - required(p.quietHoursEnd, { message: 'End time is required' }); + required(p.quietHoursStart, { when: () => this.quietHoursVisible(), message: 'Start time is required' }); + required(p.quietHoursEnd, { when: () => this.quietHoursVisible(), message: 'End time is required' }); validate(p.quietHoursEnd, ({ valueOf }) => - valueOf(p.quietHoursStart) === valueOf(p.quietHoursEnd) + this.quietHoursVisible() && valueOf(p.quietHoursStart) === valueOf(p.quietHoursEnd) ? { kind: 'sameTime', message: 'Start and end must differ' } : undefined); }); + private quietHoursVisible(): boolean { + const m: SeekerFormModel = this.model(); + return m.searchEnabled && m.proactiveSearchEnabled && m.quietHoursEnabled; + } + readonly instances = signal([]); readonly strategyDescription = computed(() => STRATEGY_DESCRIPTIONS[this.model().selectionStrategy] ?? ''); @@ -283,8 +288,8 @@ export class SeekerComponent implements HasPendingChanges { useRoundRobin: m.useRoundRobin, postReleaseGraceHours: m.postReleaseGraceHours ?? 6, quietHoursEnabled: m.quietHoursEnabled, - quietHoursStart: m.quietHoursStart, - quietHoursEnd: m.quietHoursEnd, + quietHoursStart: m.quietHoursStart || '23:00', + quietHoursEnd: m.quietHoursEnd || '07:00', instances: this.instances().map(i => ({ arrInstanceId: i.arrInstanceId, enabled: i.enabled, diff --git a/e2e/tests/seeker/seeker-config.api.spec.ts b/e2e/tests/seeker/seeker-config.api.spec.ts index 90ce43e2..21bda238 100644 --- a/e2e/tests/seeker/seeker-config.api.spec.ts +++ b/e2e/tests/seeker/seeker-config.api.spec.ts @@ -78,7 +78,9 @@ test.describe.serial('Seeker — config', () => { const current = await (await api.seeker.getConfig()).json(); const res = await api.seeker.updateConfig({ ...current, - quietHoursEnabled: false, + searchEnabled: true, + proactiveSearchEnabled: true, + quietHoursEnabled: true, quietHoursStart: '08:00', quietHoursEnd: '08:00', });