fix(spa): error handling on store actions + exact-active-class + matchmedia setup

This commit is contained in:
DumiFlex
2026-04-26 20:15:33 +03:00
parent a20a7b058b
commit 4cfe1f6d14
10 changed files with 88 additions and 49 deletions
-13
View File
@@ -26,22 +26,9 @@ const apiMod = api.modules as unknown as Record<string, ReturnType<typeof vi.fn>
beforeEach(() => {
setActivePinia(createPinia());
Object.values(apiMod).forEach((fn) => fn.mockReset());
// jsdom does not implement matchMedia; PrimeVue's TieredMenu (used by
// SplitButton) binds a matchMedia listener on mount.
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
});
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
function makeRouter() {
+3 -6
View File
@@ -2,6 +2,7 @@ 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";
import ToastService from "primevue/toastservice";
vi.mock("../api/client", () => ({
api: {
@@ -17,25 +18,21 @@ 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] } });
const wrap = mount(TestRunner, { global: { plugins: [PrimeVue, ToastService] } });
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] } });
const wrap = mount(TestRunner, { global: { plugins: [PrimeVue, ToastService] } });
await flushPromises();
const btn = wrap.findAll("button").find((b) => b.text().includes("Run"));
// PrimeVue Button forwards :disabled to the button element
@@ -26,14 +26,9 @@ beforeEach(() => {
setActivePinia(createPinia());
Object.values(apiMod).forEach((fn) => fn.mockReset());
apiCat.list.mockResolvedValue({ items: [] });
// jsdom lacks matchMedia, used by some PrimeVue overlays
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({
matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(),
}));
});
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
function makeRouter() {
+1 -1
View File
@@ -11,7 +11,7 @@ defineProps<{
<RouterLink
:to="item.to"
class="flex items-center gap-2 px-4 py-2 text-wp-text hover:bg-wp-bg2 border-l-2 border-transparent"
active-class="!bg-wp-accent/15 !border-l-[color:var(--wp-accent)] !text-white"
exact-active-class="!bg-wp-accent/15 !border-l-[color:var(--wp-accent)] !text-white"
>
<i :class="['pi', item.icon, 'text-wp-text2']" aria-hidden="true" />
<span class="text-sm">{{ item.label }}</span>
+7 -2
View File
@@ -31,8 +31,13 @@ async function add() {
}
async function remove(row: CategoryRow) {
await store.remove(row.id);
toast.add({ severity: "success", summary: "Deleted", life: 2000 });
try {
await store.remove(row.id);
toast.add({ severity: "success", summary: "Deleted", life: 2000 });
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e);
toast.add({ severity: "error", summary: "Delete failed", detail: msg, life: 4000 });
}
}
</script>
+12 -7
View File
@@ -28,13 +28,18 @@ const isEdit = computed(() => !!props.id);
onMounted(async () => {
await categoryStore.fetchAll();
if (props.id) {
const row = await moduleStore.get(props.id);
name.value = row.name;
description.value = row.description;
categoryId.value = row.category_id;
tags.value = row.tags;
const payload = row.payload as { values?: NamedValue[] };
values.value = payload.values ?? [];
try {
const row = await moduleStore.get(props.id);
name.value = row.name;
description.value = row.description;
categoryId.value = row.category_id;
tags.value = row.tags;
const payload = row.payload as { values?: NamedValue[] };
values.value = payload.values ?? [];
} catch {
toast.add({ severity: "error", summary: "Module not found", life: 3000 });
router.replace("/modules");
}
}
});
+34 -8
View File
@@ -27,16 +27,30 @@ const newOptions = [
{ label: "Fixed values", icon: "pi pi-tag", command: () => router.push("/modules/fixed-values/new") },
];
onMounted(async () => { await store.fetchAll(); });
onMounted(async () => {
try {
await store.fetchAll();
} catch (e) {
toast.add({ severity: "error", summary: "Load failed", detail: String(e), life: 4000 });
}
});
watch(search, async (q) => {
store.filter.q = q || undefined;
await store.fetchAll();
try {
await store.fetchAll();
} catch (e) {
toast.add({ severity: "error", summary: "Search failed", detail: String(e), life: 4000 });
}
});
async function setTypeFilter(type: ModuleType | undefined) {
store.filter.type = type;
await store.fetchAll();
try {
await store.fetchAll();
} catch (e) {
toast.add({ severity: "error", summary: "Load failed", detail: String(e), life: 4000 });
}
}
function rowClass(row: ModuleRow) {
@@ -49,12 +63,20 @@ function edit(row: ModuleRow) {
}
async function dup(row: ModuleRow) {
await store.duplicate(row.id);
toast.add({ severity: "success", summary: "Duplicated", detail: row.name, life: 2000 });
try {
await store.duplicate(row.id);
toast.add({ severity: "success", summary: "Duplicated", detail: row.name, life: 2000 });
} catch (e) {
toast.add({ severity: "error", summary: "Duplicate failed", detail: String(e), life: 4000 });
}
}
async function fav(row: ModuleRow) {
await store.toggleFavorite(row.id);
try {
await store.toggleFavorite(row.id);
} catch (e) {
toast.add({ severity: "error", summary: "Favorite failed", detail: String(e), life: 4000 });
}
}
function del(row: ModuleRow) {
@@ -64,8 +86,12 @@ function del(row: ModuleRow) {
icon: "pi pi-exclamation-triangle",
acceptClass: "p-button-danger",
accept: async () => {
await store.remove(row.id);
toast.add({ severity: "success", summary: "Deleted", detail: row.name, life: 2000 });
try {
await store.remove(row.id);
toast.add({ severity: "success", summary: "Deleted", detail: row.name, life: 2000 });
} catch (e) {
toast.add({ severity: "error", summary: "Delete failed", detail: String(e), life: 4000 });
}
},
});
}
+4
View File
@@ -4,11 +4,13 @@ import Button from "primevue/button";
import InputNumber from "primevue/inputnumber";
import InputText from "primevue/inputtext";
import Select from "primevue/select";
import { useToast } from "primevue/usetoast";
import { api } from "../api/client";
import { useModuleStore } from "../stores/moduleStore";
import type { TestResponse } from "../api/types";
const store = useModuleStore();
const toast = useToast();
const moduleId = ref<string | null>(null);
const samples = ref(50);
const variableBinding = ref("$x");
@@ -39,6 +41,8 @@ async function run() {
instance,
samples: samples.value,
});
} catch (e) {
toast.add({ severity: "error", summary: "Test failed", detail: String(e), life: 4000 });
} finally {
running.value = false;
}
+12 -7
View File
@@ -29,13 +29,18 @@ const isEdit = computed(() => !!props.id);
onMounted(async () => {
await categoryStore.fetchAll();
if (props.id) {
const row = await moduleStore.get(props.id);
name.value = row.name;
description.value = row.description;
categoryId.value = row.category_id;
tags.value = row.tags;
const payload = row.payload as { options?: Option[] };
options.value = payload.options ?? [];
try {
const row = await moduleStore.get(props.id);
name.value = row.name;
description.value = row.description;
categoryId.value = row.category_id;
tags.value = row.tags;
const payload = row.payload as { options?: Option[] };
options.value = payload.options ?? [];
} catch {
toast.add({ severity: "error", summary: "Module not found", life: 3000 });
router.replace("/modules");
}
}
});
+15
View File
@@ -17,3 +17,18 @@ if (typeof globalThis.ResizeObserver === "undefined") {
disconnect() {}
} as never;
}
// PrimeVue overlays (TieredMenu, Select dropdown, etc.) call window.matchMedia.
// jsdom doesn't ship with it, so we stub it for every test.
if (typeof window !== "undefined" && !window.matchMedia) {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})) as unknown as typeof window.matchMedia;
}