mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
* 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>
259 lines
8.8 KiB
TypeScript
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 });
|
|
}
|
|
);
|
|
});
|