Files
Artemis-Eapil/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.spec.ts
T

532 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { AgentService, RecordingSource, VideoSegment } from '../../services/agent.service';
import { StepReplayFrame } from '../../core/models/stream.model';
import { FloatingVideoPlayerComponent } from './floating-video-player.component';
describe('FloatingVideoPlayerComponent video markers', () => {
let fixture: ComponentFixture<FloatingVideoPlayerComponent>;
let component: FloatingVideoPlayerComponent;
let video: HTMLVideoElement;
let paused: boolean;
let seeking: boolean;
let time: number;
let play: jasmine.Spy;
let agentService: ReturnType<typeof createAgentService>;
function createAgentService() {
const start = 1_800_000_000;
const frame: StepReplayFrame = {
index: 0, stepNumber: 1, title: '步骤 1: 点击设置', imageUrl: 'step.png',
actionText: '点击设置', timestamp: start + 5
};
const service = {
isVideoWindowOpen: signal(true),
isVideoMinimized: signal(false),
recordingPlaybackStatus: signal('ready'),
playerMode: signal('video'),
hasCurrentSessionStepFrames: signal(true),
activeVideoTitle: signal('测试录像'),
isVideoLoading: signal(false),
activeVideoUrl: signal('/test.mp4'),
activeVideoSegments: signal<VideoSegment[]>([{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 }]),
recordingSources: signal<RecordingSource[]>([]),
selectedRecordingSource: signal(''),
usesSessionTimeline: signal(false),
recordingSessionDuration: signal(0),
recordingPlaybackMessage: signal(''),
selectRecordingSource: jasmine.createSpy('selectRecordingSource'),
currentSessionStepFrames: signal([frame]),
currentSession: signal({ session_id: 'test', start_time: start }),
currentSessionId: signal('test'),
sessions: signal([{ session_id: 'test', status: 'completed' }]),
selectSession: jasmine.createSpy('selectSession'),
videoSeekRequest: signal<{ seconds: number; requestId: number; sourceId?: string } | null>(null),
stepSeekRequest: signal(null),
consumeVideoAutoplay: jasmine.createSpy('consumeVideoAutoplay').and.returnValue(false)
};
service.selectRecordingSource.and.callFake((id: string) => {
const source = service.recordingSources().find(item => item.id === id);
if (!source) return false;
if (service.selectedRecordingSource() === id) return true;
service.selectedRecordingSource.set(id);
service.activeVideoSegments.set(source.segments);
service.activeVideoUrl.set(source.url || source.segments[0]?.url || '');
return true;
});
return service;
}
beforeEach(() => {
agentService = createAgentService();
TestBed.configureTestingModule({
imports: [FloatingVideoPlayerComponent],
providers: [provideHttpClient(), provideHttpClientTesting(), { provide: AgentService, useValue: agentService }]
});
fixture = TestBed.createComponent(FloatingVideoPlayerComponent);
fixture.detectChanges();
TestBed.inject(HttpTestingController).expectOne('/api/task-extensions/test').flush({
session_id: 'test', initial_prompt: '', editable: true, plugins: []
});
component = fixture.componentInstance;
video = component.videoRef!.nativeElement;
paused = true;
seeking = false;
time = 0;
Object.defineProperties(video, {
paused: { configurable: true, get: () => paused },
seeking: { configurable: true, get: () => seeking },
ended: { configurable: true, get: () => false },
duration: { configurable: true, get: () => 20 },
readyState: { configurable: true, get: () => 1 },
currentTime: {
configurable: true, get: () => time,
set: (value: number) => { time = value; seeking = true; }
}
});
spyOn(video, 'pause').and.callFake(() => {
paused = true;
video.dispatchEvent(new Event('pause'));
});
play = spyOn(video, 'play').and.callFake(() => {
paused = false;
video.dispatchEvent(new Event('play'));
return Promise.resolve();
});
video.dispatchEvent(new Event('loadedmetadata'));
});
function finishSeek(): void {
seeking = false;
video.dispatchEvent(new Event('seeked'));
fixture.detectChanges();
}
for (const mode of ['video', 'steps']) {
it(`shows review controls in ${mode} mode even without a recording`, () => {
agentService.playerMode.set(mode);
agentService.recordingPlaybackStatus.set('unavailable');
component.extensionPanels()!.refresh(); fixture.detectChanges();
TestBed.inject(HttpTestingController).expectOne('/api/task-extensions/test').flush({
session_id: 'test', initial_prompt: '', editable: true, plugins: [{
plugin_id: 'review', name: '原始用例', version: '1.1.0', revision: 0, values: {}, history: [],
workspace: { fields: [], panels: [{ id: 'case', title: '原始用例', side: 'left', kind: 'original_case', width: {minimum: 240, preferred: 340, maximum: 480} }] }
}]
}); fixture.detectChanges();
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button[aria-label="展开原始用例"]');
expect(button).not.toBeNull(); button.click(); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('aside').textContent).toContain('未记录原始用例');
});
}
it('removes the live image while hidden and reconnects it on return', () => {
const hidden = spyOnProperty(document, 'hidden', 'get').and.returnValue(false);
agentService.recordingPlaybackStatus.set('live');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('img.live-screen-img')).not.toBeNull();
hidden.and.returnValue(true);
document.dispatchEvent(new Event('visibilitychange'));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('img.live-screen-img')).toBeNull();
hidden.and.returnValue(false);
document.dispatchEvent(new Event('visibilitychange'));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('img.live-screen-img')).not.toBeNull();
expect(component.liveStreamError()).toBeFalse();
});
function playButton(): HTMLButtonElement {
return fixture.nativeElement.querySelector('.play-btn');
}
it('shows a recorded step on the video timeline and seeks when clicked', () => {
const marker = fixture.nativeElement.querySelector('.video-marker') as HTMLButtonElement;
expect(marker).not.toBeNull();
expect(marker.getAttribute('aria-label')).toContain('步骤 1');
expect(marker.style.left).toBe('25%');
const seek = spyOn(fixture.componentInstance, 'seek');
marker.click();
expect(seek).toHaveBeenCalledOnceWith(5);
});
it('resumes a playing video after a marker seek finishes and synchronizes the button', async () => {
component.togglePlay();
await Promise.resolve();
play.calls.reset();
fixture.nativeElement.querySelector('.video-marker').click();
expect(time).toBe(5);
expect(play).not.toHaveBeenCalled();
finishSeek();
await Promise.resolve();
fixture.detectChanges();
expect(paused).toBeFalse();
expect(play).toHaveBeenCalledTimes(1);
expect(playButton().title).toBe('暂停');
});
it('keeps a paused video paused after a marker seek and resumes with one click', async () => {
fixture.nativeElement.querySelector('.video-marker').click();
finishSeek();
expect(paused).toBeTrue();
expect(play).not.toHaveBeenCalled();
expect(playButton().title).toBe('播放');
playButton().click();
await Promise.resolve();
fixture.detectChanges();
expect(paused).toBeFalse();
expect(playButton().title).toBe('暂停');
});
it('preserves playing intent across rapid consecutive marker seeks', () => {
component.togglePlay();
component.seek(5);
component.seek(8);
play.calls.reset();
finishSeek();
expect(time).toBe(8);
expect(play).toHaveBeenCalledTimes(1);
expect(paused).toBeFalse();
});
it('honors play and pause clicks while the destination is still seeking', () => {
component.togglePlay();
component.seek(5);
play.calls.reset();
fixture.detectChanges();
expect(playButton().title).toBe('播放');
playButton().click();
fixture.detectChanges();
expect(playButton().title).toBe('暂停');
expect(play).not.toHaveBeenCalled();
playButton().click();
finishSeek();
expect(paused).toBeTrue();
expect(play).not.toHaveBeenCalled();
expect(playButton().title).toBe('播放');
});
it('waits for the destination segment to seek before resuming playback', () => {
agentService.activeVideoSegments.set([
{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 },
{ url: '/second.mp4', start: 20, duration: 20, width: 400, height: 800 }
]);
fixture.detectChanges();
component.togglePlay();
component.seek(25);
play.calls.reset();
fixture.detectChanges();
video.dispatchEvent(new Event('loadedmetadata'));
expect(component.activeSegmentIndex()).toBe(1);
expect(time).toBe(5);
expect(play).not.toHaveBeenCalled();
finishSeek();
expect(play).toHaveBeenCalledTimes(1);
expect(paused).toBeFalse();
expect(component.currentTime()).toBe(25);
});
it('keeps a paused video paused when jumping to another segment', () => {
agentService.activeVideoSegments.set([
{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 },
{ url: '/second.mp4', start: 20, duration: 20, width: 400, height: 800 }
]);
fixture.detectChanges();
component.seek(25);
fixture.detectChanges();
video.dispatchEvent(new Event('loadedmetadata'));
finishSeek();
expect(time).toBe(5);
expect(paused).toBeTrue();
expect(play).not.toHaveBeenCalled();
expect(playButton().title).toBe('播放');
});
it('resumes after seeking a recording without a segment manifest', () => {
agentService.activeVideoSegments.set([]);
agentService.usesSessionTimeline.set(true);
fixture.detectChanges();
video.dispatchEvent(new Event('loadedmetadata'));
component.togglePlay();
component.seek(5);
play.calls.reset();
finishSeek();
expect(time).toBe(5);
expect(component.currentTime()).toBe(5);
expect(paused).toBeFalse();
expect(play).toHaveBeenCalledTimes(1);
});
function setSources(computerStart = 0): void {
agentService.recordingSources.set([
{ id: 'android', label: '安卓', kind: 'android', target_ids: ['android'], status: 'ready',
segments: [{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 }] },
{ id: 'screen', label: '电脑', kind: 'computer', target_ids: ['window-1'], status: 'ready',
segments: [{ url: '/computer.mp4', start: computerStart, duration: 20, width: 1280, height: 720 }] }
]);
agentService.usesSessionTimeline.set(true);
agentService.recordingSessionDuration.set(40);
agentService.selectRecordingSource('android');
fixture.detectChanges();
}
function setGappedRecording(): void {
agentService.usesSessionTimeline.set(true);
agentService.recordingSessionDuration.set(1040);
agentService.activeVideoSegments.set([
{ url: '/test.mp4', start: 2, offset_ms: 2000, duration: 20, width: 400, height: 800 },
{ url: '/resume.mp4', start: 1000, offset_ms: 1000000, duration: 20, width: 400, height: 800 }
]);
fixture.detectChanges();
video.dispatchEvent(new Event('loadedmetadata'));
}
it('automatically skips a long pause and plays the next clip after it loads', () => {
setGappedRecording();
component.togglePlay();
play.calls.reset();
time = 20;
paused = true;
component.onEnded();
expect(component.activeSegmentIndex()).toBe(1);
expect(component.currentTime()).toBe(1000);
expect(component.gapMessage()).toBe('');
expect(component.skippedInterval()).toContain('00:22–16:40');
expect(play).not.toHaveBeenCalled();
component.onTimeUpdate(); // Late timeupdate from the ended clip must not move the new timeline.
expect(component.currentTime()).toBe(1000);
fixture.detectChanges();
video.dispatchEvent(new Event('loadedmetadata'));
finishSeek();
expect(time).toBe(0);
expect(paused).toBeFalse();
expect(play).toHaveBeenCalledTimes(1);
});
it('restarts and loops at the first recorded frame, skipping leading and trailing gaps', () => {
setGappedRecording();
component.restart();
finishSeek();
expect(component.currentTime()).toBe(2);
expect(paused).toBeFalse();
component.seek(1010, true);
fixture.detectChanges();
video.dispatchEvent(new Event('loadedmetadata'));
finishSeek();
component.isLooping.set(true);
paused = true;
component.onEnded();
fixture.detectChanges();
video.dispatchEvent(new Event('loadedmetadata'));
finishSeek();
expect(component.activeSegmentIndex()).toBe(0);
expect(component.currentTime()).toBe(2);
expect(component.gapMessage()).toBe('');
expect(paused).toBeFalse();
});
for (const playing of [false, true]) {
it(`snaps a scrub in a gap forward while preserving ${playing ? 'playing' : 'paused'} intent`, () => {
setGappedRecording();
if (playing) component.togglePlay();
const input = fixture.nativeElement.querySelector('.scrubber-range') as HTMLInputElement;
input.value = '500';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
video.dispatchEvent(new Event('loadedmetadata'));
finishSeek();
expect(component.currentTime()).toBe(1000);
expect(component.skippedInterval()).toContain('08:20–16:40');
expect(paused).toBe(!playing);
});
}
it('does not substitute another time for an exact step seek in a gap', () => {
setGappedRecording();
component.seekToSessionTime(500, true);
expect(component.currentTime()).toBe(500);
expect(component.gapMessage()).toContain('未录制');
expect(paused).toBeTrue();
expect(play).not.toHaveBeenCalled();
});
it('ends at the last recorded frame without playing the trailing gap', () => {
setGappedRecording();
component.seek(1010, true);
fixture.detectChanges();
video.dispatchEvent(new Event('loadedmetadata'));
finishSeek();
play.calls.reset();
paused = true;
component.onEnded();
expect(component.currentTime()).toBe(1020);
expect(component.isPlaying()).toBeFalse();
expect(component.skippedInterval()).toContain('录像已结束');
expect(play).not.toHaveBeenCalled();
});
it('shows actual recorded duration and coverage separately from task duration', () => {
setGappedRecording();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.recording-coverage-summary').textContent).toContain('00:40');
expect(fixture.nativeElement.querySelector('.time-display').textContent).toContain('17:20');
expect(fixture.nativeElement.querySelectorAll('.recording-coverage span').length).toBe(2);
expect(component.recordedCoverage()[1].left).toBeCloseTo(1000 / 1040 * 100);
});
it('explains missing computer footage without treating Android video as its evidence', () => {
agentService.currentSessionStepFrames.update(frames => [...frames,
{ ...frames[0], sourceId: 'plugin:open-computer-use', sourceLabel: '电脑', index: 1 }
]);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.recording-notice').textContent).toContain('电脑步骤没有对应录像');
agentService.recordingSources.set([
{ id: 'pc', label: '电脑', kind: 'computer', target_ids: ['plugin:open-computer-use'],
status: 'ready', segments: agentService.activeVideoSegments() }
]);
fixture.detectChanges();
expect(component.missingRecordingLabels()).toEqual([]);
});
it('handles a seek request once without seeking back on play or pause', () => {
const seek = spyOn(component, 'seekToSessionTime').and.callThrough();
agentService.videoSeekRequest.set({ seconds: 5, requestId: 1 });
fixture.detectChanges();
finishSeek();
time = 9;
component.togglePlay();
fixture.detectChanges();
expect(time).toBe(9);
component.togglePlay();
fixture.detectChanges();
expect(time).toBe(9);
expect(seek).toHaveBeenCalledOnceWith(5, false);
});
it('defers a seek command until the recording is ready', () => {
const seek = spyOn(component, 'seekToSessionTime');
agentService.recordingPlaybackStatus.set('processing');
agentService.videoSeekRequest.set({ seconds: 5, requestId: 2 });
fixture.detectChanges();
expect(seek).not.toHaveBeenCalled();
agentService.recordingPlaybackStatus.set('ready');
fixture.detectChanges();
component.onLoadedMetadata();
expect(seek).toHaveBeenCalledOnceWith(5, false);
});
it('switches to the requested source, waits for seeked and allows later manual source changes', () => {
setSources(2);
component.togglePlay();
agentService.videoSeekRequest.set({ seconds: 7, requestId: 3, sourceId: 'window-1' });
fixture.detectChanges();
play.calls.reset();
video.dispatchEvent(new Event('loadedmetadata'));
expect(time).toBe(5);
expect(play).not.toHaveBeenCalled();
finishSeek();
expect(component.currentTime()).toBe(7);
expect(paused).toBeFalse();
expect(play).toHaveBeenCalledTimes(1);
component.selectSource('android');
fixture.detectChanges();
expect(agentService.selectedRecordingSource()).toBe('android');
video.dispatchEvent(new Event('loadedmetadata'));
finishSeek();
expect(time).toBe(7);
expect(agentService.selectedRecordingSource()).toBe('android');
});
it('preserves task time when switching into a gap and advances only on explicit play', () => {
setSources(20);
time = 12;
component.onTimeUpdate();
component.togglePlay();
component.selectSource('screen');
fixture.detectChanges();
play.calls.reset();
video.dispatchEvent(new Event('loadedmetadata'));
expect(component.currentTime()).toBe(12);
expect(component.gapMessage()).toContain('未录制');
expect(paused).toBeTrue();
expect(play).not.toHaveBeenCalled();
component.togglePlay();
finishSeek();
expect(component.currentTime()).toBe(20);
expect(component.gapMessage()).toBe('');
expect(paused).toBeFalse();
});
it('retains a paused source switch and resumes with one play click', () => {
setSources();
time = 6;
component.onTimeUpdate();
component.selectSource('screen');
fixture.detectChanges();
video.dispatchEvent(new Event('loadedmetadata'));
finishSeek();
expect(component.currentTime()).toBe(6);
expect(paused).toBeTrue();
expect(play).not.toHaveBeenCalled();
component.togglePlay();
expect(play).toHaveBeenCalledTimes(1);
expect(paused).toBeFalse();
});
it('does not play another source when the requested source has no recording', () => {
setSources();
agentService.videoSeekRequest.set({ seconds: 5, requestId: 4, sourceId: 'missing-window' });
fixture.detectChanges();
component.togglePlay();
expect(component.gapMessage()).toContain('来源没有录像');
expect(component.currentTime()).toBe(5);
expect(paused).toBeTrue();
expect(play).not.toHaveBeenCalled();
});
it('resets a rejected play request so a single subsequent click can resume', async () => {
play.and.callFake(() => {
paused = false;
video.dispatchEvent(new Event('play'));
return Promise.reject(new DOMException('Interrupted', 'AbortError'));
});
component.seek(5, true);
finishSeek();
await Promise.resolve();
await Promise.resolve();
fixture.detectChanges();
expect(paused).toBeTrue();
expect(playButton().title).toBe('播放');
play.and.callFake(() => {
paused = false;
video.dispatchEvent(new Event('play'));
return Promise.resolve();
});
playButton().click();
await Promise.resolve();
fixture.detectChanges();
expect(paused).toBeFalse();
expect(playButton().title).toBe('暂停');
});
it('ignores an older aborted play request after a newer seek resumes', async () => {
let rejectOldPlay!: (reason: unknown) => void;
play.and.returnValue(new Promise<void>((_, reject) => { rejectOldPlay = reject; }));
component.togglePlay();
paused = false;
component.seek(5);
play.and.callFake(() => { paused = false; return Promise.resolve(); });
finishSeek();
rejectOldPlay(new DOMException('Interrupted', 'AbortError'));
await Promise.resolve();
await Promise.resolve();
expect(paused).toBeFalse();
expect(component.isPlaying()).toBeTrue();
});
});