{/* Others Section */}
@@ -97,7 +106,14 @@ export const TestResults: React.FC = ({testCaseName}) => {
{testCase?.id}
{
- testCase && currentFile && (
+ testCase && isLoadingFile && (
+
+ Loading source code...
+
+ )
+ }
+ {
+ testCase && currentFileCode !== undefined && (
{lowCodeMode && displayedCode.length === 0 ? (
No documentation comments found for this test case.
diff --git a/web-report/src/pages/Tests.tsx b/web-report/src/pages/Tests.tsx
new file mode 100644
index 0000000..b6a42f9
--- /dev/null
+++ b/web-report/src/pages/Tests.tsx
@@ -0,0 +1,194 @@
+import React, {useCallback, useMemo, useRef, useState} from "react";
+import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion.tsx";
+import {Button} from "@/components/ui/button.tsx";
+import {Progress} from "@/components/ui/progress.tsx";
+import {useAppContext} from "@/AppProvider.tsx";
+import {REVIEW_STATE, REVIEW_STATES, ReviewState, SELECTABLE_REVIEW_STATES} from "@/types/Review.ts";
+import {TestReviewRow} from "@/components/TestReviewRow.tsx";
+import {TestDetailDialog} from "@/components/TestDetailDialog.tsx";
+import {Download, Upload, X} from "lucide-react";
+
+type Filter = "ALL" | ReviewState;
+
+const filterButtonClass = (active: boolean) =>
+ `px-3 py-1 border-2 border-black font-mono text-xs ${
+ active
+ ? "bg-blue-100 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]"
+ : "bg-white hover:bg-gray-100"
+ }`;
+
+export const Tests: React.FC = () => {
+ const {
+ data,
+ reviews,
+ isDirty,
+ saveReviews,
+ loadReviews,
+ reviewMessage,
+ clearReviewMessage,
+ } = useAppContext();
+
+ const [filter, setFilter] = useState("ALL");
+ const [openTestId, setOpenTestId] = useState(null);
+ const fileInputRef = useRef(null);
+
+ const handleOpen = useCallback((id: string) => setOpenTestId(id), []);
+ const handleClose = useCallback(() => setOpenTestId(null), []);
+
+ const testCases = useMemo(() => data?.testCases ?? [], [data]);
+ const testFilePaths = useMemo(() => data?.testFilePaths ?? [], [data]);
+
+ const counts = useMemo(() => {
+ const c = Object.fromEntries(REVIEW_STATES.map(s => [s, 0])) as Record;
+ for (const tc of testCases) {
+ if (!tc.id) continue;
+ c[(reviews[tc.id] ?? {state: REVIEW_STATE.NOT_REVIEWED}).state]++;
+ }
+ return c;
+ }, [testCases, reviews]);
+
+ const grouped = useMemo(() => {
+ const map = new Map>();
+ for (const file of testFilePaths) map.set(file, []);
+ for (const tc of testCases) {
+ if (!tc.id || !tc.filePath) continue;
+ if (filter !== "ALL") {
+ const state = (reviews[tc.id] ?? {state: REVIEW_STATE.NOT_REVIEWED}).state;
+ if (state !== filter) continue;
+ }
+ const arr = map.get(tc.filePath) ?? [];
+ arr.push({id: tc.id, name: tc.name ?? tc.id});
+ map.set(tc.filePath, arr);
+ }
+ return map;
+ }, [testCases, testFilePaths, filter, reviews]);
+
+ const triggerLoad = () => fileInputRef.current?.click();
+ const onFileChange: React.ChangeEventHandler = async (e) => {
+ const f = e.target.files?.[0];
+ if (f) await loadReviews(f);
+ e.target.value = "";
+ };
+
+ return (
+
+
+
+
+
+ {isDirty && (
+
+ UNSAVED CHANGES
+
+ )}
+
+
+ {isDirty && (
+
+ You have unsaved review changes. Click Save reviews to download
+ the updated report-review.json and place it next to the report.
+
+ )}
+
+ {reviewMessage && (
+
+ {reviewMessage.text}
+
+
+ )}
+
+ {testCases.length > 0 && (() => {
+ const total = testCases.length;
+ const reviewed = total - counts[REVIEW_STATE.NOT_REVIEWED];
+ const pct = total === 0 ? 0 : Math.round((reviewed / total) * 100);
+ return (
+
+
+ Reviewed: {reviewed}/{total}
+ {pct}%
+
+
+
+ );
+ })()}
+
+
+ Filter:
+
+ {SELECTABLE_REVIEW_STATES.map(s => (
+
+ ))}
+
+
+
+ {testFilePaths.map((file, idx) => {
+ const items = grouped.get(file) ?? [];
+ return (
+
+
+ {file}
+ {items.length}
+
+
+ {items.length === 0 ? (
+
+ No tests match the current filter.
+
+ ) : (
+ items.map(tc => (
+
+ ))
+ )}
+
+
+ );
+ })}
+
+
+
+
+ );
+};
diff --git a/web-report/src/types/Review.ts b/web-report/src/types/Review.ts
new file mode 100644
index 0000000..2fce417
--- /dev/null
+++ b/web-report/src/types/Review.ts
@@ -0,0 +1,84 @@
+export const REVIEW_STATE = {
+ NOT_REVIEWED: "NOT-REVIEWED",
+ ACCEPTED: "ACCEPTED",
+ REJECTED: "REJECTED",
+ PREVIOUSLY_ACCEPTED: "PREVIOUSLY-ACCEPTED",
+ PREVIOUSLY_REJECTED: "PREVIOUSLY-REJECTED",
+} as const;
+
+export type ReviewState = typeof REVIEW_STATE[keyof typeof REVIEW_STATE];
+
+export const REVIEW_STATES: readonly ReviewState[] = Object.values(REVIEW_STATE);
+
+export const SELECTABLE_REVIEW_STATES: readonly ReviewState[] = [
+ REVIEW_STATE.NOT_REVIEWED,
+ REVIEW_STATE.ACCEPTED,
+ REVIEW_STATE.REJECTED,
+];
+
+export interface TestReview {
+ state: ReviewState;
+ comment: string;
+}
+
+export const DEFAULT_REVIEW: TestReview = {state: REVIEW_STATE.NOT_REVIEWED, comment: ""};
+
+export interface ReviewFile {
+ schemaVersion: string;
+ reviews: Record;
+}
+
+export const REVIEW_FILE_NAME = "report-review.json";
+export const REVIEW_SCHEMA_VERSION = __WFC_VERSION__;
+
+export const isReviewState = (v: unknown): v is ReviewState =>
+ typeof v === "string" && (REVIEW_STATES as readonly string[]).includes(v);
+
+export const normalizeReviews = (
+ reviews: Record,
+): Record => {
+ const out: Record = {};
+ for (const [id, r] of Object.entries(reviews)) {
+ const comment = (r.comment ?? "").trim();
+ if (r.state !== REVIEW_STATE.NOT_REVIEWED || comment !== "") {
+ out[id] = {state: r.state, comment: r.comment ?? ""};
+ }
+ }
+ return out;
+};
+
+const sortedStringify = (obj: Record): string => {
+ const keys = Object.keys(obj).sort();
+ const sorted: Record = {};
+ for (const k of keys) sorted[k] = obj[k];
+ return JSON.stringify(sorted);
+};
+
+export const reviewsEqual = (
+ a: Record,
+ b: Record,
+): boolean => sortedStringify(normalizeReviews(a)) === sortedStringify(normalizeReviews(b));
+
+export const parseReviewFile = (raw: unknown): Record => {
+ if (!raw || typeof raw !== "object") {
+ throw new Error("Invalid review file: not a JSON object.");
+ }
+ const obj = raw as Record;
+ const reviews = obj.reviews;
+ if (!reviews || typeof reviews !== "object") {
+ throw new Error("Invalid review file: missing 'reviews' object.");
+ }
+ const out: Record = {};
+ for (const [id, entry] of Object.entries(reviews as Record)) {
+ if (!entry || typeof entry !== "object") {
+ throw new Error(`Invalid review entry for '${id}': not an object.`);
+ }
+ const e = entry as Record;
+ if (!isReviewState(e.state)) {
+ throw new Error(`Invalid review entry for '${id}': unknown state '${String(e.state)}'.`);
+ }
+ const comment = typeof e.comment === "string" ? e.comment : "";
+ out[id] = {state: e.state, comment};
+ }
+ return out;
+};
diff --git a/web-report/src/vite-env.d.ts b/web-report/src/vite-env.d.ts
index 11f02fe..91a312a 100644
--- a/web-report/src/vite-env.d.ts
+++ b/web-report/src/vite-env.d.ts
@@ -1 +1,3 @@
///
+
+declare const __WFC_VERSION__: string;
diff --git a/web-report/vite.config.ts b/web-report/vite.config.ts
index ef07bff..aa28b3b 100644
--- a/web-report/vite.config.ts
+++ b/web-report/vite.config.ts
@@ -1,12 +1,26 @@
import path from "path"
+import fs from "fs"
import { defineConfig, UserConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from "@tailwindcss/vite"
+const readWfcVersion = (): string => {
+ const pomPath = path.resolve(__dirname, "../pom.xml");
+ const pom = fs.readFileSync(pomPath, "utf8");
+ const match = pom.match(/commons<\/artifactId>\s*([^<]+)<\/version>/);
+ if (!match) throw new Error(`Could not find project in ${pomPath}`);
+ return match[1];
+};
+
+const WFC_VERSION = readWfcVersion();
+
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
+ define: {
+ __WFC_VERSION__: JSON.stringify(WFC_VERSION),
+ },
test: {
globals: true,
environment: 'happy-dom',