View raw

1 const assert = require("node:assert/strict"); 2 const { test } = require("node:test"); 3 const vm = require("node:vm"); 4 const fs = require("node:fs/promises"); 5 const path = require("node:path"); 6 const { fileURLToPath, pathToFileURL } = require("node:url"); 7 8 const moduleCache = new Map(); 9 10 async function loadModule(file) { 11 const absolute = path.resolve(file); 12 if (moduleCache.has(absolute)) return moduleCache.get(absolute); 13 14 const source = await fs.readFile(absolute, "utf8"); 15 const module = new vm.SourceTextModule(source, { 16 identifier: pathToFileURL(absolute).href 17 }); 18 moduleCache.set(absolute, module); 19 20 await module.link((specifier, referencingModule) => { 21 const dependency = fileURLToPath(new URL(specifier, referencingModule.identifier)); 22 return loadModule(dependency); 23 }); 24 await module.evaluate(); 25 return module; 26 } 27 28 function dashboardModule(name) { 29 return loadModule(path.join(__dirname, "../../public/js/dashboard", name)); 30 } 31 32 test("status state trusts API unreachable status", async () => { 33 const module = await dashboardModule("status-state.js"); 34 const { stateForNodeStatus } = module.namespace; 35 const current = new Date().toISOString(); 36 37 assert.equal(stateForNodeStatus(null), "unreachable"); 38 assert.equal( 39 stateForNodeStatus({ status: "unreachable", received_at: current }), 40 "unreachable" 41 ); 42 assert.equal(stateForNodeStatus({ status: "ok", received_at: current }), "healthy"); 43 assert.equal( 44 stateForNodeStatus({ status: "probe_error", timestamp: current }), 45 "probe-error" 46 ); 47 assert.equal(stateForNodeStatus({ status: "ok", timestamp: "invalid" }), "unreachable"); 48 }); 49 50 test("timeframeToHours includes the selected range", async () => { 51 const module = await dashboardModule("timeframe.js"); 52 const { timeframeToHours } = module.namespace; 53 54 assert.equal(timeframeToHours({ selected: () => "hour", range: () => 1 }), 1); 55 assert.equal(timeframeToHours({ selected: () => "day", range: () => 2 }), 48); 56 assert.equal(timeframeToHours({ selected: () => "week", range: () => 3 }), 504); 57 assert.equal(timeframeToHours({ selected: () => "unknown", range: () => 1 }), 24); 58 }); 59 60 test("adjust steps to next/previous timeframe at range bounds", async () => { 61 // We need a DOM element for createTimeframeState 62 const { JSDOM } = await import("jsdom").catch(() => ({ JSDOM: null })); 63 64 // Test the logic directly using a minimal mock 65 const module = await dashboardModule("timeframe.js"); 66 const { createTimeframeState } = module.namespace; 67 68 // createTimeframeState needs document.querySelector — skip if unavailable 69 if (typeof globalThis.document === "undefined") { 70 // Patch minimal document 71 globalThis.document = { querySelector: () => ({ dataset: { defaultTimeframe: "day" } }) }; 72 } 73 74 const state = createTimeframeState(); 75 assert.equal(state.selected(), "day"); 76 assert.equal(state.range(), 1); 77 78 // Decrease below range 1 should step down to hour at threshold-1 (23) 79 state.adjust("day", -1); 80 assert.equal(state.selected(), "hour"); 81 assert.equal(state.range(), 23); 82 83 // Increase from 23 hours to 24 should promote to day 84 state.adjust("hour", 1); 85 assert.equal(state.selected(), "day"); 86 assert.equal(state.range(), 1); 87 88 // Decrease at hour/1 should stay at hour (lowest) 89 state.select("hour"); 90 state.ranges.hour = 1; 91 state.adjust("hour", -1); 92 assert.equal(state.selected(), "hour"); 93 assert.equal(state.range(), 1); 94 95 // 6 days + 1 = 7 days → promotes to week 96 state.select("day"); 97 state.ranges.day = 6; 98 state.adjust("day", 1); 99 assert.equal(state.selected(), "week"); 100 assert.equal(state.range(), 1); 101 102 // 3 weeks + 1 = 4 weeks → promotes to month 103 state.select("week"); 104 state.ranges.week = 3; 105 state.adjust("week", 1); 106 assert.equal(state.selected(), "month"); 107 assert.equal(state.range(), 1); 108 109 // 11 months + 1 = 12 → promotes to year 110 state.select("month"); 111 state.ranges.month = 11; 112 state.adjust("month", 1); 113 assert.equal(state.selected(), "year"); 114 assert.equal(state.range(), 1); 115 116 // Demoting from week should land at day/6 117 state.select("week"); 118 state.ranges.week = 1; 119 state.adjust("week", -1); 120 assert.equal(state.selected(), "day"); 121 assert.equal(state.range(), 6); 122 123 delete globalThis.document; 124 }); 125 126 test("tooltipOptions returns independent callback configuration", async () => { 127 const module = await dashboardModule("chart-options.js"); 128 const { TOOLTIP_DEFAULTS, tooltipOptions } = module.namespace; 129 const title = () => ""; 130 const options = tooltipOptions({ title }); 131 132 assert.equal(options.mode, "index"); 133 assert.equal(options.callbacks.title, title); 134 assert.equal(TOOLTIP_DEFAULTS.callbacks, undefined); 135 }); 136 137 test("compact weather scales reclaim secondary-axis width", async () => { 138 const module = await dashboardModule("weather-page.js"); 139 const { weatherScaleOptions } = module.namespace; 140 const compact = weatherScaleOptions(true); 141 const desktop = weatherScaleOptions(false); 142 143 assert.equal(compact.yHumid.display, false); 144 assert.equal(compact.yRain.display, false); 145 assert.equal(compact.yTemp.title.display, false); 146 assert.equal(compact.x.ticks.maxRotation, 0); 147 assert.equal(desktop.yHumid.display, true); 148 assert.equal(desktop.yRain.display, true); 149 }); 150 151 test("compact single-axis insights scales drop the axis title", async () => { 152 const module = await dashboardModule("insights.js"); 153 const { insightsSingleAxisScales } = module.namespace; 154 155 const compact = insightsSingleAxisScales(true, { 156 title: "Volatility (σ)", 157 y: { beginAtZero: true } 158 }); 159 const desktop = insightsSingleAxisScales(false, { title: "Volatility (σ)" }); 160 161 assert.equal(compact.y.title.display, false); 162 assert.equal(compact.x.ticks.maxRotation, 0); 163 assert.equal(compact.x.ticks.maxTicksLimit, 6); 164 // Extra y options are preserved through the merge. 165 assert.equal(compact.y.beginAtZero, true); 166 167 assert.equal(desktop.y.title.display, true); 168 assert.equal(desktop.y.title.text, "Volatility (σ)"); 169 assert.equal(desktop.x.ticks.maxRotation, 45); 170 }); 171 172 test("compact overlay insights scales collapse secondary axes", async () => { 173 const module = await dashboardModule("insights.js"); 174 const { insightsOverlayScales } = module.namespace; 175 176 const primary = { id: "yDO", text: "DO (mg/L)", color: "#0ea5e9" }; 177 const secondaries = [ 178 { id: "yORP", text: "ORP (mV)", color: "#f59e0b" }, 179 { id: "yTemp", text: "°C", color: "#64748b" } 180 ]; 181 182 const compact = insightsOverlayScales(true, primary, secondaries); 183 const desktop = insightsOverlayScales(false, primary, secondaries); 184 185 // Primary axis stays visible but loses its title on phones. 186 assert.equal(compact.yDO.title.display, false); 187 assert.equal(compact.yDO.position, "left"); 188 assert.equal(compact.yDO.ticks.color, "#0ea5e9"); 189 190 // Secondary right-hand axes are hidden entirely to reclaim width. 191 assert.equal(compact.yORP.display, false); 192 assert.equal(compact.yTemp.display, false); 193 194 // Desktop keeps everything. 195 assert.equal(desktop.yDO.title.display, true); 196 assert.equal(desktop.yORP.display, true); 197 assert.equal(desktop.yTemp.display, true); 198 assert.equal(desktop.yTemp.title.text, "°C"); 199 }); 200 201 test("compact viewport helper degrades gracefully without matchMedia", async () => { 202 const module = await dashboardModule("viewport.js"); 203 const { isCompactViewport, COMPACT_MEDIA_QUERY } = module.namespace; 204 205 assert.equal(COMPACT_MEDIA_QUERY, "(max-width: 575.98px)"); 206 // No window in the test VM → must not throw, returns false. 207 assert.equal(isCompactViewport(), false); 208 }); 209