Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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' \
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/config-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
144 changes: 76 additions & 68 deletions apps/config-editor/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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(
<T,>(
response: Exclude<EditorApiResult<T>, { kind: "success" }>,
fallback: string,
context: Parameters<typeof classifyOperationFailure>[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<SidecarStatusResult>) => {
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<boolean> => {
if (promptActiveRef.current) {
return Promise.resolve(false);
}
return confirmNativeAction(title, message, options);
},
[confirmNativeAction],
);

const actionAvailability = useMemo(
() =>
buildActionAvailability({
Expand Down Expand Up @@ -308,7 +381,7 @@ export default function App() {
disposed = true;
cleanup?.();
};
}, []);
}, [confirmAction]);

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -341,7 +414,7 @@ export default function App() {
return () => {
cancelled = true;
};
}, []);
}, [handleOperationFailure, handleStatusResponse]);

const menuHandlers: Record<MenuAction, () => void> = {
openRecipe: () => void openRecipe(),
Expand Down Expand Up @@ -1065,46 +1138,6 @@ export default function App() {
handleStatusResponse(response);
}

function handleStatusResponse(response: EditorApiResult<SidecarStatusResult>) {
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<T>(
response: Exclude<EditorApiResult<T>, { kind: "success" }>,
fallback: string,
context: Parameters<typeof classifyOperationFailure>[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);
Expand Down Expand Up @@ -1170,31 +1203,6 @@ export default function App() {
});
}

function confirmAction(title: string, message: string, options: ConfirmActionOptions = {}): Promise<boolean> {
if (promptActiveRef.current) {
return Promise.resolve(false);
}
return confirmNativeAction(title, message, options);
}

async function confirmNativeAction(
title: string,
message: string,
options: ConfirmActionOptions = {},
): Promise<boolean> {
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;
Expand Down
28 changes: 2 additions & 26 deletions apps/config-editor/src/components/ResizableEditorLayout.tsx
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions apps/config-editor/src/components/resizableEditorLayout.logic.ts
Original file line number Diff line number Diff line change
@@ -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;
}
64 changes: 64 additions & 0 deletions apps/config-editor/tests/resizableEditorLayout.logic.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading