mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: finish the homepage plugin, move service links and canvas out of core (B19)
This commit is contained in:
1 parent
21937a6b02
commit
d4ec444558
256 files changed
+27365
-9077
No files matched your search
@@ -0,0 +1,100 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string; body?: unknown },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin activated against a real in-memory database, its router mounted
|
||||
* on an express app the way core mounts it: JSON parsed, the acting user set
|
||||
* from the request, everything else left to the plugin.
|
||||
*/
|
||||
export async function startServer(
|
||||
options: {
|
||||
permissions?: string[];
|
||||
users?: string[];
|
||||
fetch?: (url: string, init?: { method?: string }) => Promise<Response>;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir);
|
||||
for (const user of options.users ?? ["user-1", "user-2"]) {
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run(user, user);
|
||||
}
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions ?? ["homepage.use"],
|
||||
fetch: options.fetch,
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") ?? undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
async request(method, path, { user = "user-1", body: given } = {}) {
|
||||
const body = method === "GET" ? undefined : given;
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("json")) {
|
||||
return { status: response.status, body: await response.text() };
|
||||
}
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import { pluginDir } from "./helpers";
|
||||
|
||||
// The three tables as core's SQLite bootstrap created them before 2.9.0,
|
||||
// plus the homepage_items index performance-indexes.ts added.
|
||||
const LEGACY_DDL = `
|
||||
CREATE TABLE homepage_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
folder_id INTEGER,
|
||||
sync_id TEXT UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_homepage_items_user_id ON homepage_items (user_id);
|
||||
|
||||
CREATE TABLE homepage_layouts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
layout TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE dashboard_service_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
sync_id TEXT UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
let db: TestDb | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
db?.close();
|
||||
db = null;
|
||||
});
|
||||
|
||||
const tableExists = (name: string) =>
|
||||
!!db!.sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(name);
|
||||
|
||||
const indexOn = (table: string) =>
|
||||
(
|
||||
db!.sqlite
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
|
||||
)
|
||||
.all(table) as { name: string }[]
|
||||
).map((row) => row.name);
|
||||
|
||||
describe("adopting the homepage tables", () => {
|
||||
it("keeps every existing row when the legacy tables are there", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO homepage_items (user_id, type_id, title, config, sync_id) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run("user-1", "clock", "My Clock", '{"format":"24h"}', "sync-item");
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO homepage_layouts (user_id, layout) VALUES (?, ?)",
|
||||
)
|
||||
.run("user-1", '{"entries":[],"pan":{"x":0,"y":0},"zoom":1}');
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO dashboard_service_links (user_id, label, url, sync_id) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.run("user-1", "Grafana", "https://grafana.local", "sync-link");
|
||||
},
|
||||
});
|
||||
|
||||
expect(db.applied).toEqual(["0001_adopt_homepage_tables"]);
|
||||
expect(tableExists("homepage_items")).toBe(false);
|
||||
expect(tableExists("homepage_layouts")).toBe(false);
|
||||
expect(tableExists("dashboard_service_links")).toBe(false);
|
||||
expect(tableExists("p_homepage_homepage_items")).toBe(true);
|
||||
expect(tableExists("p_homepage_homepage_layouts")).toBe(true);
|
||||
expect(tableExists("p_homepage_dashboard_service_links")).toBe(true);
|
||||
|
||||
const items = db.sqlite
|
||||
.prepare(
|
||||
"SELECT user_id, type_id, title, sync_id FROM p_homepage_homepage_items",
|
||||
)
|
||||
.all();
|
||||
expect(items).toEqual([
|
||||
{
|
||||
user_id: "user-1",
|
||||
type_id: "clock",
|
||||
title: "My Clock",
|
||||
sync_id: "sync-item",
|
||||
},
|
||||
]);
|
||||
|
||||
const layouts = db.sqlite
|
||||
.prepare("SELECT user_id, layout FROM p_homepage_homepage_layouts")
|
||||
.all();
|
||||
expect(layouts).toEqual([
|
||||
{
|
||||
user_id: "user-1",
|
||||
layout: '{"entries":[],"pan":{"x":0,"y":0},"zoom":1}',
|
||||
},
|
||||
]);
|
||||
|
||||
const links = db.sqlite
|
||||
.prepare(
|
||||
"SELECT user_id, label, url, sync_id FROM p_homepage_dashboard_service_links",
|
||||
)
|
||||
.all();
|
||||
expect(links).toEqual([
|
||||
{
|
||||
user_id: "user-1",
|
||||
label: "Grafana",
|
||||
url: "https://grafana.local",
|
||||
sync_id: "sync-link",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(indexOn("p_homepage_homepage_items")).toEqual([
|
||||
"idx_homepage_items_user_id",
|
||||
]);
|
||||
expect(indexOn("p_homepage_dashboard_service_links")).toEqual([
|
||||
"idx_dashboard_service_links_user_id",
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates the tables fresh when there are no legacy ones", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
|
||||
expect(tableExists("p_homepage_homepage_items")).toBe(true);
|
||||
expect(tableExists("p_homepage_homepage_layouts")).toBe(true);
|
||||
expect(tableExists("p_homepage_dashboard_service_links")).toBe(true);
|
||||
expect(
|
||||
db.sqlite.prepare("SELECT * FROM p_homepage_homepage_items").all(),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes a user's rows when the user is deleted", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
|
||||
},
|
||||
});
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"INSERT INTO p_homepage_homepage_items (user_id, type_id) VALUES (?, ?)",
|
||||
)
|
||||
.run("user-1", "clock");
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"INSERT INTO p_homepage_homepage_items (user_id, type_id) VALUES (?, ?)",
|
||||
)
|
||||
.run("user-2", "clock");
|
||||
|
||||
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
|
||||
|
||||
const remaining = (
|
||||
db.sqlite
|
||||
.prepare("SELECT user_id FROM p_homepage_homepage_items")
|
||||
.all() as { user_id: string }[]
|
||||
).map((row) => row.user_id);
|
||||
expect(remaining).toEqual(["user-2"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { startServer, type TestServer } from "./helpers";
|
||||
|
||||
let server: TestServer;
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
describe("outbound routes go through ctx.fetch", () => {
|
||||
it("proxy: returns the parsed JSON body from ctx.fetch", async () => {
|
||||
server = await startServer({
|
||||
fetch: async () => jsonResponse({ hello: "world" }),
|
||||
});
|
||||
const res = await server.request(
|
||||
"GET",
|
||||
"/proxy?url=https://example.com/api",
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ hello: "world" });
|
||||
});
|
||||
|
||||
it("proxy: every request to a private address is refused by the SSRF guard, never reaching ctx.fetch's caller", async () => {
|
||||
// The route never gets to call the stub for a rejected URL because
|
||||
// safeOutboundFetch (behind ctx.fetch) throws before any response comes
|
||||
// back; simulate that by having the stub itself reject, matching what
|
||||
// the real safeOutboundFetch does for a blocked host.
|
||||
server = await startServer({
|
||||
fetch: async () => {
|
||||
throw new Error("Private destinations are not allowed");
|
||||
},
|
||||
});
|
||||
const res = await server.request(
|
||||
"GET",
|
||||
"/proxy?url=http://127.0.0.1:9999/secret",
|
||||
);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("proxy: rejects a non-JSON response", async () => {
|
||||
server = await startServer({
|
||||
fetch: async () =>
|
||||
new Response("<html></html>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html" },
|
||||
}),
|
||||
});
|
||||
const res = await server.request("GET", "/proxy?url=https://example.com");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("ping: reports ok for a 2xx response", async () => {
|
||||
server = await startServer({
|
||||
fetch: async () => new Response(null, { status: 200 }),
|
||||
});
|
||||
const res = await server.request("GET", "/ping?url=https://example.com");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rss: parses items from the feed ctx.fetch returned", async () => {
|
||||
const xml = `<?xml version="1.0"?><rss><channel>
|
||||
<item><title>Hello</title><link>https://example.com/1</link></item>
|
||||
</channel></rss>`;
|
||||
server = await startServer({
|
||||
fetch: async () =>
|
||||
new Response(xml, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/xml" },
|
||||
}),
|
||||
});
|
||||
const res = await server.request(
|
||||
"GET",
|
||||
"/rss?url=https://example.com/feed",
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(res.body[0].title).toBe("Hello");
|
||||
});
|
||||
|
||||
it("rejects a request with no url", async () => {
|
||||
server = await startServer();
|
||||
expect((await server.request("GET", "/ping")).status).toBe(400);
|
||||
expect((await server.request("GET", "/rss")).status).toBe(400);
|
||||
expect((await server.request("GET", "/proxy")).status).toBe(400);
|
||||
expect((await server.request("GET", "/favicon")).status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { startServer, type TestServer } from "./helpers";
|
||||
|
||||
let server: TestServer;
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("homepage item routes", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startServer();
|
||||
});
|
||||
|
||||
it("creates, lists, updates and deletes an item", async () => {
|
||||
const created = await server.request("POST", "/items", {
|
||||
body: { typeId: "clock", title: "My Clock", config: { format: "24h" } },
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body).toMatchObject({ typeId: "clock", title: "My Clock" });
|
||||
expect(created.body.syncId).toEqual(expect.any(String));
|
||||
|
||||
const list = await server.request("GET", "/items");
|
||||
expect(list.body).toHaveLength(1);
|
||||
|
||||
const updated = await server.request("PUT", `/items/${created.body.id}`, {
|
||||
body: { title: "Renamed" },
|
||||
});
|
||||
expect(updated.status).toBe(200);
|
||||
expect(updated.body.title).toBe("Renamed");
|
||||
|
||||
const deleted = await server.request("DELETE", `/items/${created.body.id}`);
|
||||
expect(deleted.status).toBe(200);
|
||||
expect((await server.request("GET", "/items")).body).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a missing typeId", async () => {
|
||||
const res = await server.request("POST", "/items", { body: {} });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("404s updating or deleting another user's item", async () => {
|
||||
const created = await server.request("POST", "/items", {
|
||||
body: { typeId: "clock" },
|
||||
});
|
||||
const res = await server.request("DELETE", `/items/${created.body.id}`, {
|
||||
user: "user-2",
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("homepage layout routes", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startServer();
|
||||
});
|
||||
|
||||
it("returns null before any layout is saved, then round-trips one", async () => {
|
||||
expect((await server.request("GET", "/layout")).body).toBeNull();
|
||||
|
||||
const saved = await server.request("PUT", "/layout", {
|
||||
body: { entries: [], pan: { x: 0, y: 0 }, zoom: 1 },
|
||||
});
|
||||
expect(saved.status).toBe(200);
|
||||
expect(saved.body.layout).toEqual({
|
||||
entries: [],
|
||||
pan: { x: 0, y: 0 },
|
||||
zoom: 1,
|
||||
});
|
||||
|
||||
const fetched = await server.request("GET", "/layout");
|
||||
expect(fetched.body.layout.zoom).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("service link routes", () => {
|
||||
beforeEach(async () => {
|
||||
server = await startServer();
|
||||
});
|
||||
|
||||
it("creates, lists, updates and deletes a service link", async () => {
|
||||
const created = await server.request("POST", "/service-links", {
|
||||
body: { label: "Grafana", url: "grafana.local" },
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
// A bare host gets normalized to http://.
|
||||
expect(created.body.url).toBe("http://grafana.local");
|
||||
|
||||
const list = await server.request("GET", "/service-links");
|
||||
expect(list.body).toHaveLength(1);
|
||||
|
||||
const updated = await server.request(
|
||||
"PUT",
|
||||
`/service-links/${created.body.id}`,
|
||||
{ body: { label: "Grafana Dashboard" } },
|
||||
);
|
||||
expect(updated.body.label).toBe("Grafana Dashboard");
|
||||
|
||||
const deleted = await server.request(
|
||||
"DELETE",
|
||||
`/service-links/${created.body.id}`,
|
||||
);
|
||||
expect(deleted.status).toBe(200);
|
||||
expect((await server.request("GET", "/service-links")).body).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an invalid url", async () => {
|
||||
const res = await server.request("POST", "/service-links", {
|
||||
body: { label: "Bad", url: "javascript:alert(1)" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permission denial", () => {
|
||||
it("answers 403 for every route without homepage.use", async () => {
|
||||
server = await startServer({ permissions: [] });
|
||||
expect((await server.request("GET", "/items")).status).toBe(403);
|
||||
expect((await server.request("GET", "/layout")).status).toBe(403);
|
||||
expect((await server.request("GET", "/service-links")).status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { startServer, type TestServer } from "./helpers";
|
||||
|
||||
let server: TestServer;
|
||||
|
||||
beforeEach(async () => {
|
||||
server = await startServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("homepage.items service", () => {
|
||||
it("lists the acting user's items", async () => {
|
||||
await server.request("POST", "/items", {
|
||||
body: { typeId: "clock", title: "My Clock" },
|
||||
});
|
||||
await server.request("POST", "/items", {
|
||||
body: { typeId: "notes" },
|
||||
user: "user-2",
|
||||
});
|
||||
|
||||
server.mock.setActor("user-1");
|
||||
const service = server.mock.services.get("homepage.items") as
|
||||
| { list: () => Promise<Array<{ typeId: string; title: string | null }>> }
|
||||
| undefined;
|
||||
const items = await service!.list();
|
||||
expect(items).toEqual([
|
||||
{ id: expect.any(Number), typeId: "clock", title: "My Clock" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty list with no acting user", async () => {
|
||||
server.mock.setActor(undefined);
|
||||
const service = server.mock.services.get("homepage.items") as
|
||||
{ list: () => Promise<unknown[]> } | undefined;
|
||||
expect(await service!.list()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isValidServiceLinkUrl,
|
||||
normalizeServiceLinkUrl,
|
||||
} from "../../src/backend/url.js";
|
||||
|
||||
describe("service link URL handling", () => {
|
||||
it("keeps explicit http and https URLs", () => {
|
||||
expect(normalizeServiceLinkUrl("https://example.com")).toBe(
|
||||
"https://example.com",
|
||||
);
|
||||
expect(normalizeServiceLinkUrl("http://192.168.1.10:8080")).toBe(
|
||||
"http://192.168.1.10:8080",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds http to bare service addresses", () => {
|
||||
expect(normalizeServiceLinkUrl("192.168.1.10:8080")).toBe(
|
||||
"http://192.168.1.10:8080",
|
||||
);
|
||||
expect(normalizeServiceLinkUrl("termix.local")).toBe("http://termix.local");
|
||||
});
|
||||
|
||||
it("rejects unsupported schemes", () => {
|
||||
expect(isValidServiceLinkUrl("ssh://example.com")).toBe(false);
|
||||
expect(isValidServiceLinkUrl("javascript:alert(1)")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
|
||||
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, language: "en" }),
|
||||
}));
|
||||
|
||||
import { ClockWidget } from "../../src/frontend/widgets/ClockWidget.js";
|
||||
import { ClockEditForm } from "../../src/frontend/dialogs/ClockEditForm.js";
|
||||
import {
|
||||
normalizeTimezone,
|
||||
validateClockTimezone,
|
||||
} from "../../src/frontend/clock-timezone.js";
|
||||
import type { CanvasWidget, ClockConfig } from "../../src/frontend/types.js";
|
||||
|
||||
const widget: CanvasWidget = {
|
||||
id: 1,
|
||||
typeId: "clock",
|
||||
title: "Clock",
|
||||
config: {},
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 8,
|
||||
h: 5,
|
||||
zOrder: 0,
|
||||
};
|
||||
|
||||
function renderClock(config: Partial<ClockConfig> = {}) {
|
||||
return render(
|
||||
<ClockWidget
|
||||
widget={widget}
|
||||
config={{ showSeconds: false, format: "24h", ...config }}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function renderForm(config: Partial<ClockConfig> = {}) {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ClockEditForm
|
||||
config={{ showSeconds: false, format: "24h", ...config }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
onChange,
|
||||
input: screen.getByPlaceholderText(/America\/New_York/) as HTMLInputElement,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("ClockWidget timezone handling", () => {
|
||||
it("renders instead of throwing when a saved config holds an invalid timezone", () => {
|
||||
// Regression: "America/New York" (space, not underscore) made
|
||||
// toLocaleTimeString throw a RangeError and took down the whole canvas.
|
||||
expect(() => renderClock({ timezone: "America/New York" })).not.toThrow();
|
||||
expect(screen.queryByText("America/New York")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to local time formatting for an invalid timezone", () => {
|
||||
renderClock({ timezone: "Not/AZone" });
|
||||
const local = new Date().toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
expect(screen.getByText(local)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps and displays a valid timezone", () => {
|
||||
renderClock({ timezone: "America/New_York" });
|
||||
expect(screen.getByText("America/New_York")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows no timezone label when none is configured", () => {
|
||||
const { container } = renderClock();
|
||||
expect(
|
||||
container.querySelector('[class*="text-muted-foreground/60"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("formats in the configured timezone, not the host timezone", () => {
|
||||
renderClock({ timezone: "Asia/Tokyo" });
|
||||
const tokyo = new Date().toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "Asia/Tokyo",
|
||||
});
|
||||
expect(screen.getByText(tokyo)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateClockTimezone", () => {
|
||||
it("turns spaces into underscores", () => {
|
||||
expect(validateClockTimezone("America/New York")).toEqual({
|
||||
timezone: "America/New_York",
|
||||
valid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", () => {
|
||||
expect(validateClockTimezone(" Europe/Paris ")).toEqual({
|
||||
timezone: "Europe/Paris",
|
||||
valid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an unusable zone as invalid but still returns it normalized", () => {
|
||||
expect(validateClockTimezone("Not/AZone")).toEqual({
|
||||
timezone: "Not/AZone",
|
||||
valid: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats blank input as clearing the timezone", () => {
|
||||
expect(validateClockTimezone(" ")).toEqual({
|
||||
timezone: undefined,
|
||||
valid: true,
|
||||
});
|
||||
expect(validateClockTimezone(undefined)).toEqual({
|
||||
timezone: undefined,
|
||||
valid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes every run of whitespace", () => {
|
||||
expect(normalizeTimezone("America/Argentina/Buenos Aires")).toBe(
|
||||
"America/Argentina/Buenos_Aires",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClockEditForm", () => {
|
||||
it("stores what the user typed without rewriting it mid-edit", () => {
|
||||
const { onChange, input } = renderForm();
|
||||
fireEvent.change(input, { target: { value: "America/Los Angeles" } });
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timezone: "America/Los Angeles" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("flags an unusable zone the same way FolderMetadataDialog flags a duplicate name", () => {
|
||||
const { input } = renderForm({ timezone: "Not/AZone" });
|
||||
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(input.className).toContain("border-destructive");
|
||||
expect(screen.getByText("homepage.invalidTimezone")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("accepts a zone typed with a space, since it is normalized before checking", () => {
|
||||
const { input } = renderForm({ timezone: "America/New York" });
|
||||
expect(input.getAttribute("aria-invalid")).toBe("false");
|
||||
expect(screen.queryByText("homepage.invalidTimezone")).toBeNull();
|
||||
});
|
||||
|
||||
it("says nothing about an empty field", () => {
|
||||
const { input } = renderForm();
|
||||
expect(input.getAttribute("aria-invalid")).toBe("false");
|
||||
expect(screen.queryByText("homepage.invalidTimezone")).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the timezone when the field is emptied", () => {
|
||||
const { onChange, input } = renderForm({ timezone: "Europe/Paris" });
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timezone: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the other clock settings when the timezone changes", () => {
|
||||
const { onChange, input } = renderForm({
|
||||
showSeconds: true,
|
||||
format: "12h",
|
||||
});
|
||||
fireEvent.change(input, { target: { value: "UTC" } });
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
timezone: "UTC",
|
||||
showSeconds: true,
|
||||
format: "12h",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, waitFor } from "@testing-library/react";
|
||||
|
||||
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, language: "en" }),
|
||||
}));
|
||||
|
||||
const getVersionInfo = vi.fn();
|
||||
const getDatabaseHealth = vi.fn(async () => ({ status: "ok" }));
|
||||
const getUptime = vi.fn(async () => ({ formatted: "1d 2h" }));
|
||||
|
||||
vi.mock("@termix/plugin-sdk/ui", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
getVersionInfo: (...args: unknown[]) => getVersionInfo(...args),
|
||||
getDatabaseHealth: () => getDatabaseHealth(),
|
||||
getUptime: () => getUptime(),
|
||||
// The real one installs an interval and a visibilitychange listener; the
|
||||
// component's first fetch happens outside it, which is all these tests need.
|
||||
runVisibleInterval: () => () => {},
|
||||
}));
|
||||
|
||||
import { SystemOverviewWidget } from "../../src/frontend/widgets/SystemOverviewWidget.js";
|
||||
import type { CanvasWidget } from "../../src/frontend/types.js";
|
||||
|
||||
const widget: CanvasWidget = {
|
||||
id: 1,
|
||||
typeId: "system_overview",
|
||||
title: "Termix",
|
||||
config: {},
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 10,
|
||||
h: 6,
|
||||
zOrder: 0,
|
||||
};
|
||||
|
||||
function renderWidget() {
|
||||
render(
|
||||
<SystemOverviewWidget
|
||||
widget={widget}
|
||||
config={{ showVersion: true, showDbHealth: false, showUptime: false }}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getVersionInfo.mockReset();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("SystemOverviewWidget update indicator", () => {
|
||||
it("asks /version to actually check the remote release", async () => {
|
||||
// Regression: it used to pass checkRemote=false, which makes the endpoint
|
||||
// return early without ever comparing against the latest release.
|
||||
getVersionInfo.mockResolvedValue({
|
||||
status: "up_to_date",
|
||||
localVersion: "2.6.1",
|
||||
});
|
||||
|
||||
renderWidget();
|
||||
|
||||
await waitFor(() => expect(getVersionInfo).toHaveBeenCalledTimes(1));
|
||||
expect(getVersionInfo).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("flags an available update from the status the endpoint returns", async () => {
|
||||
// Regression: it used to read `updateAvailable`, a field /version does not
|
||||
// return in either mode, so the row could never appear.
|
||||
getVersionInfo.mockResolvedValue({
|
||||
status: "requires_update",
|
||||
localVersion: "2.6.0",
|
||||
remoteVersion: "2.6.1",
|
||||
});
|
||||
|
||||
renderWidget();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("homepage.overviewUpdateAvailable")).toBeTruthy(),
|
||||
);
|
||||
});
|
||||
|
||||
it("labels that row 'Update' rather than 'Up to date'", async () => {
|
||||
// homepage.overviewUpdate is the string "Up to date", which read as the
|
||||
// label of an update-available row said "Up to date Update available".
|
||||
getVersionInfo.mockResolvedValue({
|
||||
status: "requires_update",
|
||||
localVersion: "2.6.0",
|
||||
});
|
||||
|
||||
renderWidget();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("homepage.overviewUpdateLabel")).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByText("homepage.overviewUpdate")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no update row when the server is current", async () => {
|
||||
getVersionInfo.mockResolvedValue({
|
||||
status: "up_to_date",
|
||||
localVersion: "2.6.1",
|
||||
});
|
||||
|
||||
renderWidget();
|
||||
|
||||
await waitFor(() => expect(screen.getByText("2.6.1")).toBeTruthy());
|
||||
expect(screen.queryByText("homepage.overviewUpdateAvailable")).toBeNull();
|
||||
expect(screen.queryByText("homepage.overviewUpdateLabel")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no update row on a beta build, which is ahead rather than behind", async () => {
|
||||
getVersionInfo.mockResolvedValue({
|
||||
status: "beta",
|
||||
localVersion: "2.7.0",
|
||||
remoteVersion: "2.6.1",
|
||||
});
|
||||
|
||||
renderWidget();
|
||||
|
||||
await waitFor(() => expect(screen.getByText("2.7.0")).toBeTruthy());
|
||||
expect(screen.queryByText("homepage.overviewUpdateAvailable")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
|
||||
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, language: "en" }),
|
||||
}));
|
||||
|
||||
import { WidgetEditDialog } from "../../src/frontend/dialogs/WidgetEditDialog.js";
|
||||
import type { CanvasWidget } from "../../src/frontend/types.js";
|
||||
|
||||
function clockWidget(timezone?: string): CanvasWidget {
|
||||
return {
|
||||
id: 7,
|
||||
typeId: "clock",
|
||||
title: "Clock",
|
||||
config: { timezone, showSeconds: true, format: "24h" },
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 8,
|
||||
h: 5,
|
||||
zOrder: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function open(timezone?: string) {
|
||||
const onSave = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<WidgetEditDialog
|
||||
widget={clockWidget(timezone)}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
onSave,
|
||||
onClose,
|
||||
input: screen.getByPlaceholderText(/America\/New_York/) as HTMLInputElement,
|
||||
save: screen
|
||||
.getByText("homepage.save")
|
||||
.closest("button") as HTMLButtonElement,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("WidgetEditDialog clock timezone validation", () => {
|
||||
it("stores the normalized zone when the user typed a space", () => {
|
||||
const { onSave, onClose, input, save } = open();
|
||||
fireEvent.change(input, { target: { value: "America/New York" } });
|
||||
expect(screen.queryByText("homepage.invalidTimezone")).toBeNull();
|
||||
expect(save.disabled).toBe(false);
|
||||
|
||||
fireEvent.click(save);
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
7,
|
||||
"Clock",
|
||||
expect.objectContaining({ timezone: "America/New_York" }),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables Save while the zone is unusable", () => {
|
||||
const { onSave, onClose, input, save } = open();
|
||||
fireEvent.change(input, { target: { value: "Not/AZone" } });
|
||||
|
||||
expect(save.disabled).toBe(true);
|
||||
expect(screen.getByText("homepage.invalidTimezone")).toBeTruthy();
|
||||
|
||||
fireEvent.click(save);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-enables Save once the zone becomes usable", () => {
|
||||
const { onSave, input, save } = open();
|
||||
fireEvent.change(input, { target: { value: "Not/AZone" } });
|
||||
expect(save.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(input, { target: { value: "Asia/Tokyo" } });
|
||||
expect(save.disabled).toBe(false);
|
||||
expect(screen.queryByText("homepage.invalidTimezone")).toBeNull();
|
||||
|
||||
fireEvent.click(save);
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
7,
|
||||
"Clock",
|
||||
expect.objectContaining({ timezone: "Asia/Tokyo" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("saves a blank field as no timezone", () => {
|
||||
const { onSave, input, save } = open("Europe/Paris");
|
||||
fireEvent.change(input, { target: { value: " " } });
|
||||
fireEvent.click(save);
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
7,
|
||||
"Clock",
|
||||
expect.objectContaining({ timezone: undefined }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
const declared = (views?: Array<{ id: string }>) =>
|
||||
(views ?? []).map((view) => view.id).sort();
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
describe(`${manifest.id} activate`, () => {
|
||||
it("activates and registers only views its manifest declares", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
const contributes = manifest.contributes as unknown as Record<
|
||||
string,
|
||||
Array<{ id: string }> | undefined
|
||||
>;
|
||||
const tabs = declared(contributes.tabs);
|
||||
const cards = declared(contributes.dashboardCards);
|
||||
for (const id of rendered.registered.tabs()) expect(tabs).toContain(id);
|
||||
for (const id of rendered.registered.dashboardCards()) {
|
||||
expect(cards).toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
it("registers the homepage tab as a singleton", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.tabs()).toContain("homepage");
|
||||
});
|
||||
|
||||
it("registers both dashboard cards", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.dashboardCards().sort()).toEqual([
|
||||
"homepage_preview",
|
||||
"service_links",
|
||||
]);
|
||||
});
|
||||
|
||||
it("contributes to the dashboard.secondaryView slot", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.slot("dashboard.secondaryView")).toEqual([
|
||||
"homepage",
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes everything it registered on deactivate", async () => {
|
||||
const app = await renderWithApp(plugin, { manifest, locales });
|
||||
await app.deactivate();
|
||||
expect(app.registered.tabs()).toEqual([]);
|
||||
expect(app.registered.dashboardCards()).toEqual([]);
|
||||
expect(app.registered.slot("dashboard.secondaryView")).toEqual([]);
|
||||
expect(app.registered.actions()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
screenToCanvas,
|
||||
canvasToScreen,
|
||||
zoomAroundPoint,
|
||||
} from "../../../src/frontend/canvas/canvasGeometry.js";
|
||||
|
||||
const PAN = { x: 100, y: 200 };
|
||||
const ZOOM = 2;
|
||||
|
||||
describe("screenToCanvas", () => {
|
||||
it("inverts canvasToScreen", () => {
|
||||
const canvas = { x: 50, y: 75 };
|
||||
const screen = canvasToScreen(canvas.x, canvas.y, PAN, ZOOM);
|
||||
const back = screenToCanvas(screen.x, screen.y, PAN, ZOOM);
|
||||
expect(back.x).toBeCloseTo(canvas.x);
|
||||
expect(back.y).toBeCloseTo(canvas.y);
|
||||
});
|
||||
|
||||
it("accounts for pan offset", () => {
|
||||
const result = screenToCanvas(100, 200, PAN, 1);
|
||||
expect(result.x).toBe(0);
|
||||
expect(result.y).toBe(0);
|
||||
});
|
||||
|
||||
it("accounts for zoom", () => {
|
||||
const result = screenToCanvas(300, 400, PAN, ZOOM);
|
||||
expect(result.x).toBe((300 - 100) / 2);
|
||||
expect(result.y).toBe((400 - 200) / 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvasToScreen", () => {
|
||||
it("maps origin with pan offset", () => {
|
||||
const result = canvasToScreen(0, 0, PAN, ZOOM);
|
||||
expect(result.x).toBe(100);
|
||||
expect(result.y).toBe(200);
|
||||
});
|
||||
|
||||
it("scales by zoom", () => {
|
||||
const result = canvasToScreen(10, 20, PAN, ZOOM);
|
||||
expect(result.x).toBe(120);
|
||||
expect(result.y).toBe(240);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zoomAroundPoint", () => {
|
||||
it("keeps the mouse point on the same canvas coordinate", () => {
|
||||
const mouseX = 400;
|
||||
const mouseY = 300;
|
||||
const pan = { x: 0, y: 0 };
|
||||
const oldZoom = 1;
|
||||
const newZoom = 2;
|
||||
|
||||
const newPan = zoomAroundPoint(mouseX, mouseY, pan, oldZoom, newZoom);
|
||||
|
||||
// Canvas coordinate under mouse before zoom
|
||||
const canvasBefore = screenToCanvas(mouseX, mouseY, pan, oldZoom);
|
||||
// Canvas coordinate under mouse after zoom with newPan
|
||||
const canvasAfter = screenToCanvas(mouseX, mouseY, newPan, newZoom);
|
||||
|
||||
expect(canvasAfter.x).toBeCloseTo(canvasBefore.x);
|
||||
expect(canvasAfter.y).toBeCloseTo(canvasBefore.y);
|
||||
});
|
||||
|
||||
it("returns correct pan for zoom-out", () => {
|
||||
const mouseX = 200;
|
||||
const mouseY = 150;
|
||||
const pan = { x: 50, y: 50 };
|
||||
const oldZoom = 2;
|
||||
const newZoom = 1;
|
||||
|
||||
const newPan = zoomAroundPoint(mouseX, mouseY, pan, oldZoom, newZoom);
|
||||
const canvasBefore = screenToCanvas(mouseX, mouseY, pan, oldZoom);
|
||||
const canvasAfter = screenToCanvas(mouseX, mouseY, newPan, newZoom);
|
||||
|
||||
expect(canvasAfter.x).toBeCloseTo(canvasBefore.x);
|
||||
expect(canvasAfter.y).toBeCloseTo(canvasBefore.y);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
snapToGrid,
|
||||
snapToGridFloor,
|
||||
} from "../../../src/frontend/canvas/snapToGrid.js";
|
||||
import { GRID_SIZE } from "../../../src/frontend/types.js";
|
||||
|
||||
describe("snapToGrid", () => {
|
||||
it("snaps to nearest grid multiple", () => {
|
||||
expect(snapToGrid(0)).toBe(0);
|
||||
expect(snapToGrid(GRID_SIZE)).toBe(GRID_SIZE);
|
||||
expect(snapToGrid(GRID_SIZE * 2)).toBe(GRID_SIZE * 2);
|
||||
});
|
||||
|
||||
it("rounds to nearest (up when exactly halfway)", () => {
|
||||
expect(snapToGrid(GRID_SIZE / 2)).toBe(GRID_SIZE);
|
||||
expect(snapToGrid(GRID_SIZE * 1.5)).toBe(GRID_SIZE * 2);
|
||||
});
|
||||
|
||||
it("rounds down when below halfway", () => {
|
||||
expect(snapToGrid(GRID_SIZE * 0.4)).toBe(0);
|
||||
expect(snapToGrid(GRID_SIZE * 1.4)).toBe(GRID_SIZE);
|
||||
});
|
||||
|
||||
it("works with negative values", () => {
|
||||
expect(snapToGrid(-GRID_SIZE)).toBe(-GRID_SIZE);
|
||||
expect(snapToGrid(-GRID_SIZE * 0.4)).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapToGridFloor", () => {
|
||||
it("always rounds down to floor multiple", () => {
|
||||
expect(snapToGridFloor(GRID_SIZE - 1)).toBe(0);
|
||||
expect(snapToGridFloor(GRID_SIZE)).toBe(GRID_SIZE);
|
||||
expect(snapToGridFloor(GRID_SIZE + 1)).toBe(GRID_SIZE);
|
||||
expect(snapToGridFloor(GRID_SIZE * 2 - 1)).toBe(GRID_SIZE);
|
||||
});
|
||||
|
||||
it("handles zero", () => {
|
||||
expect(snapToGridFloor(0)).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user