Files
iptvnator/apps/web/src/app/settings/settings-form.utils.spec.ts
T
4grayandClaude Fable 5.1 6f7973a9fb feat(updater): nightly builds and a stable/nightly update channel (#1608)
* feat(updater): nightly builds and a stable/nightly update channel

Every master push publishes its artifacts as a prerelease of
4gray/iptvnator-nightly instead of the rolling test-master draft, with a
version of <next patch>-nightly.<commit date>.<run number> applied in
every build job. Settings → About gains an Update channel switch;
AppUpdateService re-points electron-updater per check (feed repository,
allowPrerelease, channel name, allowDowngrade reset) and reads release
notes from the repository the requested version belongs to. Channel
switches are forward-only: a nightly build stays until a newer stable
release exists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(updater): compute the nightly version once and keep re-runs safe

Review follow-ups: the nightly version is resolved by a leading job and
handed to every build job, and the patch is bumped only when the base
tag already exists so the release-cut window stays below the imminent
release. A re-run never deletes a published nightly; only a draft left
by a failed run is replaced. Typed update-status literals in the
remaining specs carry the new channel fields.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(packaging): expect the nightly-version prerequisite in the build workflow graph

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 17:49:22 +02:00

259 lines
8.8 KiB
TypeScript

import { FormBuilder } from '@angular/forms';
import { Settings, VideoPlayer } from '@iptvnator/shared/interfaces';
import {
createEpgUrlControl,
createSettingsForm,
createSettingsFromFormValue,
} from './settings-form.utils';
describe('settings form utils — strip country prefix', () => {
const formBuilder = new FormBuilder();
it('defaults portal request pauses on and preserves an explicit opt-out', () => {
const form = createSettingsForm(new FormBuilder(), true);
expect(form.get('portalConnectivityGuard')?.value).toBe(true);
form.patchValue({ portalConnectivityGuard: false });
expect(
createSettingsFromFormValue(form, {} as Settings)
.portalConnectivityGuard
).toBe(false);
});
it('defaults the form control to false', () => {
const form = createSettingsForm(formBuilder, true);
expect(form.getRawValue().stripCountryPrefix).toBe(false);
});
it('carries an enabled toggle into the settings object', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({ stripCountryPrefix: true });
const settings = createSettingsFromFormValue(form, {} as Settings);
expect(settings.stripCountryPrefix).toBe(true);
});
it('falls back to false when the form value is missing', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({
stripCountryPrefix: null as unknown as boolean,
});
const settings = createSettingsFromFormValue(form, {} as Settings);
expect(settings.stripCountryPrefix).toBe(false);
});
});
describe('settings form utils — startup window mode', () => {
const formBuilder = new FormBuilder();
it('defaults the form control to a normal window', () => {
const form = createSettingsForm(formBuilder, true);
expect(form.getRawValue().startupWindowMode).toBe('normal');
});
it('carries the chosen mode into the settings object', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({ startupWindowMode: 'fullscreen' });
const settings = createSettingsFromFormValue(form, {} as Settings);
expect(settings.startupWindowMode).toBe('fullscreen');
});
it('collapses a missing or unknown form value to normal', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({
startupWindowMode: null as unknown as 'normal',
});
expect(
createSettingsFromFormValue(form, {} as Settings).startupWindowMode
).toBe('normal');
form.patchValue({
startupWindowMode: 'kiosk' as unknown as 'normal',
});
expect(
createSettingsFromFormValue(form, {} as Settings).startupWindowMode
).toBe('normal');
});
});
describe('settings form utils — update channel', () => {
const formBuilder = new FormBuilder();
it('defaults the form control to the stable channel', () => {
const form = createSettingsForm(formBuilder, true);
expect(form.getRawValue().updateChannel).toBe('stable');
});
it('carries the chosen channel into the settings object', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({ updateChannel: 'nightly' });
expect(
createSettingsFromFormValue(form, {} as Settings).updateChannel
).toBe('nightly');
});
it('collapses a missing or unknown channel to stable', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({ updateChannel: null as unknown as 'stable' });
expect(
createSettingsFromFormValue(form, {} as Settings).updateChannel
).toBe('stable');
form.patchValue({ updateChannel: 'canary' as unknown as 'stable' });
expect(
createSettingsFromFormValue(form, {} as Settings).updateChannel
).toBe('stable');
});
});
describe('settings form utils — EPG display offset', () => {
const formBuilder = new FormBuilder();
it('defaults the form control to zero and only exists when EPG is supported', () => {
expect(
createSettingsForm(formBuilder, true).getRawValue().epgOffsetMinutes
).toBe(0);
expect(
'epgOffsetMinutes' in
createSettingsForm(formBuilder, false).controls
).toBe(false);
});
it('carries a whole-minute offset into the settings object', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({ epgOffsetMinutes: -90 });
expect(form.valid).toBe(true);
expect(
createSettingsFromFormValue(form, {} as Settings).epgOffsetMinutes
).toBe(-90);
});
it('rejects blanks, fractions and out-of-range values in the form', () => {
const form = createSettingsForm(formBuilder, true);
const control = form.get('epgOffsetMinutes');
control?.setValue(null);
expect(control?.valid).toBe(false);
control?.setValue(15.5);
expect(control?.valid).toBe(false);
control?.setValue(721);
expect(control?.valid).toBe(false);
control?.setValue(-720);
expect(control?.valid).toBe(true);
});
it('keeps the stored offset when EPG is unsupported and the control is absent', () => {
const form = createSettingsForm(formBuilder, false);
expect(
createSettingsFromFormValue(form, {
epgOffsetMinutes: 45,
} as Settings).epgOffsetMinutes
).toBe(45);
});
});
describe('settings form utils — embedded MPV session options', () => {
const formBuilder = new FormBuilder();
it('defaults to no extra options and auto-reconnect on', () => {
const form = createSettingsForm(formBuilder, true);
expect(form.getRawValue().embeddedMpvExtraOptions).toBe('');
expect(form.getRawValue().embeddedMpvAutoReconnect).toBe(true);
expect(form.valid).toBe(true);
});
it('rejects forbidden keys and malformed lines in the options field', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({ player: VideoPlayer.EmbeddedMpv });
const control = form.get('embeddedMpvExtraOptions');
control?.setValue('hwdec=auto\nvo=null\nnot an option');
expect(control?.errors).toEqual({
invalidLines: ['not an option'],
forbiddenKeys: ['vo'],
});
expect(form.valid).toBe(false);
control?.setValue('hwdec=auto\ncache-secs=30');
expect(control?.errors).toBeNull();
});
it('ignores extra-option errors while another player is selected', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({ player: VideoPlayer.EmbeddedMpv });
const control = form.get('embeddedMpvExtraOptions');
control?.setValue('vo=null');
expect(form.valid).toBe(false);
form.patchValue({ player: VideoPlayer.VideoJs });
expect(control?.errors).toBeNull();
expect(form.valid).toBe(true);
form.patchValue({ player: VideoPlayer.EmbeddedMpv });
expect(control?.errors).toEqual({ forbiddenKeys: ['vo'] });
expect(form.valid).toBe(false);
});
it('carries canonical options and the reconnect opt-out into the settings object', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({
embeddedMpvExtraOptions: ' --hwdec = auto \r\n\ncache-secs=30 ',
embeddedMpvAutoReconnect: false,
});
const settings = createSettingsFromFormValue(form, {} as Settings);
expect(settings.embeddedMpvExtraOptions).toBe(
'hwdec=auto\ncache-secs=30'
);
expect(settings.embeddedMpvAutoReconnect).toBe(false);
});
it('falls back to auto-reconnect on when the form value is missing', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({
embeddedMpvAutoReconnect: null as unknown as boolean,
});
const settings = createSettingsFromFormValue(form, {} as Settings);
expect(settings.embeddedMpvAutoReconnect).toBe(true);
});
});
describe('settings form utils — EPG source control', () => {
it.each([
'https://epg.example.org/guide.xml.gz',
'/home/user/epg/guide.xml',
'C:\\epg\\guide.xml.gz',
'file:///home/user/epg/guide.xml',
'',
])('accepts %j', (value) => {
expect(createEpgUrlControl(value).valid).toBe(true);
});
it.each(['guide.xml', 'ftp://epg.example.org/guide.xml', 'epg'])(
'rejects %j',
(value) => {
const control = createEpgUrlControl(value);
expect(control.valid).toBe(false);
expect(control.errors).toEqual({ epgSourceReference: true });
}
);
});