fix(settings): compare restart notices against the running app, fix CI fallout

The restart notice now lists only the restart controls whose saved value
differs from what the running app uses: the stored values at first load,
or what the embedded engine reports it actually runs for the frame-copy
opt-in. Saving the launch value back withdraws the notice instead of
leaving it up with no chip to explain it. The refresh reads the current
list untracked and writes only a changed one, because the engine probe
effect calls it.

CI: the zoneless checklist dropped the deleted Reset section component, and
the theme-tokens Electron E2E floats the recording folder label on the
Playback page now that the settings selects carry no floating label.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-08 20:48:19 +02:00
1 parent 1f036d2a8e
commit 8717591bb7
6 files changed
+187 -17

No files matched your search

@@ -150,7 +150,12 @@ test.describe('Theme tokens', () => {
).toBeGreaterThanOrEqual(3);
// A floated label inherits the field's text size and renders
// at 75% of it; it must stay legible.
// at 75% of it; it must stay legible. The settings selects
// have no floating label any more (the row title names
// them): the recording folder field on Playback keeps one,
// and focusing it floats the label.
await openSettingsSection(page, 'playback');
await page.locator('#recordingFolder').focus();
const floated = page
.locator('mat-form-field .mdc-floating-label--float-above')
.first();
@@ -1,4 +1,10 @@
import { DestroyRef, inject, Injectable, signal } from '@angular/core';
import {
DestroyRef,
inject,
Injectable,
signal,
untracked,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormArray, FormBuilder } from '@angular/forms';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
@@ -20,6 +26,7 @@ import {
diffSettingsValues,
SETTINGS_RESTART_CONTROLS,
} from './settings-change-tracking';
import { SettingsLaunchValuesService } from './settings-launch-values.service';
import {
applyEpgUrlsToFormArray,
createEpgUrlControl,
@@ -40,6 +47,7 @@ export class SettingsFormFacade {
private readonly destroyRef = inject(DestroyRef);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly formBuilder = inject(FormBuilder);
private readonly launchValues = inject(SettingsLaunchValuesService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsService = inject(SettingsService);
private readonly settingsSnackbar = inject(SettingsSnackbarService);
@@ -61,7 +69,7 @@ export class SettingsFormFacade {
*/
readonly changedPaths = signal<readonly string[]>([]);
/** Controls whose saved change waits for a restart (cleared on Later). */
/** Controls whose saved value differs from what the running app uses. */
readonly restartPendingControls = signal<readonly string[]>([]);
private savedSnapshot: unknown = {};
@@ -105,6 +113,22 @@ export class SettingsFormFacade {
this.setEpgUrls(currentSettings.epgUrl);
}
this.takeSavedSnapshot();
// The app launched with the stored values; recorded once per app run,
// not per settings visit, so a later visit compares against launch.
this.launchValues.captureOnce(
(control) => this.form.get(control)?.value
);
this.refreshRestartPending();
}
/**
* What the running app actually uses for a restart control when that
* differs from the stored value (a frame-copy opt-in the engine could
* not honour). Re-evaluates the pending notice.
*/
setRunningValue(control: string, value: unknown): void {
this.launchValues.set(control, value);
this.refreshRestartPending();
}
bindDashboardControlsEnabledState(): void {
@@ -187,11 +211,6 @@ export class SettingsFormFacade {
this.form,
this.settingsStore.getSettings()
);
// Read before the write: a successful save resets the diff.
const restartControls = this.changedPaths()
.map((path) => path.split('.')[0])
.filter((control) => control in SETTINGS_RESTART_CONTROLS);
let cleanupError: EpgSourceReconciliationError | undefined;
try {
await this.settingsStore.updateSettings(settings, {
@@ -205,14 +224,7 @@ export class SettingsFormFacade {
}
if (!cleanupError) {
onSaved();
if (restartControls.length > 0) {
this.restartPendingControls.set([
...new Set([
...this.restartPendingControls(),
...restartControls,
]),
]);
}
this.refreshRestartPending();
}
if (window.electron) {
@@ -243,6 +255,30 @@ export class SettingsFormFacade {
);
}
/**
* Restart controls whose SAVED value the running app does not use yet.
* Compares the last saved snapshot, never the draft form: a staged but
* unsaved edit must not raise or clear the reminder. Called from an
* effect (the engine probe), so the current list is read untracked and
* only a changed list is written: a fresh array on every run would
* re-trigger that effect for ever.
*/
private refreshRestartPending(): void {
const running = this.launchValues.get();
const saved = (this.savedSnapshot ?? {}) as Record<string, unknown>;
const pending = Object.keys(SETTINGS_RESTART_CONTROLS).filter(
(control) =>
control in running && saved[control] !== running[control]
);
const current = untracked(this.restartPendingControls);
if (
current.length !== pending.length ||
current.some((control, index) => control !== pending[index])
) {
this.restartPendingControls.set(pending);
}
}
private takeSavedSnapshot(): void {
this.savedSnapshot = this.form.getRawValue();
this.refreshChangedPaths();
@@ -0,0 +1,37 @@
import { Injectable } from '@angular/core';
import { SETTINGS_RESTART_CONTROLS } from './settings-change-tracking';
/**
* Values of the restart-only settings the running app started with. Root
* provided on purpose: the settings page (and its form facade) is recreated
* every time it opens, but what the app launched with does not change until
* it restarts, so the restart notice must compare against one app-lifetime
* record.
*/
@Injectable({ providedIn: 'root' })
export class SettingsLaunchValuesService {
private values: Record<string, unknown> | null = null;
/** Records the stored values once, on the first settings open. */
captureOnce(read: (control: string) => unknown): void {
if (this.values) return;
const values: Record<string, unknown> = {};
for (const control of Object.keys(SETTINGS_RESTART_CONTROLS)) {
values[control] = read(control);
}
this.values = values;
}
/**
* What the running app actually uses when that differs from the stored
* value (a frame-copy opt-in the engine could not honour).
*/
set(control: string, value: unknown): void {
if (!(control in SETTINGS_RESTART_CONTROLS)) return;
this.values = { ...(this.values ?? {}), [control]: value };
}
get(): Readonly<Record<string, unknown>> {
return this.values ?? {};
}
}
@@ -505,6 +505,26 @@ describe('SettingsComponent form', () => {
]);
expect(notice()).not.toBeNull();
// Saving the launch value back withdraws the notice: nothing
// waits for a restart any more.
component.settingsForm.get('startupWindowMode')?.setValue('normal');
component.settingsForm.markAsDirty();
component.onSubmit();
await fixture.whenStable();
fixture.detectChanges();
expect(component.form.restartPendingControls()).toEqual([]);
expect(notice()).toBeNull();
component.settingsForm
.get('startupWindowMode')
?.setValue('fullscreen');
component.settingsForm.markAsDirty();
component.onSubmit();
await fixture.whenStable();
fixture.detectChanges();
expect(notice()).not.toBeNull();
(
notice()?.querySelector(
'[data-test-id="settings-restart-later"]'
@@ -515,6 +535,69 @@ describe('SettingsComponent form', () => {
expect(notice()).toBeNull();
});
it('keeps the launch values across settings visits', async () => {
settingsStore.updateSettings.mockResolvedValue(undefined);
settingsStore.updateSettings.mockImplementation((settings) => {
settingsStore._setSettings(settings);
return Promise.resolve(undefined);
});
component.settingsForm
.get('startupWindowMode')
?.setValue('maximized');
component.settingsForm.markAsDirty();
component.onSubmit();
await fixture.whenStable();
expect(component.form.restartPendingControls()).toEqual([
'startupWindowMode',
]);
// Leave settings and come back: the page and its form are new,
// but the app still runs with the launch value.
fixture.destroy();
fixture = TestBed.createComponent(SettingsComponent);
component = fixture.componentInstance;
stubSettingsSideEffects(component);
fixture.detectChanges();
await fixture.whenStable();
expect(component.form.restartPendingControls()).toEqual([
'startupWindowMode',
]);
// Saving the launch value back withdraws the notice here too.
component.settingsForm.get('startupWindowMode')?.setValue('normal');
component.settingsForm.markAsDirty();
component.onSubmit();
await fixture.whenStable();
expect(component.form.restartPendingControls()).toEqual([]);
});
it('compares the frame-copy opt-in against the engine the app runs', async () => {
settingsStore.updateSettings.mockResolvedValue(undefined);
// Stored true, but the engine could not honour it at launch.
component.form.setRunningValue('embeddedMpvFrameCopy', false);
component.settingsForm.get('embeddedMpvFrameCopy')?.setValue(true);
component.settingsForm.markAsDirty();
component.onSubmit();
await fixture.whenStable();
expect(component.form.restartPendingControls()).toEqual([
'embeddedMpvFrameCopy',
]);
// A staged, unsaved edit back to the launch value changes nothing:
// the saved setting still needs the restart.
component.settingsForm.get('embeddedMpvFrameCopy')?.setValue(false);
component.form.setRunningValue('embeddedMpvFrameCopy', false);
expect(component.form.restartPendingControls()).toEqual([
'embeddedMpvFrameCopy',
]);
// The engine catches up (a later probe reports frame copy): the
// notice goes without another save.
component.form.setRunningValue('embeddedMpvFrameCopy', true);
expect(component.form.restartPendingControls()).toEqual([]);
});
it('discard reverts a staged cover size (regression: eager persist made it stick)', () => {
const largeCoverButton = (
fixture.nativeElement as HTMLElement
@@ -296,6 +296,16 @@ export class SettingsComponent
}
this.settingsCtx.setDirtySections(sections);
});
// The frame-copy opt-in may not be what the engine runs (it was not
// available at launch): the restart notice compares against that.
effect(() => {
if (this.embeddedMpv.support()) {
this.form.setRunningValue(
'embeddedMpvFrameCopy',
this.embeddedMpv.frameCopyActive()
);
}
});
effect(() => {
const status = this.appUpdate.status()?.status;
this.settingsCtx.setVersion(this.appUpdate.version() || null);
-1
View File
@@ -85,7 +85,6 @@ picker), so each marks itself on the form's `events` through
- [x] `apps/web/src/app/settings/settings-general-section.component.ts`
- [x] `apps/web/src/app/settings/settings-playback-section.component.ts`
- [x] `apps/web/src/app/settings/settings-remote-control-section.component.ts`
- [x] `apps/web/src/app/settings/settings-reset-section.component.ts`
- [x] `apps/web/src/app/settings/settings-tmdb-section.component.ts`
- [x] `apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts`