chore(frontend): vitest runner + WKT parser unit tests (#999) #1132
11 changed files with 2335 additions and 1477 deletions
3386
frontend/package-lock.json
generated
3386
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,8 @@
|
|||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"codegen": "openapi-typescript http://localhost:8000/openapi.json -o src/lib/api-types.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -26,16 +28,23 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/leaflet": "^1.9.0",
|
||||
"@types/leaflet-draw": "^1.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "^15.0.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"openapi-typescript": "^7.0.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,9 @@
|
|||
*/
|
||||
|
||||
import { CircleMarker, GeoJSON, LayerGroup, Popup } from "react-leaflet";
|
||||
import type { Feature, Geometry, Position } from "geojson";
|
||||
import type { Feature } from "geojson";
|
||||
|
||||
import { wktToGeometry } from "@/lib/wkt";
|
||||
import type {
|
||||
ParcelAnalysisCompetitor,
|
||||
PipelineObject,
|
||||
|
|
@ -79,92 +80,6 @@ function hasCoords<T extends { lat?: number | null; lon?: number | null }>(
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal WKT → GeoJSON parser (no new deps — see #999 conventions).
|
||||
// Поддерживает типы, которые реально отдаёт NSPD risk-слой (ST_AsText, EPSG:4326,
|
||||
// порядок «lon lat»): POINT, POLYGON, MULTIPOLYGON. Прочее / мусор → null
|
||||
// (зона тогда просто не рисуется). НЕ general-purpose: только для risk-зон.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseRing(body: string): Position[] {
|
||||
return body
|
||||
.split(",")
|
||||
.map((pair) => pair.trim().split(/\s+/).map(Number))
|
||||
.filter(
|
||||
(nums): nums is [number, number] =>
|
||||
nums.length >= 2 &&
|
||||
Number.isFinite(nums[0]) &&
|
||||
Number.isFinite(nums[1]),
|
||||
)
|
||||
.map(([lon, lat]) => [lon, lat] as Position);
|
||||
}
|
||||
|
||||
// Делит верхнеуровневые группы внутри MULTIPOLYGON по скобочной вложенности,
|
||||
// чтобы запятые ВНУТРИ координат не рвали полигоны.
|
||||
function splitTopLevel(body: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if (ch === "(") depth++;
|
||||
else if (ch === ")") depth--;
|
||||
else if (ch === "," && depth === 0) {
|
||||
parts.push(body.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(body.slice(start));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function wktToGeometry(wkt: string): Geometry | null {
|
||||
const trimmed = wkt.trim();
|
||||
const upper = trimmed.toUpperCase();
|
||||
|
||||
try {
|
||||
if (upper.startsWith("POINT")) {
|
||||
const body = trimmed.slice(trimmed.indexOf("(") + 1, trimmed.lastIndexOf(")"));
|
||||
const nums = body.trim().split(/\s+/).map(Number);
|
||||
if (nums.length < 2 || !Number.isFinite(nums[0]) || !Number.isFinite(nums[1])) {
|
||||
return null;
|
||||
}
|
||||
return { type: "Point", coordinates: [nums[0], nums[1]] };
|
||||
}
|
||||
|
||||
if (upper.startsWith("MULTIPOLYGON")) {
|
||||
// MULTIPOLYGON(((ring),(hole)),((ring)))
|
||||
const inner = trimmed.slice(trimmed.indexOf("(") + 1, trimmed.lastIndexOf(")"));
|
||||
const polygons = splitTopLevel(inner)
|
||||
.map((polyChunk) => {
|
||||
const polyBody = polyChunk.trim().replace(/^\(/, "").replace(/\)$/, "");
|
||||
const rings = splitTopLevel(polyBody)
|
||||
.map((ringChunk) => parseRing(ringChunk.replace(/[()]/g, "")))
|
||||
.filter((ring) => ring.length >= 3);
|
||||
return rings;
|
||||
})
|
||||
.filter((rings) => rings.length > 0);
|
||||
if (polygons.length === 0) return null;
|
||||
return { type: "MultiPolygon", coordinates: polygons };
|
||||
}
|
||||
|
||||
if (upper.startsWith("POLYGON")) {
|
||||
// POLYGON((ring),(hole))
|
||||
const inner = trimmed.slice(trimmed.indexOf("(") + 1, trimmed.lastIndexOf(")"));
|
||||
const rings = splitTopLevel(inner)
|
||||
.map((ringChunk) => parseRing(ringChunk.replace(/[()]/g, "")))
|
||||
.filter((ring) => ring.length >= 3);
|
||||
if (rings.length === 0) return null;
|
||||
return { type: "Polygon", coordinates: rings };
|
||||
}
|
||||
} catch {
|
||||
// Любой неожиданный WKT — graceful skip (зона не рисуется).
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layer styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
/**
|
||||
* Tests require: jest + @testing-library/react + @testing-library/jest-dom
|
||||
* npm i -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom ts-jest
|
||||
*/
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import { Badge } from "../Badge";
|
||||
|
||||
describe("Badge", () => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
/**
|
||||
* Tests require: jest + @testing-library/react + @testing-library/jest-dom
|
||||
* npm i -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom ts-jest
|
||||
*/
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { Drawer } from "../Drawer";
|
||||
|
||||
describe("Drawer", () => {
|
||||
|
|
@ -27,7 +25,7 @@ describe("Drawer", () => {
|
|||
});
|
||||
|
||||
it("calls onClose when Escape is pressed", () => {
|
||||
const onClose = jest.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<Drawer open={true} onClose={onClose} side="right">
|
||||
<p>Контент</p>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
/**
|
||||
* Tests require: jest + @testing-library/react + @testing-library/jest-dom
|
||||
* npm i -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom ts-jest
|
||||
*/
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import { HeadlineBar } from "../HeadlineBar";
|
||||
|
||||
describe("HeadlineBar", () => {
|
||||
|
|
@ -25,9 +22,15 @@ describe("HeadlineBar", () => {
|
|||
|
||||
it("does not render subtitle section when omitted", () => {
|
||||
const { container } = render(<HeadlineBar title="Только заголовок" />);
|
||||
// subtitle div has marginTop style — not present when subtitle is undefined
|
||||
const subtitleDivs = container.querySelectorAll("div > div > div");
|
||||
expect(subtitleDivs).toHaveLength(0);
|
||||
// The content wrapper (flex:1 column) holds the title and, when present, the
|
||||
// subtitle as a second child. With no subtitle it must hold exactly one
|
||||
// child (the title) — the subtitle separator block is not rendered.
|
||||
const contentWrapper = container.querySelector('[role="banner"] > div');
|
||||
expect(contentWrapper).not.toBeNull();
|
||||
expect(contentWrapper?.children).toHaveLength(1);
|
||||
expect(contentWrapper?.firstElementChild?.textContent).toBe(
|
||||
"Только заголовок",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders rightSlot content", () => {
|
||||
|
|
|
|||
148
frontend/src/lib/__tests__/wkt.test.ts
Normal file
148
frontend/src/lib/__tests__/wkt.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* Unit tests for the hand-rolled WKT → GeoJSON parser (#999 risk-zone parser).
|
||||
*
|
||||
* Highest-risk untested code: this parser drives the risk-zone polygons drawn on
|
||||
* the Site Finder map. The bug class it must guard against is coordinate-order
|
||||
* inversion (WKT `lon lat` → GeoJSON `[lon, lat]`) — getting that wrong puts
|
||||
* zones/markers in the ocean.
|
||||
*/
|
||||
import type {
|
||||
MultiPolygon,
|
||||
Point,
|
||||
Polygon,
|
||||
Position,
|
||||
} from "geojson";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseRing, splitTopLevel, wktToGeometry } from "../wkt";
|
||||
|
||||
// A simple square ring (closed) in `lon lat` order, EKB-ish coordinates.
|
||||
const SQUARE =
|
||||
"POLYGON((60.5 56.8, 60.6 56.8, 60.6 56.9, 60.5 56.9, 60.5 56.8))";
|
||||
|
||||
describe("wktToGeometry — POLYGON", () => {
|
||||
it("parses a valid single-ring POLYGON", () => {
|
||||
const geom = wktToGeometry(SQUARE) as Polygon | null;
|
||||
expect(geom).not.toBeNull();
|
||||
expect(geom?.type).toBe("Polygon");
|
||||
expect(geom?.coordinates).toHaveLength(1); // one outer ring
|
||||
expect(geom?.coordinates[0]).toHaveLength(5); // 5 closed points
|
||||
});
|
||||
|
||||
it("preserves WKT lon/lat order as GeoJSON [lon, lat]", () => {
|
||||
// The bug class: this must NOT swap to [lat, lon].
|
||||
const geom = wktToGeometry(SQUARE) as Polygon;
|
||||
const first = geom.coordinates[0][0];
|
||||
expect(first).toEqual([60.5, 56.8]);
|
||||
// lon (60) is the X / first element; lat (56) is the Y / second.
|
||||
expect(first[0]).toBe(60.5);
|
||||
expect(first[1]).toBe(56.8);
|
||||
});
|
||||
|
||||
it("parses a POLYGON with a hole (two rings)", () => {
|
||||
const wkt =
|
||||
"POLYGON((0 0, 10 0, 10 10, 0 10, 0 0)," +
|
||||
"(2 2, 4 2, 4 4, 2 4, 2 2))";
|
||||
const geom = wktToGeometry(wkt) as Polygon | null;
|
||||
expect(geom?.type).toBe("Polygon");
|
||||
expect(geom?.coordinates).toHaveLength(2); // outer + hole
|
||||
expect(geom?.coordinates[1][0]).toEqual([2, 2]);
|
||||
});
|
||||
|
||||
it("skips a ring with fewer than 3 points → null when none survive", () => {
|
||||
// Two points cannot form a ring; the single ring is filtered out → null.
|
||||
const geom = wktToGeometry("POLYGON((0 0, 1 1))");
|
||||
expect(geom).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("wktToGeometry — MULTIPOLYGON", () => {
|
||||
it("parses a MULTIPOLYGON with two polygons", () => {
|
||||
const wkt =
|
||||
"MULTIPOLYGON(" +
|
||||
"((0 0, 1 0, 1 1, 0 1, 0 0))," +
|
||||
"((5 5, 6 5, 6 6, 5 6, 5 5))" +
|
||||
")";
|
||||
const geom = wktToGeometry(wkt) as MultiPolygon | null;
|
||||
expect(geom?.type).toBe("MultiPolygon");
|
||||
expect(geom?.coordinates).toHaveLength(2); // two polygons
|
||||
expect(geom?.coordinates[0][0]).toHaveLength(5);
|
||||
expect(geom?.coordinates[1][0][0]).toEqual([5, 5]);
|
||||
});
|
||||
|
||||
it("does not let intra-coordinate commas split polygons", () => {
|
||||
// The top-level splitter must respect bracket depth — 8 coords, 1 polygon.
|
||||
const wkt = "MULTIPOLYGON(((0 0, 1 0, 1 1, 0 1, 0 0)))";
|
||||
const geom = wktToGeometry(wkt) as MultiPolygon;
|
||||
expect(geom.coordinates).toHaveLength(1);
|
||||
expect(geom.coordinates[0]).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wktToGeometry — POINT", () => {
|
||||
it("parses a POINT preserving lon/lat order", () => {
|
||||
const geom = wktToGeometry("POINT(60.6 56.8)") as Point | null;
|
||||
expect(geom?.type).toBe("Point");
|
||||
expect(geom?.coordinates).toEqual([60.6, 56.8]);
|
||||
});
|
||||
|
||||
it("returns null for a POINT with a single coordinate", () => {
|
||||
expect(wktToGeometry("POINT(60.6)")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("wktToGeometry — EWKT tolerance", () => {
|
||||
it("strips a leading SRID=4326; prefix and parses the same geometry", () => {
|
||||
const plain = wktToGeometry(SQUARE) as Polygon;
|
||||
const ewkt = wktToGeometry("SRID=4326;" + SQUARE) as Polygon | null;
|
||||
expect(ewkt?.type).toBe("Polygon");
|
||||
expect(ewkt?.coordinates).toEqual(plain.coordinates);
|
||||
});
|
||||
|
||||
it("strips the SRID prefix case-insensitively", () => {
|
||||
const geom = wktToGeometry("srid=3857;POINT(1 2)") as Point | null;
|
||||
expect(geom?.type).toBe("Point");
|
||||
expect(geom?.coordinates).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wktToGeometry — invalid / unsupported input", () => {
|
||||
it("returns null for a garbage string", () => {
|
||||
expect(wktToGeometry("not wkt at all")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an empty string", () => {
|
||||
expect(wktToGeometry("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an unsupported geometry type (LINESTRING)", () => {
|
||||
expect(wktToGeometry("LINESTRING(0 0, 1 1, 2 2)")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRing", () => {
|
||||
it("parses a coordinate list into [lon, lat] positions", () => {
|
||||
const ring: Position[] = parseRing("60.5 56.8, 60.6 56.9");
|
||||
expect(ring).toEqual([
|
||||
[60.5, 56.8],
|
||||
[60.6, 56.9],
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops pairs that are not finite numbers", () => {
|
||||
const ring = parseRing("60.5 56.8, foo bar, 1 2");
|
||||
expect(ring).toEqual([
|
||||
[60.5, 56.8],
|
||||
[1, 2],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitTopLevel", () => {
|
||||
it("splits only on top-level commas, respecting bracket depth", () => {
|
||||
const parts = splitTopLevel("(0 0, 1 1),(2 2, 3 3)");
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(parts[0]).toBe("(0 0, 1 1)");
|
||||
expect(parts[1].trim()).toBe("(2 2, 3 3)");
|
||||
});
|
||||
});
|
||||
122
frontend/src/lib/wkt.ts
Normal file
122
frontend/src/lib/wkt.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* Minimal WKT → GeoJSON parser (no new deps — see #999 conventions).
|
||||
*
|
||||
* Extracted from MarketLayers.tsx so the hand-rolled parser is unit-testable in
|
||||
* isolation (the #999 risk-zone parser had zero coverage and never ran on the QA
|
||||
* parcel). Behavior is otherwise identical to the inlined version.
|
||||
*
|
||||
* Supports the types the NSPD risk layer actually emits (ST_AsText, EPSG:4326,
|
||||
* `lon lat` order): POINT, POLYGON, MULTIPOLYGON. Anything else / garbage → null
|
||||
* (the zone then simply is not drawn). NOT general-purpose — risk zones only.
|
||||
*
|
||||
* Coordinate order: WKT is `lon lat`; GeoJSON Position is `[lon, lat]` — we keep
|
||||
* that order verbatim (mismatching it is the bug class that drops markers in the
|
||||
* ocean).
|
||||
*
|
||||
* EWKT tolerance: a leading `SRID=<digits>;` prefix (case-insensitive) is
|
||||
* stripped before type detection, so `SRID=4326;POLYGON(...)` parses to the same
|
||||
* geometry as the bare `POLYGON(...)`. Backend currently emits plain WKT via
|
||||
* ST_AsText, but this is cheap defense-in-depth (per the #999 review + chip).
|
||||
*/
|
||||
|
||||
import type { Geometry, Position } from "geojson";
|
||||
|
||||
// Leading `SRID=4326;` EWKT prefix (case-insensitive), if present.
|
||||
const EWKT_SRID_PREFIX = /^SRID=\d+;/i;
|
||||
|
||||
export function parseRing(body: string): Position[] {
|
||||
return body
|
||||
.split(",")
|
||||
.map((pair) => pair.trim().split(/\s+/).map(Number))
|
||||
.filter(
|
||||
(nums): nums is [number, number] =>
|
||||
nums.length >= 2 &&
|
||||
Number.isFinite(nums[0]) &&
|
||||
Number.isFinite(nums[1]),
|
||||
)
|
||||
.map(([lon, lat]) => [lon, lat] as Position);
|
||||
}
|
||||
|
||||
// Делит верхнеуровневые группы внутри MULTIPOLYGON по скобочной вложенности,
|
||||
// чтобы запятые ВНУТРИ координат не рвали полигоны.
|
||||
export function splitTopLevel(body: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if (ch === "(") depth++;
|
||||
else if (ch === ")") depth--;
|
||||
else if (ch === "," && depth === 0) {
|
||||
parts.push(body.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(body.slice(start));
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function wktToGeometry(wkt: string): Geometry | null {
|
||||
// Strip a leading EWKT `SRID=<n>;` prefix before type detection so EWKT input
|
||||
// parses identically to plain WKT.
|
||||
const trimmed = wkt.trim().replace(EWKT_SRID_PREFIX, "");
|
||||
const upper = trimmed.toUpperCase();
|
||||
|
||||
try {
|
||||
if (upper.startsWith("POINT")) {
|
||||
const body = trimmed.slice(
|
||||
trimmed.indexOf("(") + 1,
|
||||
trimmed.lastIndexOf(")"),
|
||||
);
|
||||
const nums = body.trim().split(/\s+/).map(Number);
|
||||
if (
|
||||
nums.length < 2 ||
|
||||
!Number.isFinite(nums[0]) ||
|
||||
!Number.isFinite(nums[1])
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { type: "Point", coordinates: [nums[0], nums[1]] };
|
||||
}
|
||||
|
||||
if (upper.startsWith("MULTIPOLYGON")) {
|
||||
// MULTIPOLYGON(((ring),(hole)),((ring)))
|
||||
const inner = trimmed.slice(
|
||||
trimmed.indexOf("(") + 1,
|
||||
trimmed.lastIndexOf(")"),
|
||||
);
|
||||
const polygons = splitTopLevel(inner)
|
||||
.map((polyChunk) => {
|
||||
const polyBody = polyChunk
|
||||
.trim()
|
||||
.replace(/^\(/, "")
|
||||
.replace(/\)$/, "");
|
||||
const rings = splitTopLevel(polyBody)
|
||||
.map((ringChunk) => parseRing(ringChunk.replace(/[()]/g, "")))
|
||||
.filter((ring) => ring.length >= 3);
|
||||
return rings;
|
||||
})
|
||||
.filter((rings) => rings.length > 0);
|
||||
if (polygons.length === 0) return null;
|
||||
return { type: "MultiPolygon", coordinates: polygons };
|
||||
}
|
||||
|
||||
if (upper.startsWith("POLYGON")) {
|
||||
// POLYGON((ring),(hole))
|
||||
const inner = trimmed.slice(
|
||||
trimmed.indexOf("(") + 1,
|
||||
trimmed.lastIndexOf(")"),
|
||||
);
|
||||
const rings = splitTopLevel(inner)
|
||||
.map((ringChunk) => parseRing(ringChunk.replace(/[()]/g, "")))
|
||||
.filter((ring) => ring.length >= 3);
|
||||
if (rings.length === 0) return null;
|
||||
return { type: "Polygon", coordinates: rings };
|
||||
}
|
||||
} catch {
|
||||
// Любой неожиданный WKT — graceful skip (зона не рисуется).
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
2
frontend/vitest-env.d.ts
vendored
Normal file
2
frontend/vitest-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="vitest/globals" />
|
||||
/// <reference types="@testing-library/jest-dom" />
|
||||
21
frontend/vitest.config.ts
Normal file
21
frontend/vitest.config.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
// Mirror tsconfig "paths": { "@/*": ["./src/*"] } so test imports like
|
||||
// `@/lib/wkt` resolve the same way they do in the Next.js build.
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
include: ["src/**/*.{test,spec}.{ts,tsx}"],
|
||||
},
|
||||
});
|
||||
3
frontend/vitest.setup.ts
Normal file
3
frontend/vitest.setup.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Registers jest-dom matchers (toBeInTheDocument, toHaveStyle, …) on Vitest's
|
||||
// `expect`. Imported via vitest.config.ts `setupFiles`.
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
Loading…
Add table
Reference in a new issue