Commit d7af8fce authored by nanahira's avatar nanahira

filters

parent 30f1c126
...@@ -9,3 +9,33 @@ ...@@ -9,3 +9,33 @@
.progress { .progress {
height: 0.75rem; height: 0.75rem;
} }
.report-scroll {
max-height: 70vh;
overflow-y: auto;
padding-right: 0.5rem;
}
details > summary {
cursor: pointer;
list-style: none;
}
details > summary::-webkit-details-marker {
display: none;
}
.summary-row {
user-select: none;
}
details > summary::after {
content: "▾";
color: #6c757d;
margin-left: 0.5rem;
transition: transform 0.15s ease;
}
details[open] > summary::after {
transform: rotate(180deg);
}
...@@ -7,23 +7,35 @@ ...@@ -7,23 +7,35 @@
</p> </p>
</header> </header>
<section class="card shadow-sm mb-4"> <section class="card shadow-sm mb-4 no-print">
<div class="card-body"> <div class="card-body">
<div class="mb-3"> <div class="mb-3">
<label class="form-label" for="packageInput">选择扩展包</label> <div class="d-flex align-items-center gap-2">
<input <div class="flex-grow-1">
id="packageInput" <label class="form-label" for="packageInput">选择扩展包</label>
class="form-control" <input
type="file" id="packageInput"
accept=".zip,.ypk,application/zip" class="form-control"
multiple type="file"
[disabled]="running()" accept=".zip,.ypk,application/zip"
(click)="onFileInputClick($event)" multiple
(change)="onFileSelection($event)" [disabled]="running()"
aria-describedby="packageHelp" (click)="onFileInputClick($event)"
/> (change)="onFileSelection($event)"
<div id="packageHelp" class="form-text"> aria-describedby="packageHelp"
支持多选,支持 zip 或 ypk。 />
<div id="packageHelp" class="form-text">
支持多选,支持 zip 或 ypk。
</div>
</div>
<button
type="button"
class="btn btn-outline-secondary btn-sm"
[disabled]="running()"
(click)="clearSelection()"
>
清空
</button>
</div> </div>
</div> </div>
...@@ -34,20 +46,15 @@ ...@@ -34,20 +46,15 @@
@for (file of selectedFiles(); track file.name) { @for (file of selectedFiles(); track file.name) {
<li class="list-group-item px-0 d-flex justify-content-between"> <li class="list-group-item px-0 d-flex justify-content-between">
<span>{{ file.name }}</span> <span>{{ file.name }}</span>
<span class="text-muted small">{{ file.size }} bytes</span> <span class="text-muted small">{{ formatFileSize(file.size) }}</span>
</li> </li>
} }
</ul> </ul>
</div> </div>
} }
<div class="d-flex flex-wrap gap-2"> <div class="small text-muted">
<button type="button" class="btn btn-primary" [disabled]="!canStart()" (click)="startTests()"> 选择文件后将自动开始测试。
开始测试
</button>
<button type="button" class="btn btn-outline-secondary" [disabled]="running()" (click)="clearSelection()">
清空
</button>
</div> </div>
</div> </div>
</section> </section>
...@@ -58,7 +65,7 @@ ...@@ -58,7 +65,7 @@
</div> </div>
} }
<section class="card shadow-sm mb-4" [attr.aria-busy]="running()"> <section class="card shadow-sm mb-4 no-print" [attr.aria-busy]="running()">
<div class="card-body"> <div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-2"> <div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-2">
<div> <div>
...@@ -96,44 +103,124 @@ ...@@ -96,44 +103,124 @@
@if (hasResults()) { @if (hasResults()) {
<section class="mb-5" aria-live="polite"> <section class="mb-5" aria-live="polite">
<h2 class="h5 mb-3">测试报告</h2> <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2">
<h2 class="h5 mb-0">测试报告</h2>
@for (report of reports(); track report.fileName) { <div class="d-flex flex-wrap align-items-center gap-2 no-print">
<article class="card shadow-sm mb-4"> @if (zipFilterOptions().length > 1) {
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2"> <label class="form-label small mb-0" for="zipFilter">扩展包筛选</label>
<div> <select
<h3 class="h6 mb-1">{{ report.fileName }}</h3> id="zipFilter"
<div class="small text-muted">{{ packageCardSummary(report) }}</div> class="form-select form-select-sm w-auto"
[value]="zipFilter()"
(change)="onZipFilterChange($event)"
>
<option value="all">全部</option>
@if (zipFilterOptions().includes('passed')) {
<option value="passed">正常</option>
}
@if (zipFilterOptions().includes('failed')) {
<option value="failed">异常</option>
}
@if (zipFilterOptions().includes('empty')) {
<option value="empty">无内容</option>
}
</select>
}
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="exportPdf()">
导出 PDF
</button>
</div>
</div>
<div class="card shadow-sm mb-3">
<div class="card-body py-2">
<div class="row g-2 small">
<div class="col-md-4">
扩展包:{{ overallStats().zipTotal }},正常 {{ overallStats().zipPassed }},异常
{{ overallStats().zipFailed }}
</div>
<div class="col-md-4">
CDB:{{ overallStats().cdbTotal }},正常 {{ overallStats().cdbPassed }},异常
{{ overallStats().cdbFailed }}
</div>
<div class="col-md-4">
卡片:{{ overallStats().cardTotal }},通过 {{ overallStats().cardPassed }},失败
{{ overallStats().cardFailed }}
</div> </div>
<span [class]="packageBadgeClass(report.status)">
{{ packageStatusLabel(report.status) }}
</span>
</div> </div>
<div class="card-body"> </div>
</div>
<div class="report-scroll" tabindex="0" aria-label="测试报告滚动区域">
@if (visibleReports().length === 0) {
<div class="text-muted">当前筛选无扩展包。</div>
}
@for (report of visibleReports(); track report.fileName) {
<details class="card shadow-sm mb-4" open>
<summary class="card-header summary-row d-flex flex-wrap align-items-center gap-2">
<div>
<h3 class="h6 mb-1">{{ report.fileName }}</h3>
<div class="small text-muted">
{{ packageCardSummary(report) }} · {{ packageCdbSummary(report) }}
</div>
</div>
<span class="ms-auto" [class]="packageBadgeClass(report)">
{{ packageStatusLabel(report) }}
</span>
</summary>
<div class="card-body">
@if (report.error) { @if (report.error) {
<div class="alert alert-danger" role="alert"> <div class="alert alert-danger" role="alert">
{{ report.error }} {{ report.error }}
</div> </div>
} }
@if (cdbFilterOptions(report).length > 1) {
<div class="d-flex flex-wrap align-items-center gap-2 mb-3">
<label class="form-label small mb-0" for="cdbFilter-{{ packageIndexOf(report) }}">
CDB 筛选
</label>
<select
id="cdbFilter-{{ packageIndexOf(report) }}"
class="form-select form-select-sm w-auto"
[value]="report.cdbFilter"
(change)="onPackageFilterChange($event, report)"
>
<option value="all">全部</option>
@if (cdbFilterOptions(report).includes('passed')) {
<option value="passed">正常</option>
}
@if (cdbFilterOptions(report).includes('failed')) {
<option value="failed">异常</option>
}
@if (cdbFilterOptions(report).includes('empty')) {
<option value="empty">无内容</option>
}
</select>
</div>
}
@if (report.cdbs.length === 0) { @if (report.cdbs.length === 0) {
<div class="text-muted">未在根目录找到 cdb 文件。</div> <div class="text-muted">未在根目录找到 cdb 文件。</div>
} @else if (visibleCdbs(report).length === 0) {
<div class="text-muted">当前筛选无 cdb。</div>
} }
@for (cdb of report.cdbs; track cdb.fileName) { @for (cdb of visibleCdbs(report); track cdb.fileName) {
<section class="border rounded p-3 mb-3"> <details class="border rounded p-3 mb-3" open>
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2"> <summary class="summary-row d-flex flex-wrap align-items-start gap-2">
<div> <div>
<h4 class="h6 mb-1">{{ cdb.fileName }}</h4> <h4 class="h6 mb-1">{{ cdb.fileName }}</h4>
<div class="small text-muted"> <div class="small text-muted">
卡片 {{ cdb.totalCards }},已测 {{ cdb.testedCards }},通过 {{ cdb.passedCards }},失败 卡片 {{ cdb.totalTestableCards }},已测 {{ cdb.testedCards }},通过 {{ cdb.passedCards }},失败
{{ cdb.failedCards }} {{ cdb.failedCards }}
</div> </div>
</div> </div>
<span [class]="cdbBadgeClass(cdb.status)"> <span class="ms-auto" [class]="cdbBadgeClass(cdb)">
{{ cdbStatusLabel(cdb.status) }} {{ cdbStatusLabel(cdb) }}
</span> </span>
</div> </summary>
@if (cdb.error) { @if (cdb.error) {
<div class="alert alert-danger mt-2" role="alert"> <div class="alert alert-danger mt-2" role="alert">
...@@ -141,11 +228,44 @@ ...@@ -141,11 +228,44 @@
</div> </div>
} }
@if (cardFilterOptions(cdb).length > 1) {
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
<label
class="form-label small mb-0"
for="cardFilter-{{ packageIndexOf(report) }}-{{ cdbIndexOf(report, cdb) }}"
>
卡片筛选
</label>
<select
id="cardFilter-{{ packageIndexOf(report) }}-{{ cdbIndexOf(report, cdb) }}"
class="form-select form-select-sm w-auto"
[value]="cdb.cardFilter"
(change)="onCdbFilterChange($event, report, cdb)"
>
<option value="all">全部</option>
@if (cardFilterOptions(cdb).includes('passed')) {
<option value="passed">通过</option>
}
@if (cardFilterOptions(cdb).includes('failed')) {
<option value="failed">失败</option>
}
@if (cardFilterOptions(cdb).includes('skip')) {
<option value="skip">无需测试</option>
}
@if (cardFilterOptions(cdb).includes('empty')) {
<option value="empty">无内容</option>
}
</select>
</div>
}
@if (cdb.cards.length === 0) { @if (cdb.cards.length === 0) {
<div class="text-muted mt-2">没有需要测试的卡片。</div> <div class="text-muted mt-2">没有需要测试的卡片。</div>
} @else if (visibleCards(cdb).length === 0) {
<div class="text-muted mt-2">当前筛选无卡片。</div>
} }
@for (card of cdb.cards; track card.id) { @for (card of visibleCards(cdb); track card.id) {
<div class="card mt-3"> <div class="card mt-3">
<div class="card-body py-2"> <div class="card-body py-2">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2"> <div class="d-flex flex-wrap justify-content-between align-items-start gap-2">
...@@ -154,12 +274,22 @@ ...@@ -154,12 +274,22 @@
{{ card.name }} {{ card.name }}
<span class="text-muted">#{{ card.id }}</span> <span class="text-muted">#{{ card.id }}</span>
</div> </div>
<div class="small text-muted"> @if (card.skipTest) {
ScriptError 数量:{{ card.scriptErrors.length }} <div class="small text-muted">无需测试</div>
</div> } @else if (card.scriptErrors.length > 0) {
<div class="small text-muted">
错误信息数量:{{ card.scriptErrors.length }}
</div>
} @else if (card.status === 'passed') {
<div class="small text-muted">卡片状态正常</div>
} @else if (card.status === 'pending') {
<div class="small text-muted">等待测试输出。</div>
} @else if (card.status === 'running') {
<div class="small text-muted">正在生成测试输出...</div>
}
</div> </div>
<span [class]="cardBadgeClass(card.status)"> <span [class]="cardBadgeClassForCard(card)">
{{ cardStatusLabel(card.status) }} {{ cardStatusLabelForCard(card) }}
</span> </span>
</div> </div>
...@@ -170,13 +300,9 @@ ...@@ -170,13 +300,9 @@
</div> </div>
} }
<div class="mt-2"> @if (card.logs.length > 0) {
<div class="small text-muted">测试输出</div> <div class="mt-2">
@if (card.status === 'pending') { <div class="small text-muted">测试输出</div>
<div class="small text-muted">等待测试输出。</div>
} @else if (card.status === 'running') {
<div class="small text-muted">正在生成测试输出...</div>
} @else if (card.logs.length > 0) {
<ul class="small mb-0"> <ul class="small mb-0">
@for (entry of card.logs; track $index) { @for (entry of card.logs; track $index) {
<li <li
...@@ -187,23 +313,22 @@ ...@@ -187,23 +313,22 @@
</li> </li>
} }
</ul> </ul>
} @else if (card.status === 'error') { </div>
<div class="small text-muted">测试未生成日志输出。</div> }
} @else {
<div class="small text-muted">卡片状态正常</div>
}
</div>
</div> </div>
</div> </div>
} }
</section> </details>
} }
</div> </div>
</article> </details>
} }
</div>
</section> </section>
} @else {
<div class="text-muted">暂无测试报告。</div>
} }
</div> </div>
<footer class="py-4 text-center text-muted small">
<span>© 2026 Nanahira</span>
</footer>
</main> </main>
...@@ -20,8 +20,16 @@ import { ...@@ -20,8 +20,16 @@ import {
import { YGOPRO_CDB_URL, YGOPRO_SCRIPT_ZIP_URL } from './resource-urls'; import { YGOPRO_CDB_URL, YGOPRO_SCRIPT_ZIP_URL } from './resource-urls';
type PackageStatus = 'pending' | 'running' | 'done' | 'error'; type PackageStatus = 'pending' | 'running' | 'done' | 'error';
type CdbStatus = 'pending' | 'running' | 'done' | 'skipped' | 'error'; type CdbStatus = 'pending' | 'running' | 'done' | 'error';
type CardStatus = 'pending' | 'running' | 'passed' | 'failed' | 'error'; type CardStatus = 'pending' | 'running' | 'passed' | 'failed' | 'error';
type FilterOption = 'all' | 'passed' | 'failed' | 'skip' | 'empty';
type DisplayStatus =
| 'pending'
| 'running'
| 'normal'
| 'abnormal'
| 'error'
| 'empty';
type CardTestResult = { type CardTestResult = {
id: number; id: number;
...@@ -29,13 +37,16 @@ type CardTestResult = { ...@@ -29,13 +37,16 @@ type CardTestResult = {
status: CardStatus; status: CardStatus;
scriptErrors: string[]; scriptErrors: string[];
logs: TestCardMessage[]; logs: TestCardMessage[];
skipTest: boolean;
detail?: string; detail?: string;
}; };
type CdbTestResult = { type CdbTestResult = {
fileName: string; fileName: string;
status: CdbStatus; status: CdbStatus;
cardFilter: FilterOption;
cards: CardTestResult[]; cards: CardTestResult[];
totalTestableCards: number;
totalCards: number; totalCards: number;
testedCards: number; testedCards: number;
passedCards: number; passedCards: number;
...@@ -46,6 +57,7 @@ type CdbTestResult = { ...@@ -46,6 +57,7 @@ type CdbTestResult = {
type PackageTestResult = { type PackageTestResult = {
fileName: string; fileName: string;
status: PackageStatus; status: PackageStatus;
cdbFilter: FilterOption;
cdbs: CdbTestResult[]; cdbs: CdbTestResult[];
error?: string; error?: string;
}; };
...@@ -60,6 +72,7 @@ type ProgressSnapshot = { ...@@ -60,6 +72,7 @@ type ProgressSnapshot = {
type SqlCardRow = { type SqlCardRow = {
id: number; id: number;
name: string | null; name: string | null;
type: number | null;
}; };
type ZipCdbEntry = { type ZipCdbEntry = {
...@@ -93,7 +106,7 @@ export class App { ...@@ -93,7 +106,7 @@ export class App {
let failed = 0; let failed = 0;
for (const report of this.reports()) { for (const report of this.reports()) {
for (const cdb of report.cdbs) { for (const cdb of report.cdbs) {
total += cdb.totalCards; total += cdb.totalTestableCards;
done += cdb.testedCards; done += cdb.testedCards;
failed += cdb.failedCards; failed += cdb.failedCards;
} }
...@@ -111,6 +124,61 @@ export class App { ...@@ -111,6 +124,61 @@ export class App {
}); });
protected readonly hasResults = computed(() => this.reports().length > 0); protected readonly hasResults = computed(() => this.reports().length > 0);
protected readonly zipFilter = signal<FilterOption>('all');
protected readonly visibleReports = computed(() =>
this.reports().filter((report) =>
this.matchesFilter(
this.packageDisplayStatus(report),
this.resolveFilter(this.zipFilter(), this.zipFilterOptions()),
),
),
);
protected readonly overallStats = computed(() => {
const reports = this.reports();
const zipTotal = reports.length;
let zipPassed = 0;
let zipFailed = 0;
let cdbTotal = 0;
let cdbPassed = 0;
let cdbFailed = 0;
let cardTotal = 0;
let cardPassed = 0;
let cardFailed = 0;
for (const report of reports) {
const zipStatus = this.packageDisplayStatus(report);
if (zipStatus === 'normal') {
zipPassed += 1;
} else if (zipStatus === 'abnormal' || zipStatus === 'error') {
zipFailed += 1;
}
for (const cdb of report.cdbs) {
cdbTotal += 1;
const cdbStatus = this.cdbDisplayStatus(cdb);
if (cdbStatus === 'normal') {
cdbPassed += 1;
} else if (cdbStatus === 'abnormal' || cdbStatus === 'error') {
cdbFailed += 1;
}
cardTotal += cdb.totalTestableCards;
cardPassed += cdb.passedCards;
cardFailed += cdb.failedCards;
}
}
return {
zipTotal,
zipPassed,
zipFailed,
cdbTotal,
cdbPassed,
cdbFailed,
cardTotal,
cardPassed,
cardFailed,
};
});
private sqlModule: SqlJsStatic | null = null; private sqlModule: SqlJsStatic | null = null;
private baseDb: Database | null = null; private baseDb: Database | null = null;
...@@ -130,10 +198,12 @@ export class App { ...@@ -130,10 +198,12 @@ export class App {
this.selectedFiles.set(files); this.selectedFiles.set(files);
this.reports.set([]); this.reports.set([]);
this.globalError.set(null); this.globalError.set(null);
this.zipFilter.set('all');
if (files.length === 0) { if (files.length === 0) {
this.statusMessage.set('等待上传扩展包。'); this.statusMessage.set('等待上传扩展包。');
} else { } else {
this.statusMessage.set(`已选择 ${files.length} 个扩展包,准备开始测试。`); this.statusMessage.set(`已选择 ${files.length} 个扩展包,开始测试。`);
void this.startTests();
} }
} }
...@@ -141,6 +211,7 @@ export class App { ...@@ -141,6 +211,7 @@ export class App {
this.selectedFiles.set([]); this.selectedFiles.set([]);
this.reports.set([]); this.reports.set([]);
this.globalError.set(null); this.globalError.set(null);
this.zipFilter.set('all');
this.statusMessage.set('已清空选择。'); this.statusMessage.set('已清空选择。');
} }
...@@ -156,6 +227,7 @@ export class App { ...@@ -156,6 +227,7 @@ export class App {
files.map((file) => ({ files.map((file) => ({
fileName: file.name, fileName: file.name,
status: 'pending', status: 'pending',
cdbFilter: 'all',
cdbs: [], cdbs: [],
})), })),
); );
...@@ -184,12 +256,16 @@ export class App { ...@@ -184,12 +256,16 @@ export class App {
} }
} }
protected packageBadgeClass(status: PackageStatus): string { protected packageBadgeClass(report: PackageTestResult): string {
switch (status) { switch (this.packageDisplayStatus(report)) {
case 'running': case 'running':
return 'badge text-bg-primary'; return 'badge text-bg-primary';
case 'done': case 'normal':
return 'badge text-bg-success'; return 'badge text-bg-success';
case 'abnormal':
return 'badge text-bg-warning';
case 'empty':
return 'badge text-bg-secondary';
case 'error': case 'error':
return 'badge text-bg-danger'; return 'badge text-bg-danger';
default: default:
...@@ -197,12 +273,16 @@ export class App { ...@@ -197,12 +273,16 @@ export class App {
} }
} }
protected packageStatusLabel(status: PackageStatus): string { protected packageStatusLabel(report: PackageTestResult): string {
switch (status) { switch (this.packageDisplayStatus(report)) {
case 'running': case 'running':
return '测试中'; return '测试中';
case 'done': case 'normal':
return '已完成'; return '正常';
case 'abnormal':
return '异常';
case 'empty':
return '无内容';
case 'error': case 'error':
return '出错'; return '出错';
default: default:
...@@ -210,14 +290,16 @@ export class App { ...@@ -210,14 +290,16 @@ export class App {
} }
} }
protected cdbBadgeClass(status: CdbStatus): string { protected cdbBadgeClass(cdb: CdbTestResult): string {
switch (status) { switch (this.cdbDisplayStatus(cdb)) {
case 'running': case 'running':
return 'badge text-bg-primary'; return 'badge text-bg-primary';
case 'done': case 'normal':
return 'badge text-bg-success'; return 'badge text-bg-success';
case 'skipped': case 'abnormal':
return 'badge text-bg-warning'; return 'badge text-bg-warning';
case 'empty':
return 'badge text-bg-secondary';
case 'error': case 'error':
return 'badge text-bg-danger'; return 'badge text-bg-danger';
default: default:
...@@ -225,14 +307,16 @@ export class App { ...@@ -225,14 +307,16 @@ export class App {
} }
} }
protected cdbStatusLabel(status: CdbStatus): string { protected cdbStatusLabel(cdb: CdbTestResult): string {
switch (status) { switch (this.cdbDisplayStatus(cdb)) {
case 'running': case 'running':
return '测试中'; return '测试中';
case 'done': case 'normal':
return '已完成'; return '正常';
case 'skipped': case 'abnormal':
return '跳过'; return '异常';
case 'empty':
return '无内容';
case 'error': case 'error':
return '出错'; return '出错';
default: default:
...@@ -269,12 +353,134 @@ export class App { ...@@ -269,12 +353,134 @@ export class App {
} }
} }
protected cardBadgeClassForCard(card: CardTestResult): string {
if (card.skipTest) {
return 'badge text-bg-secondary';
}
return this.cardBadgeClass(card.status);
}
protected cardStatusLabelForCard(card: CardTestResult): string {
if (card.skipTest) {
return '无需测试';
}
return this.cardStatusLabel(card.status);
}
protected visibleCdbs(report: PackageTestResult): CdbTestResult[] {
const filter = this.resolveFilter(report.cdbFilter, this.cdbFilterOptions(report));
return report.cdbs.filter((cdb) =>
this.matchesFilter(this.cdbDisplayStatus(cdb), filter),
);
}
protected visibleCards(cdb: CdbTestResult): CardTestResult[] {
const filter = this.resolveFilter(cdb.cardFilter, this.cardFilterOptions(cdb));
return cdb.cards.filter((card) =>
this.matchesFilter(this.cardDisplayStatus(card), filter),
);
}
protected zipFilterOptions(): FilterOption[] {
return this.filterOptionsForStatuses(
this.reports().map((report) => this.packageDisplayStatus(report)),
);
}
protected cdbFilterOptions(report: PackageTestResult): FilterOption[] {
return this.filterOptionsForStatuses(
report.cdbs.map((cdb) => this.cdbDisplayStatus(cdb)),
);
}
protected cardFilterOptions(cdb: CdbTestResult): FilterOption[] {
return this.filterOptionsForStatuses(
cdb.cards.map((card) => this.cardDisplayStatus(card)),
);
}
protected onZipFilterChange(event: Event): void {
const target = event.target as HTMLSelectElement | null;
const value = target?.value ?? 'all';
if (!this.isFilterOption(value)) {
return;
}
this.zipFilter.set(value);
}
protected onPackageFilterChange(
event: Event,
report: PackageTestResult,
): void {
const target = event.target as HTMLSelectElement | null;
const value = target?.value ?? 'all';
if (!this.isFilterOption(value)) {
return;
}
const packageIndex = this.packageIndexOf(report);
if (packageIndex < 0) {
return;
}
this.updatePackage(packageIndex, (current) => ({
...current,
cdbFilter: value,
}));
}
protected onCdbFilterChange(
event: Event,
report: PackageTestResult,
cdb: CdbTestResult,
): void {
const target = event.target as HTMLSelectElement | null;
const value = target?.value ?? 'all';
if (!this.isFilterOption(value)) {
return;
}
const packageIndex = this.packageIndexOf(report);
if (packageIndex < 0) {
return;
}
const cdbIndex = this.cdbIndexOf(report, cdb);
if (cdbIndex < 0) {
return;
}
this.updateCdb(packageIndex, cdbIndex, (current) => ({
...current,
cardFilter: value,
}));
}
protected packageIndexOf(report: PackageTestResult): number {
return this.reports().indexOf(report);
}
protected cdbIndexOf(report: PackageTestResult, cdb: CdbTestResult): number {
return report.cdbs.indexOf(cdb);
}
protected formatFileSize(bytes: number): string {
if (bytes >= 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
if (bytes >= 1024) {
return `${Math.round(bytes / 1024)} KB`;
}
return `${bytes} bytes`;
}
protected exportPdf(): void {
if (typeof window !== 'undefined') {
window.print();
}
}
protected packageCardSummary(report: PackageTestResult): string { protected packageCardSummary(report: PackageTestResult): string {
let total = 0; let total = 0;
let passed = 0; let passed = 0;
let failed = 0; let failed = 0;
for (const cdb of report.cdbs) { for (const cdb of report.cdbs) {
total += cdb.totalCards; total += cdb.totalTestableCards;
passed += cdb.passedCards; passed += cdb.passedCards;
failed += cdb.failedCards; failed += cdb.failedCards;
} }
...@@ -284,6 +490,23 @@ export class App { ...@@ -284,6 +490,23 @@ export class App {
return `卡片 ${total},通过 ${passed},失败 ${failed}`; return `卡片 ${total},通过 ${passed},失败 ${failed}`;
} }
protected packageCdbSummary(report: PackageTestResult): string {
if (report.cdbs.length === 0) {
return 'CDB 0';
}
let passed = 0;
let failed = 0;
for (const cdb of report.cdbs) {
const status = this.cdbDisplayStatus(cdb);
if (status === 'normal') {
passed += 1;
} else if (status === 'abnormal' || status === 'error') {
failed += 1;
}
}
return `CDB ${report.cdbs.length},正常 ${passed},异常 ${failed}`;
}
private async testPackage(file: File, packageIndex: number): Promise<void> { private async testPackage(file: File, packageIndex: number): Promise<void> {
this.updatePackage(packageIndex, (report) => ({ this.updatePackage(packageIndex, (report) => ({
...report, ...report,
...@@ -296,7 +519,9 @@ export class App { ...@@ -296,7 +519,9 @@ export class App {
const buffer = await file.arrayBuffer(); const buffer = await file.arrayBuffer();
const zip = await JSZip.loadAsync(buffer); const zip = await JSZip.loadAsync(buffer);
const cdbEntries = this.extractRootCdbEntries(zip); const cdbEntries = this.extractRootCdbEntries(zip);
const cdbReports = cdbEntries.map((entry) => this.createCdbReport(entry.displayName)); const cdbReports = cdbEntries.map((entry) =>
this.createCdbReport(entry.displayName),
);
this.updatePackage(packageIndex, (report) => ({ this.updatePackage(packageIndex, (report) => ({
...report, ...report,
...@@ -330,7 +555,8 @@ export class App { ...@@ -330,7 +555,8 @@ export class App {
} }
const snapshot = this.reports()[packageIndex]; const snapshot = this.reports()[packageIndex];
const hasError = snapshot?.cdbs.some((cdb) => cdb.status === 'error') ?? false; const hasError =
snapshot?.cdbs.some((cdb) => cdb.status === 'error') ?? false;
this.updatePackage(packageIndex, (report) => ({ this.updatePackage(packageIndex, (report) => ({
...report, ...report,
status: hasError ? 'error' : 'done', status: hasError ? 'error' : 'done',
...@@ -387,7 +613,8 @@ export class App { ...@@ -387,7 +613,8 @@ export class App {
if (cards.length === 0) { if (cards.length === 0) {
this.updateCdb(packageIndex, cdbIndex, (cdb) => ({ this.updateCdb(packageIndex, cdbIndex, (cdb) => ({
...cdb, ...cdb,
status: 'skipped', status: 'done',
totalTestableCards: 0,
totalCards: 0, totalCards: 0,
testedCards: 0, testedCards: 0,
passedCards: 0, passedCards: 0,
...@@ -403,12 +630,15 @@ export class App { ...@@ -403,12 +630,15 @@ export class App {
status: 'pending', status: 'pending',
scriptErrors: [], scriptErrors: [],
logs: [], logs: [],
skipTest: card.skipTest,
})); }));
const testableCards = cardResults.filter((card) => !card.skipTest);
this.updateCdb(packageIndex, cdbIndex, (cdb) => ({ this.updateCdb(packageIndex, cdbIndex, (cdb) => ({
...cdb, ...cdb,
cards: cardResults, cards: cardResults,
totalCards: cardResults.length, totalCards: cardResults.length,
totalTestableCards: testableCards.length,
testedCards: 0, testedCards: 0,
passedCards: 0, passedCards: 0,
failedCards: 0, failedCards: 0,
...@@ -416,6 +646,9 @@ export class App { ...@@ -416,6 +646,9 @@ export class App {
for (let cardIndex = 0; cardIndex < cardResults.length; cardIndex += 1) { for (let cardIndex = 0; cardIndex < cardResults.length; cardIndex += 1) {
const card = cardResults[cardIndex]; const card = cardResults[cardIndex];
if (card.skipTest) {
continue;
}
this.statusMessage.set(`正在测试 ${entry.displayName} - ${card.name}`); this.statusMessage.set(`正在测试 ${entry.displayName} - ${card.name}`);
this.updateCard(packageIndex, cdbIndex, cardIndex, (item) => ({ this.updateCard(packageIndex, cdbIndex, cardIndex, (item) => ({
...item, ...item,
...@@ -479,7 +712,9 @@ export class App { ...@@ -479,7 +712,9 @@ export class App {
return { return {
fileName, fileName,
status: 'pending', status: 'pending',
cardFilter: 'all',
cards: [], cards: [],
totalTestableCards: 0,
totalCards: 0, totalCards: 0,
testedCards: 0, testedCards: 0,
passedCards: 0, passedCards: 0,
...@@ -580,6 +815,7 @@ export class App { ...@@ -580,6 +815,7 @@ export class App {
status: scriptErrors.length === 0 ? 'passed' : 'failed', status: scriptErrors.length === 0 ? 'passed' : 'failed',
scriptErrors, scriptErrors,
logs, logs,
skipTest: false,
}; };
} catch (error) { } catch (error) {
return { return {
...@@ -588,6 +824,7 @@ export class App { ...@@ -588,6 +824,7 @@ export class App {
status: 'error', status: 'error',
scriptErrors: [], scriptErrors: [],
logs: [], logs: [],
skipTest: false,
detail: this.formatErrorStack(error), detail: this.formatErrorStack(error),
}; };
} finally { } finally {
...@@ -595,25 +832,28 @@ export class App { ...@@ -595,25 +832,28 @@ export class App {
} }
} }
private queryTestCards(db: Database): Array<{ id: number; name: string }> { private queryTestCards(db: Database): Array<{ id: number; name: string; skipTest: boolean }> {
const normalMonsterType = const normalMonsterType =
(OcgcoreCommonConstants.TYPE_NORMAL | OcgcoreCommonConstants.TYPE_MONSTER) >>> 0; (OcgcoreCommonConstants.TYPE_NORMAL | OcgcoreCommonConstants.TYPE_MONSTER) >>> 0;
const tokenType = OcgcoreCommonConstants.TYPE_TOKEN >>> 0; const tokenType = OcgcoreCommonConstants.TYPE_TOKEN >>> 0;
const stmt = db.prepare( const stmt = db.prepare(
'SELECT datas.id as id, texts.name as name FROM datas INNER JOIN texts ON datas.id = texts.id WHERE datas.type != ? AND (datas.type & ?) = 0', 'SELECT datas.id as id, texts.name as name, datas.type as type FROM datas INNER JOIN texts ON datas.id = texts.id',
); );
try { try {
const result: Array<{ id: number; name: string }> = []; const result: Array<{ id: number; name: string; skipTest: boolean }> = [];
stmt.bind([normalMonsterType, tokenType]);
while (stmt.step()) { while (stmt.step()) {
const row = stmt.getAsObject() as unknown as SqlCardRow; const row = stmt.getAsObject() as unknown as SqlCardRow;
if (!row || row.id == null) { if (!row || row.id == null) {
continue; continue;
} }
const typeValue = (row.type ?? 0) >>> 0;
const skipTest =
typeValue === normalMonsterType || (typeValue & tokenType) !== 0;
result.push({ result.push({
id: row.id, id: row.id,
name: row.name ?? '未命名卡片', name: row.name ?? '未命名卡片',
skipTest,
}); });
} }
return result; return result;
...@@ -719,6 +959,146 @@ export class App { ...@@ -719,6 +959,146 @@ export class App {
return status === 'failed' || status === 'error'; return status === 'failed' || status === 'error';
} }
private cardDisplayStatus(
card: CardTestResult,
): 'passed' | 'failed' | 'pending' | 'skip' {
if (card.skipTest) {
return 'skip';
}
if (card.status === 'passed') {
return 'passed';
}
if (card.status === 'failed' || card.status === 'error') {
return 'failed';
}
return 'pending';
}
private cdbDisplayStatus(cdb: CdbTestResult): DisplayStatus {
if (cdb.status === 'error') {
return 'error';
}
if (cdb.status === 'running') {
return 'running';
}
if (cdb.totalTestableCards === 0 && cdb.status === 'done') {
return 'empty';
}
if (cdb.failedCards > 0) {
return 'abnormal';
}
if (cdb.totalTestableCards > 0 && cdb.testedCards === cdb.totalTestableCards) {
return 'normal';
}
return 'pending';
}
private packageDisplayStatus(report: PackageTestResult): DisplayStatus {
if (report.status === 'error') {
return 'error';
}
if (report.status === 'running') {
return 'running';
}
if (report.status === 'done' && report.cdbs.length === 0) {
return 'empty';
}
if (
report.status === 'done' &&
report.cdbs.length > 0 &&
report.cdbs.every((cdb) => this.cdbDisplayStatus(cdb) === 'empty')
) {
return 'empty';
}
const hasFailed =
report.cdbs.some((cdb) => this.cdbDisplayStatus(cdb) === 'abnormal') ||
report.cdbs.some((cdb) => this.cdbDisplayStatus(cdb) === 'error');
if (hasFailed) {
return 'abnormal';
}
const allDone = report.cdbs.every((cdb) => {
const status = this.cdbDisplayStatus(cdb);
return status === 'normal' || status === 'empty' || status === 'error';
});
if (allDone && report.cdbs.length > 0) {
return 'normal';
}
return 'pending';
}
private matchesFilter(
status: 'passed' | 'failed' | DisplayStatus | 'skip',
filter: FilterOption,
): boolean {
if (filter === 'all') {
return true;
}
if (filter === 'passed') {
return status === 'passed' || status === 'normal';
}
if (filter === 'skip') {
return status === 'skip';
}
if (filter === 'empty') {
return status === 'empty';
}
return status === 'failed' || status === 'abnormal' || status === 'error';
}
private filterOptionsForStatuses(
statuses: Array<'passed' | 'failed' | DisplayStatus | 'pending' | 'skip'>,
): FilterOption[] {
let hasPassed = false;
let hasFailed = false;
let hasSkip = false;
let hasEmpty = false;
for (const status of statuses) {
if (status === 'passed' || status === 'normal') {
hasPassed = true;
} else if (status === 'failed' || status === 'abnormal' || status === 'error') {
hasFailed = true;
} else if (status === 'skip') {
hasSkip = true;
} else if (status === 'empty') {
hasEmpty = true;
}
}
const options: FilterOption[] = [];
if (hasPassed) {
options.push('passed');
}
if (hasFailed) {
options.push('failed');
}
if (hasSkip) {
options.push('skip');
}
if (hasEmpty) {
options.push('empty');
}
return options;
}
private resolveFilter(
current: FilterOption,
options: FilterOption[],
): FilterOption {
if (options.includes(current)) {
return current;
}
return 'all';
}
private isFilterOption(value: string): value is FilterOption {
return (
value === 'all' ||
value === 'passed' ||
value === 'failed' ||
value === 'skip' ||
value === 'empty'
);
}
private formatError(error: unknown): string { private formatError(error: unknown): string {
if (error instanceof Error) { if (error instanceof Error) {
return error.message; return error.message;
......
...@@ -11,3 +11,19 @@ body { ...@@ -11,3 +11,19 @@ body {
main { main {
min-height: 100vh; min-height: 100vh;
} }
@media print {
body {
background: #ffffff;
}
.no-print {
display: none !important;
}
.report-scroll {
max-height: none !important;
overflow: visible !important;
padding-right: 0 !important;
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment