From 851a2b9734530a7c439342f31900c4b3040f1c06 Mon Sep 17 00:00:00 2001 From: WhatObiPlays <219249021+whatobiplays@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:42:24 -0700 Subject: [PATCH 1/2] fix: prevent stale cargo test artifacts in make test --- Makefile | 21 +++- tools/cargo-test-freshness.mjs | 124 ++++++++++++++++++++ tools/cargo-test-freshness.test.mjs | 174 ++++++++++++++++++++++++++++ 3 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 tools/cargo-test-freshness.mjs create mode 100644 tools/cargo-test-freshness.test.mjs diff --git a/Makefile b/Makefile index 52b4874..09077d1 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,13 @@ BACKEND_MANIFEST := crates/emuchef-rust-backend/Cargo.toml # Rust workspace boundary: the EmuChef Tauri application's workspace. EMUCHEF_TAURI_MANIFEST := apps/emuchef-app/src-tauri/Cargo.toml -.PHONY: help install ensure-deps build test phase-6f-qualification-check emuchef-app config-editor dev +# Backend Cargo test freshness gate: the stamp records a content digest of the +# backend crate's build inputs so `make test` never reuses a stale test binary. +BACKEND_DIR := $(dir $(BACKEND_MANIFEST)) +BACKEND_TEST_STAMP := $(BACKEND_DIR)target/.emuchef-cargo-test-source.sha256 +BACKEND_TEST_PENDING := $(BACKEND_TEST_STAMP).pending + +.PHONY: help install ensure-deps build test phase-6f-qualification-check cargo-test-freshness-check backend-test-fresh emuchef-app config-editor dev help: @printf '%s\n' \ @@ -48,7 +54,7 @@ build: ensure-deps npm --prefix $(EMUCHEF_APP_PREFIX) run build npm --prefix $(CONFIG_EDITOR_PREFIX) run build -test: ensure-deps phase-6f-qualification-check +test: ensure-deps phase-6f-qualification-check cargo-test-freshness-check backend-test-fresh cargo test --manifest-path $(BACKEND_MANIFEST) cargo test --manifest-path $(EMUCHEF_TAURI_MANIFEST) npm --prefix $(EMUCHEF_APP_PREFIX) run test @@ -63,6 +69,17 @@ phase-6f-qualification-check: node --test tools/phase-6f-qualification.test.mjs node tools/phase-6f-qualification.mjs --check +cargo-test-freshness-check: + node --test tools/cargo-test-freshness.test.mjs + +backend-test-fresh: + @node tools/cargo-test-freshness.mjs $(BACKEND_DIR) $(BACKEND_TEST_STAMP) + @if [ -f $(BACKEND_TEST_PENDING) ]; then \ + echo 'backend-test-fresh: backend source changed; cleaning emuchef-rust-backend test artifacts'; \ + cargo clean --manifest-path $(BACKEND_MANIFEST) -p emuchef-rust-backend; \ + rm -f $(BACKEND_TEST_PENDING); \ + fi + # Ordinary app development is simulation-only; real execution requires its separate guarded command. emuchef-app: ensure-deps npm --prefix $(EMUCHEF_APP_PREFIX) run tauri:dev diff --git a/tools/cargo-test-freshness.mjs b/tools/cargo-test-freshness.mjs new file mode 100644 index 0000000..d31f4a6 --- /dev/null +++ b/tools/cargo-test-freshness.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +"use strict"; + +// Freshness gate for Cargo test artifacts. +// +// Cargo decides whether a local crate's test executable is up to date from +// file mtimes (its fingerprint uses CheckDepInfo with checksum disabled), so a +// checkout or restore that preserves old source mtimes can leave `cargo test` +// executing a stale binary even when the source content changed. This script +// compares a content digest of the crate's build inputs against a recorded +// stamp instead of relying on mtimes. +// +// Protocol (fail-safe against interruption): +// 1. If the digest differs from the stamp, or a `.pending` marker already +// exists, the caller must invalidate the crate's Cargo artifacts. +// 2. The `.pending` marker is written before the stamp is replaced, so no +// interruption can leave a changed source state looking fresh. +// 3. The caller clears the marker only after the package-scoped +// `cargo clean` succeeds. +// +// Output: prints "invalidate" when the caller must clean, otherwise "fresh". +// Exit status is non-zero (fail closed) when inputs cannot be read. + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +function usage() { + console.error( + "usage: node cargo-test-freshness.mjs ", + ); + process.exit(2); +} + +function collectBuildInputs(crateDir) { + const inputs = []; + + const addRegularFile = (relative) => { + const full = path.join(crateDir, relative); + if (fs.statSync(full).isFile()) { + inputs.push(relative); + } + }; + + for (const name of ["Cargo.toml", "Cargo.lock", "build.rs"]) { + if (fs.existsSync(path.join(crateDir, name))) { + addRegularFile(name); + } + } + + for (const sourceDir of ["src", "tests"]) { + const base = path.join(crateDir, sourceDir); + if (!fs.existsSync(base)) { + continue; + } + const walk = (current, prefix) => { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + walk(full, relative); + } else if (entry.isFile()) { + inputs.push(relative); + } + } + }; + walk(base, sourceDir); + } + + inputs.sort(); + return inputs; +} + +function computeDigest(crateDir) { + const hash = crypto.createHash("sha256"); + for (const relative of collectBuildInputs(crateDir)) { + const content = fs.readFileSync(path.join(crateDir, relative)); + hash.update(relative); + hash.update("\0"); + hash.update(content); + hash.update("\0"); + } + return hash.digest("hex"); +} + +function atomicWrite(file, content) { + const directory = path.dirname(file); + fs.mkdirSync(directory, { recursive: true }); + const temporary = path.join( + directory, + `.${path.basename(file)}.tmp-${process.pid}-${Date.now()}`, + ); + fs.writeFileSync(temporary, content); + fs.renameSync(temporary, file); +} + +function main() { + const [crateDir, stampFile] = process.argv.slice(2); + if (!crateDir || !stampFile) { + usage(); + } + + const digest = computeDigest(crateDir); + const pendingFile = `${stampFile}.pending`; + const hasPending = fs.existsSync(pendingFile); + const previous = fs.existsSync(stampFile) + ? fs.readFileSync(stampFile, "utf8").trim() + : ""; + + if (hasPending || previous !== digest) { + atomicWrite(pendingFile, `${digest}\n`); + atomicWrite(stampFile, `${digest}\n`); + process.stdout.write("invalidate\n"); + } else { + process.stdout.write("fresh\n"); + } +} + +try { + main(); +} catch (error) { + console.error(`cargo-test-freshness: ${error.message}`); + process.exit(1); +} diff --git a/tools/cargo-test-freshness.test.mjs b/tools/cargo-test-freshness.test.mjs new file mode 100644 index 0000000..4487b5a --- /dev/null +++ b/tools/cargo-test-freshness.test.mjs @@ -0,0 +1,174 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +const SCRIPT = path.join(import.meta.dirname, "cargo-test-freshness.mjs"); +const OLD_MTIME = new Date("2020-01-02T03:04:05Z"); + +function makeCrate() { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "emuchef-freshness-")); + fs.mkdirSync(path.join(directory, "src"), { recursive: true }); + fs.mkdirSync(path.join(directory, "tests"), { recursive: true }); + fs.writeFileSync( + path.join(directory, "Cargo.toml"), + '[package]\nname = "emuchef-rust-backend"\nversion = "0.1.0"\nedition = "2021"\n', + ); + fs.writeFileSync(path.join(directory, "Cargo.lock"), "# lockfile\n"); + fs.writeFileSync( + path.join(directory, "src", "lib.rs"), + "pub fn value() -> u8 { 1 }\n", + ); + fs.writeFileSync( + path.join(directory, "tests", "contract.rs"), + "use fixture::value;\n#[test]\nfn works() { assert_eq!(value(), 1); }\n", + ); + return directory; +} + +function runFreshness(crateDir, stampFile) { + return spawnSync(process.execPath, [SCRIPT, crateDir, stampFile], { + encoding: "utf8", + }); +} + +function setOldMtimes(directory) { + const walk = (current) => { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + walk(full); + } else { + fs.utimesSync(full, OLD_MTIME, OLD_MTIME); + } + } + }; + walk(directory); +} + +function allSourceMtimesOld(crateDir) { + const paths = ["Cargo.toml", "Cargo.lock", "src/lib.rs", "tests/contract.rs"]; + return paths.every( + (relative) => fs.statSync(path.join(crateDir, relative)).mtimeMs === OLD_MTIME.getTime(), + ); +} + +function stampDigest(stampFile) { + return fs.readFileSync(stampFile, "utf8").trim(); +} + +test("missing stamp invalidates and records the current digest", (t) => { + const crateDir = makeCrate(); + t.after(() => fs.rmSync(crateDir, { recursive: true, force: true })); + const stampFile = path.join(crateDir, "target", ".fixture-source.sha256"); + + const result = runFreshness(crateDir, stampFile); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /invalidate/); + assert.ok(fs.existsSync(stampFile)); + assert.match(stampDigest(stampFile), /^[0-9a-f]{64}$/); + assert.ok(fs.existsSync(`${stampFile}.pending`)); +}); + +test("unchanged digest with old mtimes stays fresh and untouched", (t) => { + const crateDir = makeCrate(); + t.after(() => fs.rmSync(crateDir, { recursive: true, force: true })); + const stampFile = path.join(crateDir, "target", ".fixture-source.sha256"); + + runFreshness(crateDir, stampFile); + fs.rmSync(`${stampFile}.pending`); + setOldMtimes(crateDir); + const before = stampDigest(stampFile); + + const result = runFreshness(crateDir, stampFile); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /fresh/); + assert.equal(stampDigest(stampFile), before); + assert.ok(!fs.existsSync(`${stampFile}.pending`)); + assert.ok(allSourceMtimesOld(crateDir)); +}); + +test("changed content with an unchanged old mtime invalidates", (t) => { + const crateDir = makeCrate(); + t.after(() => fs.rmSync(crateDir, { recursive: true, force: true })); + const stampFile = path.join(crateDir, "target", ".fixture-source.sha256"); + const libFile = path.join(crateDir, "src", "lib.rs"); + + runFreshness(crateDir, stampFile); + fs.rmSync(`${stampFile}.pending`); + setOldMtimes(crateDir); + const before = stampDigest(stampFile); + fs.writeFileSync(libFile, "pub fn value() -> u8 { 2 }\n"); + fs.utimesSync(libFile, OLD_MTIME, OLD_MTIME); + + const result = runFreshness(crateDir, stampFile); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /invalidate/); + assert.notEqual(stampDigest(stampFile), before); + assert.ok(fs.existsSync(`${stampFile}.pending`)); + assert.equal(fs.statSync(libFile).mtimeMs, OLD_MTIME.getTime()); +}); + +test("existing pending marker with a matching digest still invalidates", (t) => { + const crateDir = makeCrate(); + t.after(() => fs.rmSync(crateDir, { recursive: true, force: true })); + const stampFile = path.join(crateDir, "target", ".fixture-source.sha256"); + + runFreshness(crateDir, stampFile); + const before = stampDigest(stampFile); + assert.ok(fs.existsSync(`${stampFile}.pending`)); + + const result = runFreshness(crateDir, stampFile); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /invalidate/); + assert.equal(stampDigest(stampFile), before); + assert.ok(fs.existsSync(`${stampFile}.pending`)); +}); + +test("ignored files do not alter the digest", (t) => { + const crateDir = makeCrate(); + t.after(() => fs.rmSync(crateDir, { recursive: true, force: true })); + const stampFile = path.join(crateDir, "target", ".fixture-source.sha256"); + + runFreshness(crateDir, stampFile); + fs.rmSync(`${stampFile}.pending`); + setOldMtimes(crateDir); + assert.match(runFreshness(crateDir, stampFile).stdout, /fresh/); + + fs.mkdirSync(path.join(crateDir, "target", "deep"), { recursive: true }); + fs.writeFileSync(path.join(crateDir, "target", "deep", "x.bin"), "ignored"); + fs.mkdirSync(path.join(crateDir, ".git"), { recursive: true }); + fs.writeFileSync(path.join(crateDir, ".git", "HEAD"), "ref: refs/heads/main\n"); + fs.writeFileSync(path.join(crateDir, ".DS_Store"), "junk"); + fs.mkdirSync(path.join(crateDir, "gen"), { recursive: true }); + fs.writeFileSync(path.join(crateDir, "gen", "out.txt"), "generated"); + + const first = runFreshness(crateDir, stampFile); + assert.match(first.stdout, /fresh/); + + fs.writeFileSync(path.join(crateDir, "target", "deep", "x.bin"), "changed"); + const second = runFreshness(crateDir, stampFile); + assert.match(second.stdout, /fresh/); +}); + +test("unreadable build input fails closed", (t) => { + const crateDir = makeCrate(); + t.after(() => { + fs.chmodSync(path.join(crateDir, "src", "lib.rs"), 0o644); + fs.rmSync(crateDir, { recursive: true, force: true }); + }); + const stampFile = path.join(crateDir, "target", ".fixture-source.sha256"); + fs.chmodSync(path.join(crateDir, "src", "lib.rs"), 0o000); + + const result = runFreshness(crateDir, stampFile); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /cargo-test-freshness/); + assert.ok(!fs.existsSync(`${stampFile}.pending`)); +}); From 3797092049d2a1f4c1b6a8130955bc0bc0708b8d Mon Sep 17 00:00:00 2001 From: WhatObiPlays <219249021+whatobiplays@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:42:27 -0700 Subject: [PATCH 2/2] fix: resolve config-editor lint warnings --- apps/config-editor/package.json | 2 +- apps/config-editor/src/App.tsx | 144 +++++++++--------- .../src/components/ResizableEditorLayout.tsx | 28 +--- .../components/resizableEditorLayout.logic.ts | 25 +++ .../tests/resizableEditorLayout.logic.test.ts | 64 ++++++++ 5 files changed, 168 insertions(+), 95 deletions(-) create mode 100644 apps/config-editor/src/components/resizableEditorLayout.logic.ts create mode 100644 apps/config-editor/tests/resizableEditorLayout.logic.test.ts diff --git a/apps/config-editor/package.json b/apps/config-editor/package.json index 21acc61..aabeb42 100644 --- a/apps/config-editor/package.json +++ b/apps/config-editor/package.json @@ -29,7 +29,7 @@ "smoke:packaged-runtime-network": "node scripts/smoke-packaged-runtime-network.mjs", "check:rust-runtime": "npm run test:sidecar-packaging && npm run test:sidecar-bundle-inspection && npm run test:macos-packaging && npm run check:tauri-csp && npm run check:python-runtime-retirement && npm run typecheck && npm run test:logic", "dev": "vite", - "test:logic": "node -e \"require('node:fs').rmSync('/tmp/emuchef-config-editor-tests',{recursive:true,force:true})\" && tsc --ignoreConfig --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir /tmp/emuchef-config-editor-tests --rootDir . --types node --skipLibCheck --strict tests/advancedStepInternals.logic.test.ts tests/appGenerator.logic.test.ts tests/deviceProfileGenerator.logic.test.ts tests/editorState.logic.test.ts tests/runtimeConfiguration.logic.test.ts tests/stepDependencies.logic.test.ts tests/stepParams.logic.test.ts tests/textInputGuards.logic.test.ts tests/userConfiguration.logic.test.ts src/components/advancedStepInternals.logic.ts src/components/appGenerator.logic.ts src/components/deviceProfileGenerator.logic.ts src/components/editorState.logic.ts src/components/runtimeConfiguration.logic.ts src/components/stepDependencies.logic.ts src/components/stepParams.logic.ts src/components/textInputGuards.logic.ts src/components/userConfiguration.logic.ts src/api/runtimeConfiguration.ts src/api/types.ts src/api/commands.ts && node --test /tmp/emuchef-config-editor-tests/tests/*.test.js", + "test:logic": "node -e \"require('node:fs').rmSync('/tmp/emuchef-config-editor-tests',{recursive:true,force:true})\" && tsc --ignoreConfig --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir /tmp/emuchef-config-editor-tests --rootDir . --types node --skipLibCheck --strict tests/advancedStepInternals.logic.test.ts tests/appGenerator.logic.test.ts tests/deviceProfileGenerator.logic.test.ts tests/editorState.logic.test.ts tests/resizableEditorLayout.logic.test.ts tests/runtimeConfiguration.logic.test.ts tests/stepDependencies.logic.test.ts tests/stepParams.logic.test.ts tests/textInputGuards.logic.test.ts tests/userConfiguration.logic.test.ts src/components/advancedStepInternals.logic.ts src/components/appGenerator.logic.ts src/components/deviceProfileGenerator.logic.ts src/components/editorState.logic.ts src/components/resizableEditorLayout.logic.ts src/components/runtimeConfiguration.logic.ts src/components/stepDependencies.logic.ts src/components/stepParams.logic.ts src/components/textInputGuards.logic.ts src/components/userConfiguration.logic.ts src/api/runtimeConfiguration.ts src/api/types.ts src/api/commands.ts && node --test /tmp/emuchef-config-editor-tests/tests/*.test.js", "typecheck": "tsc --noEmit", "lint": "eslint src tests", "build": "tsc && vite build", diff --git a/apps/config-editor/src/App.tsx b/apps/config-editor/src/App.tsx index 6bd07be..74aeeb4 100644 --- a/apps/config-editor/src/App.tsx +++ b/apps/config-editor/src/App.tsx @@ -1,6 +1,6 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { confirm as nativeConfirm, open, save as saveFile } from "@tauri-apps/plugin-dialog"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { EditorCommand } from "./api/commands"; import { @@ -122,6 +122,79 @@ export default function App() { const allowCloseRef = useRef(false); const closePromptOpenRef = useRef(false); + const markDocumentSessionInvalid = useCallback((message: string) => { + documentSessionValidRef.current = false; + sessionInvalidReasonRef.current = message; + setDocumentSessionValid(false); + setSessionInvalidReason(message); + }, []); + + const handleOperationFailure = useCallback( + ( + response: Exclude, { kind: "success" }>, + fallback: string, + context: Parameters[2] = {}, + ) => { + const classification = classifyOperationFailure(response, fallback, context); + setErrorMessage(classification.message); + setStatusMessage(null); + if (classification.sessionInvalid) { + markDocumentSessionInvalid(classification.message); + } + return classification; + }, + [markDocumentSessionInvalid], + ); + + const handleStatusResponse = useCallback( + (response: EditorApiResult) => { + if (response.kind === "success") { + sidecarStateRef.current = response.result; + setSidecarState(response.result); + const classification = classifySidecarStatus(response.result); + if (classification.sessionInvalid && classification.message !== null) { + markDocumentSessionInvalid(classification.message); + setErrorMessage(classification.message); + setStatusMessage(null); + } + return; + } + + const classification = handleOperationFailure(response, "Sidecar status unavailable."); + if (classification.sessionInvalid) { + markDocumentSessionInvalid(classification.message); + } + }, + [handleOperationFailure, markDocumentSessionInvalid], + ); + + const confirmNativeAction = useCallback( + async (title: string, message: string, options: ConfirmActionOptions = {}) => { + promptActiveRef.current = true; + try { + return await nativeConfirm(message, { + title, + kind: options.destructive ? "warning" : "info", + }); + } catch { + return window.confirm(`${title}\n\n${message}`); + } finally { + promptActiveRef.current = false; + } + }, + [], + ); + + const confirmAction = useCallback( + (title: string, message: string, options: ConfirmActionOptions = {}): Promise => { + if (promptActiveRef.current) { + return Promise.resolve(false); + } + return confirmNativeAction(title, message, options); + }, + [confirmNativeAction], + ); + const actionAvailability = useMemo( () => buildActionAvailability({ @@ -308,7 +381,7 @@ export default function App() { disposed = true; cleanup?.(); }; - }, []); + }, [confirmAction]); useEffect(() => { let cancelled = false; @@ -341,7 +414,7 @@ export default function App() { return () => { cancelled = true; }; - }, []); + }, [handleOperationFailure, handleStatusResponse]); const menuHandlers: Record void> = { openRecipe: () => void openRecipe(), @@ -1065,46 +1138,6 @@ export default function App() { handleStatusResponse(response); } - function handleStatusResponse(response: EditorApiResult) { - if (response.kind === "success") { - sidecarStateRef.current = response.result; - setSidecarState(response.result); - const classification = classifySidecarStatus(response.result); - if (classification.sessionInvalid && classification.message !== null) { - markDocumentSessionInvalid(classification.message); - setErrorMessage(classification.message); - setStatusMessage(null); - } - return; - } - - const classification = handleOperationFailure(response, "Sidecar status unavailable."); - if (classification.sessionInvalid) { - markDocumentSessionInvalid(classification.message); - } - } - - function handleOperationFailure( - response: Exclude, { kind: "success" }>, - fallback: string, - context: Parameters[2] = {}, - ) { - const classification = classifyOperationFailure(response, fallback, context); - setErrorMessage(classification.message); - setStatusMessage(null); - if (classification.sessionInvalid) { - markDocumentSessionInvalid(classification.message); - } - return classification; - } - - function markDocumentSessionInvalid(message: string) { - documentSessionValidRef.current = false; - sessionInvalidReasonRef.current = message; - setDocumentSessionValid(false); - setSessionInvalidReason(message); - } - function showInvalidSessionMessage() { setErrorMessage(sessionInvalidReasonRef.current ?? invalidSessionMessage()); setStatusMessage(null); @@ -1170,31 +1203,6 @@ export default function App() { }); } - function confirmAction(title: string, message: string, options: ConfirmActionOptions = {}): Promise { - if (promptActiveRef.current) { - return Promise.resolve(false); - } - return confirmNativeAction(title, message, options); - } - - async function confirmNativeAction( - title: string, - message: string, - options: ConfirmActionOptions = {}, - ): Promise { - promptActiveRef.current = true; - try { - return await nativeConfirm(message, { - title, - kind: options.destructive ? "warning" : "info", - }); - } catch { - return window.confirm(`${title}\n\n${message}`); - } finally { - promptActiveRef.current = false; - } - } - function resolveTextPrompt(value: string | null) { if (textPrompt === null) { return; diff --git a/apps/config-editor/src/components/ResizableEditorLayout.tsx b/apps/config-editor/src/components/ResizableEditorLayout.tsx index 14cf360..c2565de 100644 --- a/apps/config-editor/src/components/ResizableEditorLayout.tsx +++ b/apps/config-editor/src/components/ResizableEditorLayout.tsx @@ -1,5 +1,7 @@ import { PointerEvent, ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { clampSidebarWidth, parseStoredSidebarWidth, type WidthClampOptions } from "./resizableEditorLayout.logic.js"; + const HANDLE_WIDTH = 8; const KEYBOARD_STEP = 16; @@ -15,14 +17,6 @@ interface ResizableEditorLayoutProps { minDetailWidth?: number; } -interface WidthClampOptions { - minSidebarWidth: number; - maxSidebarWidth: number; - containerWidth: number; - minDetailWidth: number; - handleWidth: number; -} - interface DragState { pointerId: number; startX: number; @@ -196,24 +190,6 @@ export function ResizableEditorLayout({ ); } -export function clampSidebarWidth(width: number, options: WidthClampOptions): number { - const finiteWidth = Number.isFinite(width) ? width : options.minSidebarWidth; - const sectionClamped = Math.min(Math.max(finiteWidth, options.minSidebarWidth), options.maxSidebarWidth); - if (options.containerWidth <= 0) { - return sectionClamped; - } - const maxWidthForDetail = Math.max(0, options.containerWidth - options.minDetailWidth - options.handleWidth); - return Math.min(sectionClamped, maxWidthForDetail); -} - -export function parseStoredSidebarWidth(value: string | null): number | null { - if (value === null) { - return null; - } - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; -} - function readStoredSidebarWidth( storageKey: string, fallbackWidth: number, diff --git a/apps/config-editor/src/components/resizableEditorLayout.logic.ts b/apps/config-editor/src/components/resizableEditorLayout.logic.ts new file mode 100644 index 0000000..a2602fb --- /dev/null +++ b/apps/config-editor/src/components/resizableEditorLayout.logic.ts @@ -0,0 +1,25 @@ +export interface WidthClampOptions { + minSidebarWidth: number; + maxSidebarWidth: number; + containerWidth: number; + minDetailWidth: number; + handleWidth: number; +} + +export function clampSidebarWidth(width: number, options: WidthClampOptions): number { + const finiteWidth = Number.isFinite(width) ? width : options.minSidebarWidth; + const sectionClamped = Math.min(Math.max(finiteWidth, options.minSidebarWidth), options.maxSidebarWidth); + if (options.containerWidth <= 0) { + return sectionClamped; + } + const maxWidthForDetail = Math.max(0, options.containerWidth - options.minDetailWidth - options.handleWidth); + return Math.min(sectionClamped, maxWidthForDetail); +} + +export function parseStoredSidebarWidth(value: string | null): number | null { + if (value === null) { + return null; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} diff --git a/apps/config-editor/tests/resizableEditorLayout.logic.test.ts b/apps/config-editor/tests/resizableEditorLayout.logic.test.ts new file mode 100644 index 0000000..0661ae4 --- /dev/null +++ b/apps/config-editor/tests/resizableEditorLayout.logic.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { clampSidebarWidth, parseStoredSidebarWidth } from "../src/components/resizableEditorLayout.logic.js"; + +function clampOptions(overrides: Partial<{ + minSidebarWidth: number; + maxSidebarWidth: number; + containerWidth: number; + minDetailWidth: number; + handleWidth: number; +}> = {}) { + return { + minSidebarWidth: 100, + maxSidebarWidth: 300, + containerWidth: 0, + minDetailWidth: 360, + handleWidth: 8, + ...overrides, + }; +} + +test("clampSidebarWidth keeps in-range widths unchanged", () => { + assert.equal(clampSidebarWidth(200, clampOptions()), 200); + assert.equal(clampSidebarWidth(100, clampOptions()), 100); + assert.equal(clampSidebarWidth(300, clampOptions()), 300); +}); + +test("clampSidebarWidth clamps to the configured bounds", () => { + assert.equal(clampSidebarWidth(50, clampOptions()), 100); + assert.equal(clampSidebarWidth(400, clampOptions()), 300); +}); + +test("clampSidebarWidth falls back to the minimum for non-finite widths", () => { + assert.equal(clampSidebarWidth(Number.NaN, clampOptions()), 100); + assert.equal(clampSidebarWidth(Number.POSITIVE_INFINITY, clampOptions()), 100); +}); + +test("clampSidebarWidth applies the detail-width constraint with a measured container", () => { + const options = clampOptions({ containerWidth: 500 }); + assert.equal(clampSidebarWidth(250, options), 132); + assert.equal(clampSidebarWidth(120, options), 120); +}); + +test("clampSidebarWidth skips the detail-width constraint without a measured container", () => { + assert.equal(clampSidebarWidth(250, clampOptions()), 250); +}); + +test("clampSidebarWidth floors the detail-width constraint at zero", () => { + const options = clampOptions({ containerWidth: 360 }); + assert.equal(clampSidebarWidth(150, options), 0); +}); + +test("parseStoredSidebarWidth accepts finite numeric strings", () => { + assert.equal(parseStoredSidebarWidth("320"), 320); + assert.equal(parseStoredSidebarWidth("12.5"), 12.5); +}); + +test("parseStoredSidebarWidth rejects null and non-finite values", () => { + assert.equal(parseStoredSidebarWidth(null), null); + assert.equal(parseStoredSidebarWidth("abc"), null); + assert.equal(parseStoredSidebarWidth("Infinity"), null); + assert.equal(parseStoredSidebarWidth("NaN"), null); +});