metube/ui/src/app/app.spec.ts
TonyBlu e639caa21d fix: Codex review P2 issues + frontend test failure
- 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)
2026-05-25 20:09:17 +08:00

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();
});
});