mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
* perf(web): keep backup/restore and portal helpers off the initial path #1601 (parental lock) put about 35 KB onto the renderer's initial path by design (the lock service, lock store and enforcement gate the workspace resolver and the catalog data sources) and was merged with the ratchet red: renderer.initialBytes 1,655,428 against the 1,619,993 baseline. Offset it without touching the lock gate. Code splitting puts a module in the chunk shared by every entry that reaches it, so helpers only lazy routes use landed in initial chunks because eager files reach them through barrels: - PlaylistBackupService (only the lazy settings page) moves to @iptvnator/services/playlist-backup and out of the services barrel. - The eager Xtream data layer and root shell import the portal logger and DI tokens through @iptvnator/portal/shared/util/logger and /tokens instead of the barrel, whose navigation, keyboard-shortcut and download helpers (about 45 KB) belong to the lazy portal routes. renderer.initialBytes 1,655,428 -> 1,598,232 bytes (-57,196). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(performance): lower the initial-bytes baseline to 1,598,232 bytes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
204 lines
7.3 KiB
TypeScript
204 lines
7.3 KiB
TypeScript
import { TestBed } from '@angular/core/testing';
|
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
|
import {
|
|
selectAllPlaylistsMeta,
|
|
selectIsEpgAvailable,
|
|
} from '@iptvnator/m3u-state';
|
|
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
|
import { PlaylistBackupService } from '@iptvnator/services/playlist-backup';
|
|
import { provideMockStore } from '@ngrx/store/testing';
|
|
import { TranslateModule } from '@ngx-translate/core';
|
|
import { MockProvider } from 'ng-mocks';
|
|
import { SettingsBackupFacade } from './settings-backup.facade';
|
|
import { SettingsSnackbarService } from './settings-snackbar.service';
|
|
import {
|
|
BACKUP_EXPORT_RESULT,
|
|
createElectronStub,
|
|
MatSnackBarStub,
|
|
} from './test-stubs/settings-test-harness.stub';
|
|
|
|
describe('SettingsBackupFacade', () => {
|
|
let facade: SettingsBackupFacade;
|
|
let playlistBackupService: PlaylistBackupService;
|
|
const originalElectron = window.electron;
|
|
|
|
/** The component normally supplies a paint frame; tests skip the wait */
|
|
const noWait = () => Promise.resolve();
|
|
|
|
function configure(): void {
|
|
TestBed.configureTestingModule({
|
|
providers: [
|
|
SettingsBackupFacade,
|
|
SettingsSnackbarService,
|
|
{ provide: MatSnackBar, useClass: MatSnackBarStub },
|
|
MockProvider(PlaylistBackupService, {
|
|
exportBackup: jest
|
|
.fn()
|
|
.mockResolvedValue(BACKUP_EXPORT_RESULT),
|
|
importBackup: jest.fn(),
|
|
}),
|
|
MockProvider(XtreamStore, {
|
|
reconcilePendingRestoreBlock: jest.fn(),
|
|
}),
|
|
provideMockStore({
|
|
selectors: [
|
|
{ selector: selectAllPlaylistsMeta, value: [] },
|
|
{ selector: selectIsEpgAvailable, value: false },
|
|
],
|
|
}),
|
|
],
|
|
imports: [TranslateModule.forRoot()],
|
|
});
|
|
|
|
facade = TestBed.inject(SettingsBackupFacade);
|
|
playlistBackupService = TestBed.inject(PlaylistBackupService);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
window.electron = createElectronStub();
|
|
});
|
|
|
|
afterEach(() => {
|
|
window.electron = originalElectron;
|
|
});
|
|
|
|
it('shows an export busy state until the backup file has been written', async () => {
|
|
configure();
|
|
let resolveExport: (value: typeof BACKUP_EXPORT_RESULT) => void = () =>
|
|
undefined;
|
|
(playlistBackupService.exportBackup as jest.Mock).mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveExport = resolve;
|
|
})
|
|
);
|
|
|
|
const exportPromise = facade.exportData(noWait);
|
|
|
|
expect(facade.isExportingData()).toBe(true);
|
|
|
|
resolveExport(BACKUP_EXPORT_RESULT);
|
|
await exportPromise;
|
|
|
|
expect(window.electron.saveFileDialog).toHaveBeenCalledWith(
|
|
BACKUP_EXPORT_RESULT.defaultFileName,
|
|
[
|
|
{
|
|
extensions: ['json'],
|
|
name: 'JSON',
|
|
},
|
|
]
|
|
);
|
|
expect(window.electron.writeFile).toHaveBeenCalledWith(
|
|
'/tmp/backup.json',
|
|
'{}'
|
|
);
|
|
expect(facade.isExportingData()).toBe(false);
|
|
});
|
|
|
|
it('falls back to browser backup download when desktop file-save preload is incomplete', async () => {
|
|
const saveFileDialog = jest.fn().mockResolvedValue('/tmp/backup.json');
|
|
window.electron = {
|
|
platform: 'linux',
|
|
saveFileDialog,
|
|
} as unknown as typeof window.electron;
|
|
configure();
|
|
|
|
const createObjectURL = jest.fn().mockReturnValue('blob:backup');
|
|
const revokeObjectURL = jest.fn();
|
|
const originalCreateObjectURL = window.URL.createObjectURL;
|
|
const originalRevokeObjectURL = window.URL.revokeObjectURL;
|
|
Object.defineProperty(window.URL, 'createObjectURL', {
|
|
configurable: true,
|
|
value: createObjectURL,
|
|
});
|
|
Object.defineProperty(window.URL, 'revokeObjectURL', {
|
|
configurable: true,
|
|
value: revokeObjectURL,
|
|
});
|
|
const clickSpy = jest
|
|
.spyOn(HTMLAnchorElement.prototype, 'click')
|
|
.mockImplementation();
|
|
|
|
try {
|
|
await facade.exportData(noWait);
|
|
|
|
expect(saveFileDialog).not.toHaveBeenCalled();
|
|
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
|
|
expect(clickSpy).toHaveBeenCalled();
|
|
expect(revokeObjectURL).toHaveBeenCalledWith('blob:backup');
|
|
} finally {
|
|
clickSpy.mockRestore();
|
|
Object.defineProperty(window.URL, 'createObjectURL', {
|
|
configurable: true,
|
|
value: originalCreateObjectURL,
|
|
});
|
|
Object.defineProperty(window.URL, 'revokeObjectURL', {
|
|
configurable: true,
|
|
value: originalRevokeObjectURL,
|
|
});
|
|
}
|
|
});
|
|
|
|
it('reports a failed export and clears the busy state', async () => {
|
|
configure();
|
|
(playlistBackupService.exportBackup as jest.Mock).mockRejectedValueOnce(
|
|
new Error('disk full')
|
|
);
|
|
jest.spyOn(console, 'error').mockImplementation();
|
|
const snackBar = TestBed.inject(
|
|
MatSnackBar
|
|
) as unknown as MatSnackBarStub;
|
|
|
|
await facade.exportData(noWait);
|
|
|
|
expect(facade.isExportingData()).toBe(false);
|
|
expect(snackBar.open).toHaveBeenCalledWith(
|
|
'Playlist backup export failed.',
|
|
undefined,
|
|
expect.objectContaining({ panelClass: ['settings-snackbar'] })
|
|
);
|
|
});
|
|
|
|
it('reconciles a pending Xtream restore before completing an import', async () => {
|
|
configure();
|
|
const input = document.createElement('input');
|
|
const file = {
|
|
text: jest.fn().mockResolvedValue('{}'),
|
|
} as unknown as File;
|
|
Object.defineProperty(input, 'files', { value: [file] });
|
|
const addEventListener = jest.spyOn(input, 'addEventListener');
|
|
const createElement = jest
|
|
.spyOn(document, 'createElement')
|
|
.mockReturnValue(input);
|
|
jest.spyOn(input, 'click').mockImplementation();
|
|
const summary = {
|
|
imported: 0,
|
|
merged: 0,
|
|
skipped: 0,
|
|
failed: 1,
|
|
errors: ['pending restore state could not be consumed'],
|
|
};
|
|
(playlistBackupService.importBackup as jest.Mock).mockResolvedValue(
|
|
summary
|
|
);
|
|
const onImported = jest.fn();
|
|
const xtreamStore = TestBed.inject(XtreamStore);
|
|
|
|
facade.importData(onImported);
|
|
const changeListener = addEventListener.mock.calls.find(
|
|
([type]) => type === 'change'
|
|
)?.[1] as (event: Event) => Promise<void>;
|
|
await changeListener({ target: input } as unknown as Event);
|
|
|
|
expect(xtreamStore.reconcilePendingRestoreBlock).toHaveBeenCalledTimes(
|
|
1
|
|
);
|
|
expect(onImported).toHaveBeenCalledTimes(1);
|
|
expect(
|
|
(xtreamStore.reconcilePendingRestoreBlock as jest.Mock).mock
|
|
.invocationCallOrder[0]
|
|
).toBeLessThan(onImported.mock.invocationCallOrder[0]);
|
|
createElement.mockRestore();
|
|
});
|
|
});
|