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; let component: FloatingVideoPlayerComponent; let video: HTMLVideoElement; let paused: boolean; let seeking: boolean; let time: number; let play: jasmine.Spy; let agentService: ReturnType; 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([{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 }]), recordingSources: signal([]), 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((_, 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(); }); });