import { TestBed, ComponentFixture, fakeAsync, flushMicrotasks, tick } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TaskExtensionPanelsComponent } from './task-extension-panels.component'; import { TaskExtensions } from '../../services/task-extensions.service'; function snapshot(sessionId = 'task-one'): TaskExtensions { return { session_id: sessionId, initial_prompt: '# 原始需求\n\n| 项目 | 要求 |\n| --- | --- |\n| 页面 | 可见 |', editable: true, plugins: [{ plugin_id: 'review', name: '人工评审', version: '1.0.0', revision: 0, values: { accuracy: '', note: '', correctness: '' }, history: [], workspace: { panels: [ { id: 'prompt', title: '任务需求', side: 'left', kind: 'initial_prompt', width: { minimum: 240, preferred: 360, maximum: 480 } }, { id: 'review', title: '人工评审', side: 'right', kind: 'form', width: { minimum: 260, preferred: 380, maximum: 520 } } ], fields: [ { id: 'accuracy', label: 'AI执行准确性', description: '人工核对步骤', kind: 'choice', options: ['Pass', 'Failed'], max_length: 100, report: true }, { id: 'note', label: '执行备注', description: '', kind: 'text', options: [], max_length: 2000, report: true }, { id: 'correctness', label: '测试结果正确性', description: '', kind: 'choice', options: ['Pass', 'Failed'], max_length: 100, report: true } ] } }] }; } describe('步骤回放工作区扩展', () => { let fixture: ComponentFixture; let component: TaskExtensionPanelsComponent; let http: HttpTestingController; beforeEach(() => { TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] }); fixture = TestBed.createComponent(TaskExtensionPanelsComponent); component = fixture.componentInstance; http = TestBed.inject(HttpTestingController); }); afterEach(() => { http.verify(); fixture.destroy(); }); function open(data = snapshot()) { fixture.componentRef.setInput('sessionId', data.session_id); fixture.componentRef.setInput('enabled', true); fixture.detectChanges(); http.expectOne(`/api/task-extensions/${data.session_id}`).flush(data); fixture.detectChanges(); } function toggle(side: 'left' | 'right') { const plugin = component.data()!.plugins[0]; component.toggle(plugin, plugin.workspace.panels.find(panel => panel.side === side)!); fixture.detectChanges(); } it('does not request disabled extensions and shows no controls without a plugin', () => { fixture.componentRef.setInput('sessionId', 'task-one'); fixture.detectChanges(); http.expectNone('/api/task-extensions/task-one'); open({ ...snapshot(), plugins: [] }); expect(fixture.nativeElement.querySelector('nav')).toBeNull(); }); it('starts collapsed, renders the full initial Markdown prompt and honors declared widths', () => { open(); expect(fixture.nativeElement.querySelector('aside')).toBeNull(); toggle('left'); expect(fixture.nativeElement.querySelector('h1').textContent).toBe('原始需求'); expect(fixture.nativeElement.querySelector('table td').textContent).toBe('页面'); expect(component.width('left')).toBe(360); expect(fixture.nativeElement.querySelector('.report-prose.compact')).toBeNull(); }); it('keeps either single panel beside playback below the former stacking breakpoint', () => { open(); for (const width of [740, 600]) { fixture.nativeElement.style.width = `${width}px`; for (const side of ['left', 'right'] as const) { toggle(side); const panel = fixture.nativeElement.querySelector('aside').getBoundingClientRect(); const player = fixture.nativeElement.querySelector('.player-content').getBoundingClientRect(); const layout = fixture.nativeElement.querySelector('.extension-layout').getBoundingClientRect(); expect(panel.top).toBe(player.top); expect(panel.width).toBeGreaterThan(0); expect(player.width).toBeGreaterThan(0); expect(side === 'left' ? panel.right : player.right).toBeCloseTo(side === 'left' ? player.left : panel.left, 0); expect(Math.max(panel.right, player.right)).toBeLessThanOrEqual(layout.right + 1); toggle(side); } } }); it('automatically saves choices and clears while keeping unfilled outcomes blank', fakeAsync(() => { open(); toggle('right'); const pass: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.pass'); pass.click(); fixture.detectChanges(); expect(saved().values).toEqual({ accuracy: 'Pass', note: '', correctness: '' }); pass.click(); fixture.detectChanges(); expect(saved(2).values.accuracy).toBe(''); expect(fixture.nativeElement.textContent).toContain('人工评审已自动保存'); })); function saved(revision = 1, id = 'task-one') { const request = http.expectOne(`/api/task-extensions/${id}/review`); expect(request.request.method).toBe('PUT'); const body = request.request.body, data = snapshot(id); data.plugins[0].revision = revision; data.plugins[0].values = body.values; request.flush(data); flushMicrotasks(); fixture.detectChanges(); return body; } it('debounces notes and never posts just from opening a task', fakeAsync(() => { open(); toggle('right'); tick(700); http.expectNone('/api/task-extensions/task-one/review'); component.edit('review', 'note', '第一段'); tick(400); component.edit('review', 'note', '完整备注'); tick(599); http.expectNone('/api/task-extensions/task-one/review'); tick(1); expect(saved().values.note).toBe('完整备注'); })); it('does not overwrite an automatic save with an older read response', fakeAsync(() => { open(); component.edit('review', 'note', '已保存的新内容'); tick(600); saved(2); component.refresh(); fixture.detectChanges(); http.expectOne('/api/task-extensions/task-one').flush(snapshot()); fixture.detectChanges(); expect(component.drafts['review'].values['note']).toBe('已保存的新内容'); expect(component.drafts['review'].revision).toBe(2); })); it('flushes a pending note when the review panel is closed', fakeAsync(() => { open(); component.edit('review', 'note', '关闭前保存'); fixture.destroy(); const request = http.expectOne('/api/task-extensions/task-one/review'); expect(request.request.body.values.note).toBe('关闭前保存'); const response = snapshot(); response.plugins[0].revision = 1; response.plugins[0].values = request.request.body.values; request.flush(response); flushMicrotasks(); })); it('preserves draft and request receipt on failure, and retries only when requested', fakeAsync(() => { open(); toggle('right'); const plugin = component.data()!.plugins[0]; component.edit('review', 'accuracy', 'Failed'); void component.save(plugin); const first = http.expectOne('/api/task-extensions/task-one/review'), requestId = first.request.body.request_id; first.flush({}, { status: 503, statusText: 'Unavailable' }); flushMicrotasks(); tick(700); http.expectNone('/api/task-extensions/task-one/review'); void component.save(plugin); const retry = http.expectOne('/api/task-extensions/task-one/review'); expect(retry.request.body.request_id).toBe(requestId); retry.flush({}, { status: 409, statusText: 'Conflict' }); flushMicrotasks(); expect(component.drafts['review'].values['accuracy']).toBe('Failed'); component.edit('review', 'accuracy', ''); void component.save(plugin); const clear = saved(); expect(clear.values.accuracy).toBe(''); expect(clear.request_id).not.toBe(requestId); })); it('serializes rapid edits and saves the latest choice using the returned revision', fakeAsync(() => { const data = snapshot(); data.plugins[0].workspace.fields[0].options = ['通过', '失败']; open(data); toggle('right'); const pass: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.pass'); const failed: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.failed'); pass.click(); failed.click(); fixture.detectChanges(); expect(component.drafts['review'].values['accuracy']).toBe('失败'); expect(saved().values.accuracy).toBe('通过'); const second = saved(2); expect(second.values.accuracy).toBe('失败'); expect(second.expected_revision).toBe(1); expect(component.drafts['review'].dirty).toBeFalse(); })); it('does not apply a delayed save response to the task now displayed', fakeAsync(() => { open(); toggle('right'); component.edit('review', 'note', '只属于第一个任务'); void component.save(component.data()!.plugins[0]); const oldSave = http.expectOne('/api/task-extensions/task-one/review'); fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges(); http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two')); fixture.detectChanges(); oldSave.flush(snapshot('task-one')); flushMicrotasks(); fixture.detectChanges(); expect(component.data()!.session_id).toBe('task-two'); expect(component.drafts['review'].values['note']).toBe(''); })); it('flushes a pending note on direct task switches and hides disabled plugins', fakeAsync(() => { open(); toggle('right'); component.edit('review', 'note', '自动保存草稿'); fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges(); http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two')); const body = saved(1, 'task-one'); expect(body.values.note).toBe('自动保存草稿'); fixture.componentRef.setInput('sessionId', 'task-one'); fixture.detectChanges(); const first = snapshot(); first.plugins[0].revision = 1; first.plugins[0].values = body.values; http.expectOne('/api/task-extensions/task-one').flush(first); fixture.detectChanges(); expect(component.drafts['review'].values['note']).toBe('自动保存草稿'); window.dispatchEvent(new Event('artemis-plugin-change')); fixture.detectChanges(); http.expectOne('/api/task-extensions/task-one').flush({ ...first, plugins: [] }); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('aside')).toBeNull(); })); it('keeps running tasks read-only then enables editing when the task finishes', () => { open({ ...snapshot(), editable: false }); toggle('right'); expect(fixture.nativeElement.querySelector('fieldset').disabled).toBeTrue(); component.save(component.data()!.plugins[0]); http.expectNone('/api/task-extensions/task-one/review'); fixture.componentRef.setInput('taskStatus', 'completed'); fixture.detectChanges(); http.expectOne('/api/task-extensions/task-one').flush(snapshot()); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('fieldset').disabled).toBeFalse(); }); it('collapses the opposite panel on narrow screens', () => { const original = Object.getOwnPropertyDescriptor(window, 'innerWidth')!; Object.defineProperty(window, 'innerWidth', { configurable: true, value: 600 }); try { open(); toggle('left'); toggle('right'); expect(component.panels('left').length).toBe(0); expect(component.panels('right').length).toBe(1); } finally { Object.defineProperty(window, 'innerWidth', original); } }); it('shows original case sections in order without substituting the AI prompt', () => { const data = snapshot(); data.plugins[0].workspace.panels[0].kind = 'original_case'; data.case_title = 'CASE-001 检查设置'; data.original_case = { preconditions: '已登录', steps: '1. 打开设置\n2. 核对版本', expected: '版本可见' }; open(data); toggle('left'); const text = fixture.nativeElement.textContent; expect(text).toContain('CASE-001 检查设置'); expect(text).toMatch(/预置条件[\s\S]*已登录[\s\S]*操作步骤[\s\S]*核对版本[\s\S]*预期结果[\s\S]*版本可见/); expect(text).not.toContain('原始需求'); }); it('saves before navigation and keeps the current task on save failure', fakeAsync(() => { const first = snapshot(); first.navigation = { scope: 'batch', position: 1, total: 2, previous: null, next: 'task-two' }; open(first); toggle('right'); const emitted = jasmine.createSpy('navigate'); component.navigate.subscribe(emitted); const saveButton = fixture.nativeElement.querySelector('button.save'), navigation = fixture.nativeElement.querySelector('.review-navigation'); expect(saveButton.compareDocumentPosition(navigation) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); component.edit('review', 'note', '切换前保存'); void component.move('next'); expect(emitted).not.toHaveBeenCalled(); http.expectOne('/api/task-extensions/task-one/review').flush({}, {status: 409, statusText: 'Conflict'}); flushMicrotasks(); expect(emitted).not.toHaveBeenCalled(); expect(component.drafts['review'].dirty).toBeTrue(); void component.move('next'); saved(); flushMicrotasks(); expect(emitted).toHaveBeenCalledOnceWith('task-two'); fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges(); http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two')); fixture.detectChanges(); expect(component.panels('right').length).toBe(1); })); });