const assert = require("node:assert/strict"); const { test } = require("node:test"); const vm = require("node:vm"); const fs = require("node:fs/promises"); const path = require("node:path"); const { fileURLToPath, pathToFileURL } = require("node:url"); const moduleCache = new Map(); async function loadModule(file) { const absolute = path.resolve(file); if (moduleCache.has(absolute)) return moduleCache.get(absolute); const source = await fs.readFile(absolute, "utf8"); const module = new vm.SourceTextModule(source, { identifier: pathToFileURL(absolute).href }); moduleCache.set(absolute, module); await module.link((specifier, referencingModule) => { const dependency = fileURLToPath(new URL(specifier, referencingModule.identifier)); return loadModule(dependency); }); await module.evaluate(); return module; } function dashboardModule(name) { return loadModule(path.join(__dirname, "../../public/js/dashboard", name)); } test("status state trusts API unreachable status", async () => { const module = await dashboardModule("status-state.js"); const { stateForNodeStatus } = module.namespace; const current = new Date().toISOString(); assert.equal(stateForNodeStatus(null), "unreachable"); assert.equal( stateForNodeStatus({ status: "unreachable", received_at: current }), "unreachable" ); assert.equal(stateForNodeStatus({ status: "ok", received_at: current }), "healthy"); assert.equal( stateForNodeStatus({ status: "probe_error", timestamp: current }), "probe-error" ); assert.equal(stateForNodeStatus({ status: "ok", timestamp: "invalid" }), "unreachable"); }); test("timeframeToHours includes the selected range", async () => { const module = await dashboardModule("timeframe.js"); const { timeframeToHours } = module.namespace; assert.equal(timeframeToHours({ selected: () => "hour", range: () => 1 }), 1); assert.equal(timeframeToHours({ selected: () => "day", range: () => 2 }), 48); assert.equal(timeframeToHours({ selected: () => "week", range: () => 3 }), 504); assert.equal(timeframeToHours({ selected: () => "unknown", range: () => 1 }), 24); }); test("adjust steps to next/previous timeframe at range bounds", async () => { // We need a DOM element for createTimeframeState const { JSDOM } = await import("jsdom").catch(() => ({ JSDOM: null })); // Test the logic directly using a minimal mock const module = await dashboardModule("timeframe.js"); const { createTimeframeState } = module.namespace; // createTimeframeState needs document.querySelector — skip if unavailable if (typeof globalThis.document === "undefined") { // Patch minimal document globalThis.document = { querySelector: () => ({ dataset: { defaultTimeframe: "day" } }) }; } const state = createTimeframeState(); assert.equal(state.selected(), "day"); assert.equal(state.range(), 1); // Decrease below range 1 should step down to hour at threshold-1 (23) state.adjust("day", -1); assert.equal(state.selected(), "hour"); assert.equal(state.range(), 23); // Increase from 23 hours to 24 should promote to day state.adjust("hour", 1); assert.equal(state.selected(), "day"); assert.equal(state.range(), 1); // Decrease at hour/1 should stay at hour (lowest) state.select("hour"); state.ranges.hour = 1; state.adjust("hour", -1); assert.equal(state.selected(), "hour"); assert.equal(state.range(), 1); // 6 days + 1 = 7 days → promotes to week state.select("day"); state.ranges.day = 6; state.adjust("day", 1); assert.equal(state.selected(), "week"); assert.equal(state.range(), 1); // 3 weeks + 1 = 4 weeks → promotes to month state.select("week"); state.ranges.week = 3; state.adjust("week", 1); assert.equal(state.selected(), "month"); assert.equal(state.range(), 1); // 11 months + 1 = 12 → promotes to year state.select("month"); state.ranges.month = 11; state.adjust("month", 1); assert.equal(state.selected(), "year"); assert.equal(state.range(), 1); // Demoting from week should land at day/6 state.select("week"); state.ranges.week = 1; state.adjust("week", -1); assert.equal(state.selected(), "day"); assert.equal(state.range(), 6); delete globalThis.document; }); test("tooltipOptions returns independent callback configuration", async () => { const module = await dashboardModule("chart-options.js"); const { TOOLTIP_DEFAULTS, tooltipOptions } = module.namespace; const title = () => ""; const options = tooltipOptions({ title }); assert.equal(options.mode, "index"); assert.equal(options.callbacks.title, title); assert.equal(TOOLTIP_DEFAULTS.callbacks, undefined); }); test("compact weather scales reclaim secondary-axis width", async () => { const module = await dashboardModule("weather-page.js"); const { weatherScaleOptions } = module.namespace; const compact = weatherScaleOptions(true); const desktop = weatherScaleOptions(false); assert.equal(compact.yHumid.display, false); assert.equal(compact.yRain.display, false); assert.equal(compact.yTemp.title.display, false); assert.equal(compact.x.ticks.maxRotation, 0); assert.equal(desktop.yHumid.display, true); assert.equal(desktop.yRain.display, true); }); test("compact single-axis insights scales drop the axis title", async () => { const module = await dashboardModule("insights.js"); const { insightsSingleAxisScales } = module.namespace; const compact = insightsSingleAxisScales(true, { title: "Volatility (σ)", y: { beginAtZero: true } }); const desktop = insightsSingleAxisScales(false, { title: "Volatility (σ)" }); assert.equal(compact.y.title.display, false); assert.equal(compact.x.ticks.maxRotation, 0); assert.equal(compact.x.ticks.maxTicksLimit, 6); // Extra y options are preserved through the merge. assert.equal(compact.y.beginAtZero, true); assert.equal(desktop.y.title.display, true); assert.equal(desktop.y.title.text, "Volatility (σ)"); assert.equal(desktop.x.ticks.maxRotation, 45); }); test("compact overlay insights scales collapse secondary axes", async () => { const module = await dashboardModule("insights.js"); const { insightsOverlayScales } = module.namespace; const primary = { id: "yDO", text: "DO (mg/L)", color: "#0ea5e9" }; const secondaries = [ { id: "yORP", text: "ORP (mV)", color: "#f59e0b" }, { id: "yTemp", text: "°C", color: "#64748b" } ]; const compact = insightsOverlayScales(true, primary, secondaries); const desktop = insightsOverlayScales(false, primary, secondaries); // Primary axis stays visible but loses its title on phones. assert.equal(compact.yDO.title.display, false); assert.equal(compact.yDO.position, "left"); assert.equal(compact.yDO.ticks.color, "#0ea5e9"); // Secondary right-hand axes are hidden entirely to reclaim width. assert.equal(compact.yORP.display, false); assert.equal(compact.yTemp.display, false); // Desktop keeps everything. assert.equal(desktop.yDO.title.display, true); assert.equal(desktop.yORP.display, true); assert.equal(desktop.yTemp.display, true); assert.equal(desktop.yTemp.title.text, "°C"); }); test("compact viewport helper degrades gracefully without matchMedia", async () => { const module = await dashboardModule("viewport.js"); const { isCompactViewport, COMPACT_MEDIA_QUERY } = module.namespace; assert.equal(COMPACT_MEDIA_QUERY, "(max-width: 575.98px)"); // No window in the test VM → must not throw, returns false. assert.equal(isCompactViewport(), false); });