diff --git a/apps/web/src/app/settings/settings-snackbar.service.ts b/apps/web/src/app/settings/settings-snackbar.service.ts index e4e8fb985..1b1166d7b 100644 --- a/apps/web/src/app/settings/settings-snackbar.service.ts +++ b/apps/web/src/app/settings/settings-snackbar.service.ts @@ -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 diff --git a/apps/web/src/app/settings/settings.component.form.spec.ts b/apps/web/src/app/settings/settings.component.form.spec.ts index d158d11a6..c11db5c82 100644 --- a/apps/web/src/app/settings/settings.component.form.spec.ts +++ b/apps/web/src/app/settings/settings.component.form.spec.ts @@ -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 diff --git a/apps/web/src/app/settings/settings.component.storage-failure.spec.ts b/apps/web/src/app/settings/settings.component.storage-failure.spec.ts new file mode 100644 index 000000000..94fe3a9a6 --- /dev/null +++ b/apps/web/src/app/settings/settings.component.storage-failure.spec.ts @@ -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; + let settingsStore: MockSettingsStore; + let snackBar: MatSnackBarStub; + let epgBridge: Partial; + 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 + ); + }); +});