feat(spa): categories manager + import/export + test runner
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import PrimeVue from "primevue/config";
|
||||
|
||||
vi.mock("../api/client", () => ({
|
||||
api: {
|
||||
modules: { list: vi.fn().mockResolvedValue({ items: [], total: 0 }) },
|
||||
test: vi.fn(),
|
||||
},
|
||||
ApiError: class extends Error {
|
||||
constructor(public status: number, message: string) { super(message); }
|
||||
},
|
||||
}));
|
||||
|
||||
import TestRunner from "../views/TestRunner.vue";
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({
|
||||
matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(),
|
||||
}));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("TestRunner.vue", () => {
|
||||
it("renders heading and no histogram before run", async () => {
|
||||
const wrap = mount(TestRunner, { global: { plugins: [PrimeVue] } });
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("Test runner");
|
||||
expect(wrap.text()).not.toContain("Histogram");
|
||||
});
|
||||
|
||||
it("Run button disabled when no module selected", async () => {
|
||||
const wrap = mount(TestRunner, { global: { plugins: [PrimeVue] } });
|
||||
await flushPromises();
|
||||
const btn = wrap.findAll("button").find((b) => b.text().includes("Run"));
|
||||
// PrimeVue Button forwards :disabled to the button element
|
||||
expect(btn?.attributes("disabled")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import DataTable from "primevue/datatable";
|
||||
import Column from "primevue/column";
|
||||
import Button from "primevue/button";
|
||||
import InputText from "primevue/inputtext";
|
||||
import ColorPicker from "primevue/colorpicker";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { ApiError } from "../api/client";
|
||||
import type { CategoryRow } from "../api/types";
|
||||
|
||||
const store = useCategoryStore();
|
||||
const toast = useToast();
|
||||
const newName = ref("");
|
||||
const newColor = ref("a970ff");
|
||||
|
||||
onMounted(async () => { await store.fetchAll(); });
|
||||
|
||||
async function add() {
|
||||
const name = newName.value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
await store.create({ name, color: `#${newColor.value}` });
|
||||
newName.value = "";
|
||||
toast.add({ severity: "success", summary: "Created", life: 2000 });
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e);
|
||||
toast.add({ severity: "error", summary: "Failed", detail: msg, life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: CategoryRow) {
|
||||
await store.remove(row.id);
|
||||
toast.add({ severity: "success", summary: "Deleted", life: 2000 });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 text-wp-text">
|
||||
<h1 class="text-xl font-semibold m-0">Categories</h1>
|
||||
<p class="text-sm text-wp-text2 m-0 mt-2">Stub — wired up in Task 8.</p>
|
||||
<div class="p-6 text-wp-text max-w-3xl">
|
||||
<h1 class="text-xl font-semibold mb-4">Categories</h1>
|
||||
|
||||
<div class="flex gap-2 mb-6 items-end">
|
||||
<div class="flex-1">
|
||||
<label for="cat-name" class="block text-xs text-wp-text2 mb-1">Name</label>
|
||||
<InputText id="cat-name" v-model="newName" placeholder="Style" class="w-full" @keydown.enter="add" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="cat-color" class="block text-xs text-wp-text2 mb-1">Color</label>
|
||||
<ColorPicker id="cat-color" v-model="newColor" />
|
||||
</div>
|
||||
<Button label="+ Add" severity="primary" data-test="add-category-btn" @click="add" />
|
||||
</div>
|
||||
|
||||
<DataTable :value="store.items" data-key="id" :loading="store.loading">
|
||||
<Column field="name" header="Name" />
|
||||
<Column field="color" header="Color" style="width:8rem">
|
||||
<template #body="{ data }">
|
||||
<span
|
||||
class="inline-block w-4 h-4 rounded mr-2 align-middle"
|
||||
:style="{ background: data.color || 'transparent', border: '1px solid var(--wp-border)' }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="font-mono text-xs">{{ data.color || "—" }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="sort_order" header="Order" sortable style="width:6rem" />
|
||||
<Column header="Actions" style="width:6rem">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
icon="pi pi-trash" text rounded size="small" severity="danger"
|
||||
aria-label="Delete category"
|
||||
@click="remove(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<template #empty>
|
||||
<div class="text-center p-6 text-wp-text2">No categories yet.</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import Button from "primevue/button";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import { api } from "../api/client";
|
||||
import type { ImportBundle } from "../api/types";
|
||||
|
||||
const toast = useToast();
|
||||
const uploadInput = ref<HTMLInputElement | null>(null);
|
||||
const lastResult = ref("");
|
||||
|
||||
async function downloadExport() {
|
||||
try {
|
||||
const bundle = await api.exportBundle();
|
||||
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `wildcard-pipeline-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.add({ severity: "success", summary: "Exported", life: 2000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "Export failed", detail: String(e), life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function onUpload(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const bundle = JSON.parse(text) as ImportBundle;
|
||||
const result = await api.importBundle(bundle);
|
||||
lastResult.value = `Modules: ${result.modules_imported}, categories: ${result.categories_imported}, skipped: ${result.skipped.length}`;
|
||||
toast.add({ severity: "success", summary: "Imported", detail: lastResult.value, life: 4000 });
|
||||
} catch (err) {
|
||||
toast.add({ severity: "error", summary: "Import failed", detail: String(err), life: 5000 });
|
||||
} finally {
|
||||
// reset so the same file can be selected again
|
||||
if (uploadInput.value) uploadInput.value.value = "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 text-wp-text">
|
||||
<h1 class="text-xl font-semibold m-0">Import / Export</h1>
|
||||
<p class="text-sm text-wp-text2 m-0 mt-2">Stub — wired up in Task 8.</p>
|
||||
<div class="p-6 text-wp-text max-w-2xl">
|
||||
<h1 class="text-xl font-semibold mb-4">Import / Export</h1>
|
||||
<p class="text-sm text-wp-text2 mb-6">
|
||||
Library bundles are JSON files containing all module declarations and categories.
|
||||
Workflow files are NOT handled here.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3 items-center mb-6">
|
||||
<Button icon="pi pi-download" label="Export library" severity="primary" @click="downloadExport" />
|
||||
<Button icon="pi pi-upload" label="Import bundle" severity="secondary" outlined @click="uploadInput?.click()" />
|
||||
<input
|
||||
ref="uploadInput" type="file" accept="application/json"
|
||||
aria-label="Upload bundle JSON file"
|
||||
class="hidden" data-test="upload-input"
|
||||
@change="onUpload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="lastResult" class="text-sm text-wp-text2 p-3 rounded bg-wp-bg2 border border-wp-border">
|
||||
Last import: {{ lastResult }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import Button from "primevue/button";
|
||||
import InputNumber from "primevue/inputnumber";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Select from "primevue/select";
|
||||
import { api } from "../api/client";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import type { TestResponse } from "../api/types";
|
||||
|
||||
const store = useModuleStore();
|
||||
const moduleId = ref<string | null>(null);
|
||||
const samples = ref(50);
|
||||
const variableBinding = ref("$x");
|
||||
const result = ref<TestResponse | null>(null);
|
||||
const running = ref(false);
|
||||
|
||||
onMounted(async () => { await store.fetchAll(); });
|
||||
|
||||
const selectedModule = computed(() => store.items.find((m) => m.id === moduleId.value));
|
||||
|
||||
watch(selectedModule, (mod) => {
|
||||
if (mod && variableBinding.value === "$x") {
|
||||
variableBinding.value = `$${mod.name.replace(/[^a-zA-Z0-9_]/g, "_")}`;
|
||||
}
|
||||
});
|
||||
|
||||
async function run() {
|
||||
const mod = selectedModule.value;
|
||||
if (!mod) return;
|
||||
running.value = true;
|
||||
try {
|
||||
const instance = mod.type === "wildcard"
|
||||
? { variable_binding: variableBinding.value }
|
||||
: {};
|
||||
result.value = await api.test({
|
||||
type: mod.type,
|
||||
payload: mod.payload,
|
||||
instance,
|
||||
samples: samples.value,
|
||||
});
|
||||
} finally {
|
||||
running.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const histogramEntries = computed(() => {
|
||||
if (!result.value) return [];
|
||||
const max = Math.max(...Object.values(result.value.histogram), 1);
|
||||
return Object.entries(result.value.histogram)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([value, count]) => ({ value, count, pct: Math.round((count / max) * 100) }));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 text-wp-text">
|
||||
<h1 class="text-xl font-semibold m-0">Test runner</h1>
|
||||
<p class="text-sm text-wp-text2 m-0 mt-2">Stub — wired up in Task 8.</p>
|
||||
<div class="p-6 text-wp-text max-w-3xl">
|
||||
<h1 class="text-xl font-semibold mb-4">Test runner</h1>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3 mb-4">
|
||||
<div class="col-span-2">
|
||||
<label for="tr-module" class="block text-xs text-wp-text2 mb-1">Module</label>
|
||||
<Select
|
||||
id="tr-module"
|
||||
v-model="moduleId"
|
||||
:options="store.items"
|
||||
option-label="name"
|
||||
option-value="id"
|
||||
placeholder="Select a module…"
|
||||
filter
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tr-samples" class="block text-xs text-wp-text2 mb-1">Samples</label>
|
||||
<InputNumber id="tr-samples" v-model="samples" :min="1" :max="10000" class="w-full" />
|
||||
</div>
|
||||
<div v-if="selectedModule?.type === 'wildcard'" class="col-span-2">
|
||||
<label for="tr-binding" class="block text-xs text-wp-text2 mb-1">Variable binding</label>
|
||||
<InputText id="tr-binding" v-model="variableBinding" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
label="Run" icon="pi pi-bolt" severity="primary"
|
||||
:loading="running" :disabled="!moduleId"
|
||||
data-test="run-btn"
|
||||
@click="run"
|
||||
/>
|
||||
|
||||
<div v-if="result" class="mt-6">
|
||||
<h2 class="text-sm uppercase tracking-wider text-wp-text2 mb-2">Histogram</h2>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="entry in histogramEntries"
|
||||
:key="entry.value"
|
||||
class="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<div class="w-48 truncate" :title="entry.value">{{ entry.value }}</div>
|
||||
<div class="flex-1 h-3 bg-wp-bg2 rounded overflow-hidden">
|
||||
<div class="h-full bg-wp-accent" :style="{ width: entry.pct + '%' }" />
|
||||
</div>
|
||||
<div class="w-12 text-right text-wp-text2">{{ entry.count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user