mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
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:
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);
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
Reference in new issue
Block a user