244 lines
8.4 KiB
TypeScript
244 lines
8.4 KiB
TypeScript
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
import { signal } from '@angular/core';
|
|
import { AgentService } 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
|
|
};
|
|
return {
|
|
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 }]),
|
|
currentSessionStepFrames: signal([frame]),
|
|
currentSession: signal({ session_id: 'test', start_time: start }),
|
|
currentSessionId: signal('test'),
|
|
videoSeekRequest: signal(null),
|
|
stepSeekRequest: signal(null),
|
|
consumeVideoAutoplay: jasmine.createSpy('consumeVideoAutoplay').and.returnValue(false)
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
agentService = createAgentService();
|
|
TestBed.configureTestingModule({
|
|
imports: [FloatingVideoPlayerComponent],
|
|
providers: [{ provide: AgentService, useValue: agentService }]
|
|
});
|
|
|
|
fixture = TestBed.createComponent(FloatingVideoPlayerComponent);
|
|
fixture.detectChanges();
|
|
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 },
|
|
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();
|
|
});
|
|
});
|
|
|
|
function finishSeek(): void {
|
|
seeking = false;
|
|
video.dispatchEvent(new Event('seeked'));
|
|
fixture.detectChanges();
|
|
}
|
|
|
|
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([]);
|
|
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);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|