feat: release 1.5.1 with human review and batch filters
This commit is contained in:
1 parent
53c5680260
commit
bdd1aa2c14
36 files changed
+672
-57
No files matched your search
+7
-2
@@ -8,7 +8,7 @@ export const TEST_RESULT_LABELS: Record<CaseTestResult['status'], string> = {
|
||||
@Component({
|
||||
selector: 'app-test-result-badge',
|
||||
standalone: true,
|
||||
template: `<span [class]="'test-result-badge ' + status()" [class.prominent]="prominent()" [title]="result()?.reason || '执行状态与测试结论分别记录'"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
|
||||
template: `<span [class]="'test-result-badge ' + status()" [class.compact]="compact()" [class.prominent]="prominent()" [title]="(result()?.source === 'human' ? '人工确认:' : 'AI确认:') + (result()?.reason || '执行状态与测试结论分别记录')"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
|
||||
styles: [`
|
||||
:host{display:inline-flex;vertical-align:middle;max-width:100%}
|
||||
.test-result-badge{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;border-radius:6px;border:1px solid #cbd5e1;padding:3px 8px;font-size:12px;font-weight:700;line-height:1.5;background:#f1f5f9;color:#475569}
|
||||
@@ -17,6 +17,8 @@ export const TEST_RESULT_LABELS: Record<CaseTestResult['status'], string> = {
|
||||
.failed{background:#b91c1c;color:#fff;border-color:#b91c1c}
|
||||
.inconclusive{background:#fff7ed;color:#9a3412;border-color:#fdba74}
|
||||
.blocked{background:#fef3c7;color:#92400e;border-color:#fbbf24}
|
||||
.compact{box-sizing:border-box;height:20px;padding:0 6px;font-size:11px;line-height:14px;gap:3px;border-radius:5px}
|
||||
.compact .material-symbols-outlined{font-size:13px}
|
||||
.prominent{padding:4px 9px;font-size:14px;gap:5px}
|
||||
.prominent .material-symbols-outlined{font-size:18px}
|
||||
`]
|
||||
@@ -24,8 +26,11 @@ export const TEST_RESULT_LABELS: Record<CaseTestResult['status'], string> = {
|
||||
export class TestResultBadgeComponent {
|
||||
result = input<CaseTestResult | null | undefined>();
|
||||
executionStatus = input('');
|
||||
compact = input(false);
|
||||
prominent = input(false);
|
||||
status = computed(() => this.result()?.status || (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()) ? 'inconclusive' : 'pending'));
|
||||
label = computed(() => this.status() === 'pending' ? '待判定' : TEST_RESULT_LABELS[this.status() as CaseTestResult['status']]);
|
||||
label = computed(() => this.result()?.source === 'human'
|
||||
? (this.status() === 'passed' ? '人工通过' : '人工不通过')
|
||||
: this.status() === 'pending' ? '待判定' : TEST_RESULT_LABELS[this.status() as CaseTestResult['status']]);
|
||||
icon = computed(() => ({passed: 'check_circle', failed: 'cancel', blocked: 'block', inconclusive: 'help', pending: 'hourglass_empty'})[this.status()]);
|
||||
}
|
||||
+46
@@ -1,7 +1,11 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestResultCardComponent } from './test-result-card.component';
|
||||
|
||||
describe('用例测试结果卡片', () => {
|
||||
beforeEach(() => TestBed.configureTestingModule({providers:[provideHttpClient(), provideHttpClientTesting()]}));
|
||||
afterEach(() => TestBed.inject(HttpTestingController).verify());
|
||||
it('显式展示不通过及原因,允许展开证据', () => {
|
||||
const fixture = TestBed.createComponent(TestResultCardComponent);
|
||||
fixture.componentRef.setInput('result', {status: 'failed', reason: '预期英文,实际为中文。', evidence: ['截图显示搜索设置']});
|
||||
@@ -16,4 +20,46 @@ describe('用例测试结果卡片', () => {
|
||||
expect(details.open).toBeTrue();
|
||||
expect(details.textContent).toContain('截图显示搜索设置');
|
||||
});
|
||||
|
||||
it('人工复验可覆盖 AI 判定,保留原始结论并提交备注', () => {
|
||||
const fixture = TestBed.createComponent(TestResultCardComponent);
|
||||
const ai = {status:'failed',reason:'AI发现不一致',evidence:['步骤 3 截图'],source:'ai'};
|
||||
fixture.componentRef.setInput('result', ai);
|
||||
fixture.componentRef.setInput('sessionId', 'task-1'); fixture.componentRef.setInput('executionStatus','completed');
|
||||
const saved = jasmine.createSpy(); fixture.componentInstance.reviewSaved.subscribe(saved);
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('.review-toolbar button').click(); fixture.detectChanges();
|
||||
fixture.componentInstance.note = '实际符合预期,已人工核对';
|
||||
fixture.nativeElement.querySelector('.confirm-pass').click(); fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.confirm-fail').disabled).toBeTrue();
|
||||
const request = TestBed.inject(HttpTestingController).expectOne('/api/automation/reports/task-1/review');
|
||||
expect(request.request.method).toBe('PUT');
|
||||
expect(request.request.body).toEqual({status:'passed',reason:'实际符合预期,已人工核对'});
|
||||
const manual = {status:'passed',reason:'实际符合预期,已人工核对',evidence:[],source:'human',reviewed_at:100,ai_result:ai};
|
||||
request.flush({id:'task-1',test_result:manual,verdict:'测试通过(人工确认)'});
|
||||
fixture.componentRef.setInput('result',manual); fixture.detectChanges();
|
||||
expect(saved).toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain('人工通过');
|
||||
expect(fixture.nativeElement.textContent).toContain('原 AI 结论');
|
||||
expect(fixture.nativeElement.textContent).toContain('AI发现不一致');
|
||||
expect(fixture.nativeElement.querySelector('.review-form')).toBeNull();
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('保存失败时保留备注并允许重试,执行中不显示人工复验', () => {
|
||||
const fixture = TestBed.createComponent(TestResultCardComponent);
|
||||
fixture.componentRef.setInput('result',{status:'inconclusive',reason:'尚未验证',evidence:[]});
|
||||
fixture.componentRef.setInput('sessionId','task-2'); fixture.componentRef.setInput('executionStatus','running');
|
||||
fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.review-toolbar')).toBeNull();
|
||||
fixture.componentRef.setInput('executionStatus','failed'); fixture.detectChanges();
|
||||
fixture.nativeElement.querySelector('.review-toolbar button').click(); fixture.detectChanges();
|
||||
fixture.componentInstance.note = '人工发现异常';
|
||||
fixture.nativeElement.querySelector('.confirm-fail').click();
|
||||
TestBed.inject(HttpTestingController).expectOne('/api/automation/reports/task-2/review').flush({detail:'报告正在打包'}, {status:409,statusText:'Conflict'});
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.note).toBe('人工发现异常');
|
||||
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('报告正在打包');
|
||||
expect(fixture.nativeElement.querySelector('.confirm-fail').disabled).toBeFalse();
|
||||
fixture.destroy();
|
||||
});
|
||||
});
|
||||
+49
-4
@@ -1,15 +1,30 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { CaseTestResult } from '../../core/models/session.model';
|
||||
import { Component, computed, effect, inject, input, output, signal } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { CaseReviewResponse, CaseTestResult } from '../../core/models/session.model';
|
||||
import { TestResultBadgeComponent } from './test-result-badge.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-test-result-card',
|
||||
standalone: true,
|
||||
imports: [TestResultBadgeComponent],
|
||||
imports: [TestResultBadgeComponent, DatePipe, FormsModule],
|
||||
template: `<section [class]="'test-result-card ' + result().status" aria-label="用例测试结果" role="status">
|
||||
<header><span class="caption">用例测试结果</span><app-test-result-badge [result]="result()" [prominent]="true" /></header>
|
||||
<header><span class="caption">用例测试结果 <small>{{result().source === 'human' ? '人工确认' : 'AI确认'}}</small></span><app-test-result-badge [result]="result()" [prominent]="true" /></header>
|
||||
<p class="reason">{{result().reason}}</p>
|
||||
@if(result().reviewed_at){<p class="review-time">复验于 {{result().reviewed_at! * 1000 | date:'yyyy-MM-dd HH:mm:ss'}}</p>}
|
||||
@if(result().ai_result; as original){<details><summary>原 AI 结论 · {{original.status === 'passed' ? '测试通过' : original.status === 'failed' ? '测试不通过' : original.status === 'blocked' ? '测试阻塞' : '未验证'}}</summary><p class="reason">{{original.reason}}</p>@if(original.evidence.length){<ol>@for(item of original.evidence; track $index){<li>{{item}}</li>}</ol>}</details>}
|
||||
@if(result().evidence.length){<details><summary>查看验收证据 · {{result().evidence.length}} 条</summary><ol>@for(item of result().evidence; track $index){<li>{{item}}</li>}</ol></details>}
|
||||
@if(canReview()){
|
||||
<div class="review-toolbar"><button type="button" [disabled]="saving()" (click)="toggleReview()" [attr.aria-expanded]="reviewing()">{{result().source === 'human' ? '修改人工复验' : '人工复验'}}</button>@if(notice()){<span role="status">{{notice()}}</span>}</div>
|
||||
@if(reviewing()){
|
||||
<div class="review-form" aria-label="人工复验">
|
||||
<label>复验备注(选填)<textarea [(ngModel)]="note" [disabled]="saving()" rows="2" maxlength="2000" placeholder="填写人工检查依据或发现的问题"></textarea></label>
|
||||
<div class="review-actions"><button type="button" class="confirm-pass" [disabled]="saving()" (click)="saveReview('passed')">确认通过</button><button type="button" class="confirm-fail" [disabled]="saving()" (click)="saveReview('failed')">确认不通过</button><button type="button" [disabled]="saving()" (click)="reviewing.set(false)">取消</button>@if(saving()){<span role="status">正在保存…</span>}</div>
|
||||
@if(error()){<p class="review-error" role="alert">{{error()}}</p>}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</section>`,
|
||||
styles: [`
|
||||
:host{display:block;margin:8px 0}
|
||||
@@ -19,13 +34,43 @@ import { TestResultBadgeComponent } from './test-result-badge.component';
|
||||
.inconclusive,.blocked{border-color:#fde68a;border-left-color:#b45309;background:#fffbeb}
|
||||
header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}
|
||||
.caption{font-size:12px;font-weight:600;color:#475569}
|
||||
.caption small{font-size:11px;font-weight:400;margin-left:7px;color:#64748b}
|
||||
.reason{font-size:13px;line-height:1.7;margin:8px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;color:#1e293b}
|
||||
details{margin-top:6px;font-size:12px;line-height:1.7;color:#475569}
|
||||
summary{cursor:pointer;width:fit-content;padding:2px 0;font-weight:600}
|
||||
ol{padding-left:22px;margin:8px 0 0}li{padding:3px 0;white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
.review-time{margin:5px 0 0;color:#64748b;font-size:11px}
|
||||
.review-toolbar,.review-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;font-size:12px}
|
||||
button{border:1px solid #cbd5e1;border-radius:6px;padding:4px 9px;background:#fff;color:#475569;font:inherit;cursor:pointer;line-height:1.5}
|
||||
button:hover{border-color:#94a3b8;background:#f8fafc}button:disabled{opacity:.55;cursor:wait}button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}
|
||||
.review-toolbar span{color:#15803d}.review-form{border-top:1px solid #cbd5e1;margin-top:10px;padding-top:10px;font-size:12px}
|
||||
label{display:block;color:#475569}textarea{display:block;box-sizing:border-box;width:100%;resize:vertical;border:1px solid #cbd5e1;border-radius:6px;margin-top:5px;padding:7px 9px;color:#1e293b;background:#fff;font:inherit;line-height:1.6}
|
||||
.confirm-pass{color:#166534;border-color:#86efac;background:#f0fdf4}.confirm-fail{color:#991b1b;border-color:#fca5a5;background:#fef2f2}.review-error{color:#b91c1c;margin:8px 0 0}
|
||||
@media(max-width:480px){.test-result-card{padding:10px 12px}}
|
||||
`]
|
||||
})
|
||||
export class TestResultCardComponent {
|
||||
private http = inject(HttpClient);
|
||||
result = input.required<CaseTestResult>();
|
||||
sessionId = input<string | null>(null);
|
||||
executionStatus = input('');
|
||||
reviewSaved = output<CaseReviewResponse>();
|
||||
reviewing = signal(false); saving = signal(false); error = signal(''); notice = signal(''); note = '';
|
||||
canReview = computed(() => !!this.sessionId() && ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.executionStatus()));
|
||||
constructor() {
|
||||
effect(() => { this.sessionId(); this.reviewing.set(false); this.error.set(''); this.notice.set(''); this.note = ''; });
|
||||
}
|
||||
toggleReview() { this.reviewing.update(value => !value); this.error.set(''); this.notice.set(''); this.note = this.result().source === 'human' ? this.result().reason : ''; }
|
||||
saveReview(status: 'passed' | 'failed') {
|
||||
const id = this.sessionId();
|
||||
if (!id || !this.canReview() || this.saving()) return;
|
||||
this.saving.set(true); this.error.set('');
|
||||
this.http.put<CaseReviewResponse>(`/api/automation/reports/${encodeURIComponent(id)}/review`, {status, reason: this.note.trim()}).subscribe({
|
||||
next: response => {
|
||||
this.saving.set(false); this.reviewSaved.emit(response);
|
||||
if (this.sessionId() === id) { this.reviewing.set(false); this.note = ''; this.notice.set('人工复验已保存,报告已更新'); }
|
||||
},
|
||||
error: error => { this.saving.set(false); if (this.sessionId() === id) this.error.set(error.error?.detail || '复验保存失败,请重试'); }
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user