- Reset download_phase when transitioning to paused (pause(), update_status(),
_post_download_cleanup()) so UI shows 'Paused' instead of stale phase label
- Reset download_phase when starting/resuming download (start() preparing state)
- Count 'postprocessing' as active download in updateMetrics() so dashboard
shows accurate counts during ffmpeg post-processing
- Add missing completedDownload Subject to DownloadsServiceStub in app.spec.ts
- Add Download type import to app.spec.ts
Fixes: TypeError Cannot read properties of undefined (reading 'pipe')
at app.ts:300 (completedDownload not in test stub)
248 lines
7.8 KiB
TypeScript
248 lines
7.8 KiB
TypeScript
import { TestBed } from '@angular/core/testing';
|
|
import { HttpClient } from '@angular/common/http';
|
|
import { Subject, of } from 'rxjs';
|
|
import { App } from './app';
|
|
import { DownloadsService } from './services/downloads.service';
|
|
import { SubscriptionsService } from './services/subscriptions.service';
|
|
import { CookieService } from 'ngx-cookie-service';
|
|
import { Download } from './interfaces';
|
|
|
|
class DownloadsServiceStub {
|
|
loading = false;
|
|
queue = new Map();
|
|
done = new Map();
|
|
configuration: Record<string, unknown> = { CUSTOM_DIRS: true, CREATE_CUSTOM_DIRS: true, ALLOW_YTDL_OPTIONS_OVERRIDES: false };
|
|
customDirs = { download_dir: [], audio_download_dir: [] };
|
|
queueChanged = new Subject<void>();
|
|
doneChanged = new Subject<void>();
|
|
configurationChanged = new Subject<Record<string, unknown>>();
|
|
customDirsChanged = new Subject<Record<string, string[]>>();
|
|
ytdlOptionsChanged = new Subject<Record<string, unknown>>();
|
|
updated = new Subject<void>();
|
|
completedDownload = new Subject<Download>();
|
|
|
|
getCookieStatus() {
|
|
return of({ status: 'ok', has_cookies: false });
|
|
}
|
|
|
|
getPresets() {
|
|
return of({ presets: ['Preset A'] });
|
|
}
|
|
|
|
add() {
|
|
return of({ status: 'ok' as const });
|
|
}
|
|
|
|
cancelAdd() {
|
|
return of({ status: 'ok' as const });
|
|
}
|
|
|
|
startById() {
|
|
return of({});
|
|
}
|
|
|
|
delById() {
|
|
return of({});
|
|
}
|
|
|
|
delByFilter() {
|
|
return of({});
|
|
}
|
|
|
|
startByFilter() {
|
|
return of({});
|
|
}
|
|
|
|
uploadCookies() {
|
|
return of({ status: 'ok' });
|
|
}
|
|
|
|
deleteCookies() {
|
|
return of({ status: 'ok' });
|
|
}
|
|
}
|
|
|
|
class SubscriptionsServiceStub {
|
|
subscriptions = new Map();
|
|
subscriptionsChanged = new Subject<void>();
|
|
subscribeCalls: unknown[] = [];
|
|
|
|
subscribe(payload: unknown) {
|
|
this.subscribeCalls.push(payload);
|
|
return of({ status: 'ok' as const });
|
|
}
|
|
|
|
delete() {
|
|
return of({});
|
|
}
|
|
|
|
update() {
|
|
return of({ status: 'ok' as const });
|
|
}
|
|
|
|
refreshList() {
|
|
return of([]);
|
|
}
|
|
}
|
|
|
|
class CookieServiceStub {
|
|
private cookies = new Map<string, string>();
|
|
|
|
get(name: string) {
|
|
return this.cookies.get(name) ?? '';
|
|
}
|
|
|
|
set(name: string, value: string) {
|
|
this.cookies.set(name, value);
|
|
}
|
|
|
|
check(name: string) {
|
|
return this.cookies.has(name);
|
|
}
|
|
}
|
|
|
|
describe('App', () => {
|
|
let downloads: DownloadsServiceStub;
|
|
|
|
beforeEach(async () => {
|
|
Object.defineProperty(window, 'matchMedia', {
|
|
writable: true,
|
|
enumerable: true,
|
|
value: vi.fn().mockImplementation((query: string) => ({
|
|
matches: false,
|
|
media: query,
|
|
onchange: null,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
})),
|
|
});
|
|
downloads = new DownloadsServiceStub();
|
|
await TestBed.configureTestingModule({
|
|
imports: [App],
|
|
providers: [
|
|
{ provide: DownloadsService, useValue: downloads },
|
|
{ provide: SubscriptionsService, useClass: SubscriptionsServiceStub },
|
|
{ provide: CookieService, useClass: CookieServiceStub },
|
|
{
|
|
provide: HttpClient,
|
|
useValue: {
|
|
get: vi.fn().mockReturnValue(of({ 'yt-dlp': 'test', version: 'test' })),
|
|
},
|
|
},
|
|
],
|
|
}).compileComponents();
|
|
});
|
|
|
|
it('should create the app', () => {
|
|
const fixture = TestBed.createComponent(App);
|
|
const app = fixture.componentInstance;
|
|
expect(app).toBeTruthy();
|
|
});
|
|
|
|
it('hides manual override input when disabled', () => {
|
|
const fixture = TestBed.createComponent(App);
|
|
fixture.componentInstance.isAdvancedOpen = true;
|
|
fixture.detectChanges();
|
|
|
|
const root = fixture.nativeElement as HTMLElement;
|
|
expect(root.querySelector('input[name="ytdlOptionsOverrides"]')).toBeNull();
|
|
|
|
const presetWrapper = root.querySelector('ng-select[name="ytdlOptionsPresets"]')?.closest('.col-12');
|
|
expect(presetWrapper?.classList.contains('col-md-6')).toBe(false);
|
|
|
|
const presetRow = root.querySelector('ng-select[name="ytdlOptionsPresets"]')?.closest('.row');
|
|
expect(presetRow?.querySelector('input[name="checkIntervalMinutes"]')).toBeNull();
|
|
});
|
|
|
|
it('shows manual override input when enabled', () => {
|
|
downloads.configuration['ALLOW_YTDL_OPTIONS_OVERRIDES'] = true;
|
|
|
|
const fixture = TestBed.createComponent(App);
|
|
fixture.componentInstance.isAdvancedOpen = true;
|
|
fixture.detectChanges();
|
|
|
|
const root = fixture.nativeElement as HTMLElement;
|
|
expect(root.querySelector('input[name="ytdlOptionsOverrides"]')).not.toBeNull();
|
|
|
|
const presetWrapper = root.querySelector('ng-select[name="ytdlOptionsPresets"]')?.closest('.col-12');
|
|
expect(presetWrapper?.classList.contains('col-md-6')).toBe(true);
|
|
|
|
const presetRow = root.querySelector('ng-select[name="ytdlOptionsPresets"]')?.closest('.row');
|
|
expect(presetRow?.querySelector('input[name="checkIntervalMinutes"]')).toBeNull();
|
|
expect(presetRow?.querySelector('input[name="ytdlOptionsOverrides"]')).not.toBeNull();
|
|
});
|
|
|
|
it('does not submit manual overrides when disabled', () => {
|
|
const fixture = TestBed.createComponent(App);
|
|
const app = fixture.componentInstance;
|
|
|
|
app.ytdlOptionsOverrides = '{"exec":"echo hi"}';
|
|
|
|
const payload = app['buildAddPayload']();
|
|
|
|
expect(payload.ytdlOptionsOverrides).toBe('');
|
|
});
|
|
|
|
it('includes titleRegex in subscribe payload', () => {
|
|
const fixture = TestBed.createComponent(App);
|
|
const app = fixture.componentInstance;
|
|
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
|
|
app.addUrl = 'https://example.com/channel';
|
|
app.titleRegex = 'EPISODE';
|
|
app.addSubscription();
|
|
expect(subs.subscribeCalls.length).toBe(1);
|
|
const payload = subs.subscribeCalls[0] as { titleRegex: string; skipSubscriberOnly: boolean };
|
|
expect(payload.titleRegex).toBe('EPISODE');
|
|
expect(payload.skipSubscriberOnly).toBe(false);
|
|
});
|
|
|
|
it('includes skipSubscriberOnly true when checked', () => {
|
|
const fixture = TestBed.createComponent(App);
|
|
const app = fixture.componentInstance;
|
|
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
|
|
app.addUrl = 'https://example.com/channel';
|
|
app.skipSubscriberOnly = true;
|
|
app.addSubscription();
|
|
expect(subs.subscribeCalls.length).toBe(1);
|
|
const payload = subs.subscribeCalls[0] as { skipSubscriberOnly: boolean };
|
|
expect(payload.skipSubscriberOnly).toBe(true);
|
|
});
|
|
|
|
it('omits clip fields from subscribe payload', () => {
|
|
const fixture = TestBed.createComponent(App);
|
|
const app = fixture.componentInstance;
|
|
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
|
|
app.addUrl = 'https://example.com/channel';
|
|
app.clipStart = '1:00';
|
|
app.clipEnd = '2:00';
|
|
app.addSubscription();
|
|
expect(subs.subscribeCalls.length).toBe(1);
|
|
const payload = subs.subscribeCalls[0] as Record<string, unknown>;
|
|
expect('clipStart' in payload).toBe(false);
|
|
expect('clipEnd' in payload).toBe(false);
|
|
});
|
|
|
|
it('buildAddPayload includes clip times', () => {
|
|
const fixture = TestBed.createComponent(App);
|
|
const app = fixture.componentInstance;
|
|
app.clipStart = '0:10';
|
|
app.clipEnd = '1:20';
|
|
const payload = app['buildAddPayload']();
|
|
expect(payload.clipStart).toBe('0:10');
|
|
expect(payload.clipEnd).toBe('1:20');
|
|
});
|
|
|
|
it('blocks subscribe with invalid title regex', () => {
|
|
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
|
|
const fixture = TestBed.createComponent(App);
|
|
const app = fixture.componentInstance;
|
|
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
|
|
app.addUrl = 'https://example.com/channel';
|
|
app.titleRegex = '[';
|
|
app.addSubscription();
|
|
expect(subs.subscribeCalls.length).toBe(0);
|
|
expect(alertSpy).toHaveBeenCalledWith('Invalid subscription title filter (regex)');
|
|
alertSpy.mockRestore();
|
|
});
|
|
});
|