test(settings): move the storage-failure coverage to its own spec

Rebase follow-up. #1274 split the settings page into facades and #1277 split
its spec along the same seams, so this PR's renderer changes moved:

- the failed-read warning is now raised by `SettingsFormFacade.loadSettings()`,
  which owns the store call
- the cover-size and EPG-view-mode writes moved into the facade, so their
  rejection handling went with them
- `showStorageFailure()` became `SettingsSnackbarService.storageFailure()`,
  because the component and the facade both need it and neither can reach the
  other's copy
- `onSubmit()` still owns the dialog, so the catch and the "close only after
  the write succeeded" ordering stay in the component

The four regression tests live in `settings.component.storage-failure.spec.ts`
rather than being appended to `settings.component.form.spec.ts`, which would
have pushed that file to 427 lines, past the 400-line lint maximum. They assert
against the MatDialog the component actually calls — spying on the instance
from `TestBed.inject(MatDialog)` does not observe it — and one of them stubs
the post-save EPG fetch, since a throw there would otherwise land in the same
catch and look like a failed write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-27 20:32:56 +02:00
1 parent 9f8713bdcb
commit 4779642e6e
3 files changed
+128 -78

No files matched your search

@@ -31,19 +31,6 @@ export class SettingsSnackbarService {
});
}
/**
* Failures need more reading time than the 2s confirmation toast, and a
* dismiss action so the message can stay up until it is acknowledged.
*/
error(message: string, dismissLabel: string): void {
this.snackBar.open(message, dismissLabel, {
duration: 10000,
horizontalPosition: 'center',
verticalPosition: 'bottom',
panelClass: ['settings-snackbar', 'settings-snackbar--error'],
});
}
/**
* Reports a failed settings read/write. Both mean the same thing to the
* user — what is on screen will not survive a restart — so they get a
@@ -1,9 +1,7 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
CoverSize,
Language,
StartupBehavior,
Theme,
@@ -23,11 +21,6 @@ import {
stubSettingsSideEffects,
} from './test-stubs/settings-test-harness.stub';
/** Matches the snackbar config used by `SettingsSnackbarService.error`. */
const ERROR_SNACKBAR_CONFIG = expect.objectContaining({
panelClass: ['settings-snackbar', 'settings-snackbar--error'],
});
/**
* Everything the rendered settings form does: hydration from the store, the
* section outputs that write straight through, and the submit path.
@@ -78,18 +71,6 @@ describe('SettingsComponent form', () => {
expect(component.settingsForm.value).toEqual(DEFAULT_SETTINGS);
});
it('warns when the persisted settings could not be read', async () => {
settingsStore.storageFailure.set('load');
await component.ngOnInit();
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.SETTINGS_LOAD_FAILED',
'CLOSE',
ERROR_SNACKBAR_CONFIG
);
});
it('should get and apply custom settings', () => {
settingsStore._setSettings({
...DEFAULT_SETTINGS,
@@ -267,52 +248,6 @@ describe('SettingsComponent form', () => {
});
});
it('warns and keeps the dialog open when the settings write fails', async () => {
settingsStore.updateSettings.mockRejectedValue(
new Error('storage unavailable')
);
const matDialog = TestBed.inject(MatDialog);
const closeAll = jest.spyOn(matDialog, 'closeAll');
component.isDialog = true;
component.onSubmit();
await fixture.whenStable();
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.SETTINGS_SAVE_FAILED',
'CLOSE',
ERROR_SNACKBAR_CONFIG
);
expect(closeAll).not.toHaveBeenCalled();
});
it('closes the dialog only after the settings write succeeded', async () => {
settingsStore.updateSettings.mockResolvedValue(undefined);
const matDialog = TestBed.inject(MatDialog);
const closeAll = jest.spyOn(matDialog, 'closeAll');
component.isDialog = true;
component.onSubmit();
await fixture.whenStable();
expect(closeAll).toHaveBeenCalledTimes(1);
});
it('warns when a section write fails without leaving an unhandled rejection', async () => {
settingsStore.updateSettings.mockRejectedValue(
new Error('storage unavailable')
);
component.form.selectCoverSize(CoverSize.Large);
await fixture.whenStable();
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.SETTINGS_SAVE_FAILED',
'CLOSE',
ERROR_SNACKBAR_CONFIG
);
});
it('saves the shared web controls setting on submit', async () => {
settingsStore.updateSettings.mockResolvedValue(undefined);
component.settingsForm
@@ -0,0 +1,128 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { SettingsStore } from '../services/settings-store.service';
import { SettingsComponent } from './settings.component';
import {
configureSettingsComponentTestBed,
createElectronStub,
createEpgBridgeStub,
MatSnackBarStub,
MockSettingsStore,
stubSettingsSideEffects,
} from './test-stubs/settings-test-harness.stub';
/** Matches the snackbar config used by `SettingsSnackbarService.error`. */
const ERROR_SNACKBAR_CONFIG = expect.objectContaining({
panelClass: ['settings-snackbar', 'settings-snackbar--error'],
});
/**
* Assertions target the MatDialog the component actually calls: spying on the
* one returned by `TestBed.inject(MatDialog)` does not observe it.
*/
interface SettingsComponentPrivateTestApi {
matDialog: MatDialog;
}
/**
* Settings live in the renderer's IndexedDB, and both halves of the round trip
* can fail while the UI keeps looking healthy: a failed read shows defaults as
* if they were saved, and a failed write is applied in memory so it survives
* until the next restart. These cover the user-visible reporting of both.
*/
describe('SettingsComponent storage failures', () => {
let component: SettingsComponent;
let fixture: ComponentFixture<SettingsComponent>;
let settingsStore: MockSettingsStore;
let snackBar: MatSnackBarStub;
let epgBridge: Partial<EpgRuntimeBridgeService>;
const originalElectron = window.electron;
const componentMatDialog = (): MatDialog =>
(component as unknown as SettingsComponentPrivateTestApi).matDialog;
beforeEach(waitForAsync(() => {
epgBridge = createEpgBridgeStub();
configureSettingsComponentTestBed(epgBridge);
}));
beforeEach(() => {
window.electron = createElectronStub();
fixture = TestBed.createComponent(SettingsComponent);
settingsStore = TestBed.inject(
SettingsStore
) as unknown as MockSettingsStore;
snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarStub;
component = fixture.componentInstance;
stubSettingsSideEffects(component);
fixture.detectChanges();
});
afterEach(() => {
window.electron = originalElectron;
});
it('warns when the persisted settings could not be read', async () => {
settingsStore.storageFailure.set('load');
await component.ngOnInit();
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.SETTINGS_LOAD_FAILED',
'CLOSE',
ERROR_SNACKBAR_CONFIG
);
});
it('warns and keeps the dialog open when the settings write fails', async () => {
settingsStore.updateSettings.mockRejectedValue(
new Error('storage unavailable')
);
const closeAll = jest.spyOn(componentMatDialog(), 'closeAll');
component.isDialog = true;
component.onSubmit();
await fixture.whenStable();
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.SETTINGS_SAVE_FAILED',
'CLOSE',
ERROR_SNACKBAR_CONFIG
);
expect(closeAll).not.toHaveBeenCalled();
});
it('closes the dialog only after the settings write succeeded', async () => {
settingsStore.updateSettings.mockResolvedValue(undefined);
const closeAll = jest.spyOn(componentMatDialog(), 'closeAll');
jest.spyOn(component.epg, 'fetchConfiguredEpg').mockImplementation();
component.isDialog = true;
component.onSubmit();
// save() resolves a tick after the store write: the callback and
// the Electron mirror run first, so the close lands on the next
// turn of the microtask queue.
await fixture.whenStable();
await fixture.whenStable();
expect(closeAll).toHaveBeenCalledTimes(1);
});
it('warns when a section write fails without leaving an unhandled rejection', async () => {
settingsStore.updateSettings.mockRejectedValue(
new Error('storage unavailable')
);
component.form.selectCoverSize('large');
await fixture.whenStable();
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.SETTINGS_SAVE_FAILED',
'CLOSE',
ERROR_SNACKBAR_CONFIG
);
});
});