feat(spa): categories manager + import/export + test runner

This commit is contained in:
DumiFlex
2026-04-26 20:02:43 +03:00
parent 8abab1ee3d
commit a20a7b058b
4 changed files with 285 additions and 9 deletions
+44
View File
@@ -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();
});
});
+76 -3
View File
@@ -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>
+63 -3
View File
@@ -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>
+102 -3
View File
@@ -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>