import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { cancellationReason, FinalReview, LlmRequestStatusComponent, ModelRequest, requestHint, requestTermination, reviewStage } from './llm-request-status.component'; const waiting: ModelRequest = { request_id: 'call-1', attempt_id: 'attempt-1', model: 'gpt-6-luna', role: 'FlashRunner', is_fallback: false, phase: 'waiting_output', active: true, attempt: 1, max_retries: 5, timeout_seconds: 300, elapsed_seconds: 42, started_at: Date.now() / 1000 - 42, http_status: 200, usage: null, images: [] }; describe('模型请求诊断', () => { beforeEach(() => TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] })); afterEach(() => TestBed.inject(HttpTestingController).verify()); it('收到200仍显示等待,未知用量不当作0;完成后取消轮询', fakeAsync(() => { const fixture = TestBed.createComponent(LlmRequestStatusComponent); fixture.componentRef.setInput('sessionId', 'task-one'); fixture.componentRef.setInput('running', true); fixture.detectChanges(); const http = TestBed.inject(HttpTestingController); http.expectOne('/api/sessions/task-one/request-diagnostics').flush({ requests: [waiting] }); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.request-live').textContent).toContain('等待模型输出'); expect(fixture.nativeElement.textContent).toContain('未知(服务未报告)'); expect(fixture.nativeElement.querySelector('details').open).toBeFalse(); tick(3000); http.expectOne('/api/sessions/task-one/request-diagnostics').flush({ requests: [{ ...waiting, active: false, phase: 'completed', ended_at: Date.now() / 1000 }] }); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.request-live')).toBeNull(); fixture.destroy(); tick(5000); http.expectNone('/api/sessions/task-one/request-diagnostics'); })); it('切换会话取消旧请求,历史失败不会显示正在重试', () => { const fixture = TestBed.createComponent(LlmRequestStatusComponent); const http = TestBed.inject(HttpTestingController); fixture.componentRef.setInput('sessionId', 'old'); fixture.detectChanges(); const old = http.expectOne('/api/sessions/old/request-diagnostics'); fixture.componentRef.setInput('sessionId', 'new'); fixture.detectChanges(); expect(old.cancelled).toBeTrue(); http.expectOne('/api/sessions/new/request-diagnostics').flush({ requests: [{ ...waiting, phase: 'retry_wait' }] }); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.request-live')).toBeNull(); expect(fixture.nativeElement.querySelector('a').href).toContain('/new/request-diagnostics?download=true'); fixture.destroy(); }); it('诊断接口失败时明确提示状态未知', () => { const fixture = TestBed.createComponent(LlmRequestStatusComponent); fixture.componentRef.setInput('sessionId', 'task'); fixture.detectChanges(); TestBed.inject(HttpTestingController).expectOne('/api/sessions/task/request-diagnostics').flush({}, { status: 503, statusText: 'Unavailable' }); fixture.detectChanges(); expect(fixture.nativeElement.textContent).toContain('不能据此判断任务是否卡住'); fixture.destroy(); }); it('重试倒计时到0后不谎称已发送请求', () => { expect(requestHint({ ...waiting, phase: 'retry_wait', retry_at: 110 }, 105)).toContain('5 秒后进行第 1/5 次重试'); expect(requestHint({ ...waiting, phase: 'retry_wait', retry_at: 110 }, 111)).toContain('等待下一次请求状态'); }); it('持续生成工具参数时展示工具名和字符数,不误报工具已执行', () => { const fixture = TestBed.createComponent(LlmRequestStatusComponent); fixture.componentRef.setInput('sessionId', 'tool-task'); fixture.componentRef.setInput('running', true); fixture.detectChanges(); TestBed.inject(HttpTestingController).expectOne('/api/sessions/tool-task/request-diagnostics').flush({ requests: [{ ...waiting, phase: 'generating_tool_arguments', tool_argument_chars: 15000, tools: [{ name: 'click', argument_chars: 15000 }], output_chunks: 200 }] }); fixture.detectChanges(); const text = fixture.nativeElement.querySelector('.request-live').textContent; expect(text).toContain('正在生成工具参数'); expect(text).toContain('click'); expect(text).toContain((15000).toLocaleString()); expect(text).toContain('本次工具尚未执行'); fixture.destroy(); }); it('无模型请求时仍展示验收阶段与总预算,耗尽后保留明确原因', fakeAsync(() => { const fixture = TestBed.createComponent(LlmRequestStatusComponent); const http = TestBed.inject(HttpTestingController); const now = Date.now() / 1000; const review: FinalReview = { review_id: 'review-1', attempt_id: 'final#1', status: 'running', stage: 'reading_evidence', active: true, started_at: now - 20, deadline_at: now + 80, total_seconds: 100, elapsed_seconds: 20, remaining_seconds: 80, request_count: 2, retry_count: 1, max_retries: 5 }; fixture.componentRef.setInput('sessionId', 'review-task'); fixture.componentRef.setInput('running', true); fixture.detectChanges(); http.expectOne('/api/sessions/review-task/request-diagnostics').flush({ requests: [], final_reviews: [review] }); fixture.detectChanges(); const text = fixture.nativeElement.querySelector('.review-live').textContent; expect(text).toContain('正在读取验收证据'); expect(text).toContain('测试仍为待判定'); expect(text).toContain('剩余验收预算 80 秒'); expect(text).toContain('已重试 1 次'); tick(1000); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.review-live').textContent).toContain('剩余验收预算 79 秒'); tick(2000); http.expectOne('/api/sessions/review-task/request-diagnostics').flush({ requests: [], final_reviews: [{ ...review, active: false, status: 'budget_exhausted', ended_at: now + 3, elapsed_seconds: 100 }] }); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.review-live')).toBeNull(); expect(fixture.nativeElement.textContent).toContain('验收总预算已用尽'); expect(fixture.nativeElement.textContent).toContain('未确认通过'); fixture.destroy(); tick(3000); http.expectNone('/api/sessions/review-task/request-diagnostics'); })); it('区分重试耗尽、验收中断与完成,不把完成当成通过', () => { const review = { status: 'request_retries_exhausted' } as FinalReview; expect(reviewStage(review)).toBe('验收请求重试已用尽'); expect(reviewStage({ ...review, status: 'cancelled' })).toBe('验收已中断'); expect(reviewStage({ ...review, status: 'completed' })).toBe('验收完成'); }); it('取消原因有来源才显示,旧记录与未知字段保留未知', () => { expect(cancellationReason('user_stop')).toContain('用户停止'); expect(cancellationReason('guidance_replaced')).toContain('新指导替换'); expect(cancellationReason('task_paused')).toContain('任务暂停'); expect(cancellationReason('task_replaced')).toContain('新任务替换'); expect(cancellationReason('superseded')).toContain('新的检查或计划'); expect(cancellationReason()).toContain('取消来源未记录'); expect(cancellationReason('untrusted new instruction')).toContain('取消来源未记录'); expect(requestTermination({ ...waiting, phase: 'cancelled' })).toContain('取消来源未记录'); }); it('区分三层超时、未完成输出与未确认来源的499', () => { expect(requestTermination({ ...waiting, phase: 'failed', timeout_scope: 'request' })).toContain('单次模型请求'); expect(requestTermination({ ...waiting, phase: 'failed', timeout_scope: 'transport' })).toContain('连接或读写超时'); expect(requestTermination({ ...waiting, phase: 'failed', timeout_scope: 'final_review' })).toContain('最终验收总预算'); expect(requestTermination({ ...waiting, phase: 'cancelled', cancel_reason: 'outer_timeout', timeout_scope: 'outer' })).toContain('外层等待时限'); expect(requestTermination({ ...waiting, phase: 'failed', http_status: 499 })).toContain('来源尚未确认'); expect(requestTermination({ ...waiting, phase: 'failed', category: 'connection' })).toContain('来源需结合服务端记录'); expect(requestTermination({ ...waiting, phase: 'failed', response_guard: { reason: 'missing_completion' } })).toContain('本次输出未被采用'); expect(requestTermination({ ...waiting, phase: 'completed' })).toBe(''); }); it('将本次尝试、上次重试、链路编号与结束原因一起展示', () => { const fixture = TestBed.createComponent(LlmRequestStatusComponent); fixture.componentRef.setInput('sessionId', 'cancelled'); fixture.detectChanges(); TestBed.inject(HttpTestingController).expectOne('/api/sessions/cancelled/request-diagnostics').flush({ requests: [{ ...waiting, active: false, phase: 'cancelled', cancel_reason: 'guidance_replaced', cancel_phase: 'retry_wait', previous_attempt_id: 'attempt-before', client_request_id: 'client-wire-id', server_request_id: 'server-wire-id', response_id: 'resp-from-server', server_trace_id: 'gateway-trace', traceparent: '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', ended_at: Date.now() / 1000 }] }); fixture.detectChanges(); const text = fixture.nativeElement.textContent; for (const value of ['attempt-before', 'client-wire-id', 'server-wire-id', 'resp-from-server', 'gateway-trace', '新指导替换了旧请求', '中断阶段', '等待重试']) expect(text).toContain(value); expect(fixture.nativeElement.querySelector('.request-live')).toBeNull(); fixture.destroy(); }); });