推进 INI 面板 control-page refresh workflow helper

This commit is contained in:
2026-06-15 04:39:08 +08:00
parent 213514f64c
commit ed5d03ea1b
13 changed files with 379 additions and 13 deletions

View File

@@ -1822,3 +1822,78 @@ host_wasm_opfs_browser_smokes=ok
继续推进 OPFS/session persistence 或 browser/UI workflow 的实质链路。下一批建议在
`app.js` 外抽出一个 control-page snapshot refresh workflow helper让 read-only 页面能复用
明确的 refresh 状态模型;仍保持只读,不新增控制按钮,不解析 G-code。
二十四、2026-06-15 继续执行记录INI panel control-page refresh workflow helper
本轮按“第一原则:加快实质性推进”推进 browser/UI workflow在 `app.js` 外新增
read-only control-page refresh workflow helper让外层页面可复用明确的 refresh 状态模型。
完成内容:
- 新增 `wasm-port/runtime/ui/ini-panel/control-page-refresh-workflow.js`
- 新增 `controlPageStatusText(status)`,格式化已观察到的 control view status 字段;
- 新增 `createControlPageRefreshState(input)`,固定 refresh 状态结构;
- 新增 `refreshMachineSessionControlPage(input)`,统一处理:
- `waiting`panel API 尚不可用;
- `rendered`:读取 control view 并调用 renderer
- `error`control view getter 或 renderer 抛错;
- `createMachineSessionControlPageController()` 改为通过 refresh workflow 执行 render
- control-page controller 新增 `refresh()`,保留原 `render()` 返回值兼容;
- `control-page.js` 对浏览器页面暴露 `refreshControlPage`
- `ui-shell.js` re-export refresh workflow helper并在 `createIniPanelShellViewModel()` 中暴露
`controlPage.refresh`
- 新增 `wasm-port/tests/ui/node/verify_ini_panel_control_page_refresh_workflow.mjs` 和 `.sh`
- `verify_ui_node_smokes.sh` 纳入 refresh workflow smoke
- browser control-page smoke 验证 `refreshControlPage().phase === "rendered"`
- `panel-entry.md` 和 entry docs gate 同步记录 refresh workflow API
- 未新增控制按钮;
- 未解析 G-code
- 未解释 canonical events
- 未改变 OPFS/session persistence、LinuxCNC interpreter、tool、parameter、planner 或
LinuxCNC-owned runtime semantics。
验证已通过:
```bash
git diff --check
wasm-port/tests/ui/node/verify_ini_panel_control_page_refresh_workflow.sh
wasm-port/tests/ui/node/verify_ini_panel_control_page_controller.sh
wasm-port/tests/ui/node/verify_ini_panel_ui_shell.sh
wasm-port/tests/ui/node/verify_ini_panel_entry_docs.sh
wasm-port/tests/ui/node/verify_ui_node_smokes.sh
SKIP_INI_BUILD=1 SKIP_INTERP_BUILD=1 wasm-port/tests/browser/verify_ini_panel_browser.sh
wasm-port/tools/verify_vendor_sync.sh
wasm-port/tools/verify_no_standalone_cnc_semantics.sh
SKIP_INTERP_BUILD=1 wasm-port/tests/wasm/node/verify_interp_wasm.sh
SKIP_INTERP_BUILD=1 wasm-port/tests/wasm/node/verify_sim_configs_inventory_wasm.sh
wasm-port/tests/host/verify_host_smokes.sh
```
关键输出:
```text
ini_panel_control_page_refresh_workflow_node_smoke=ok
ini_panel_control_page_controller_node_smoke=ok
ini_panel_ui_shell_node_smoke=ok
ini_panel_entry_docs_node_smoke=ok
ui_node_smokes=ok
browser_ini_opfs_smoke=ok
browser_ini_control_page_smoke=ok
browser_ini_launch_smoke=ok
vendor sync up to date
standalone CNC semantics guard complete
interp_wasm_node_smoke=ok
sim_configs_wasm_node_inventory_executed=28
sim_configs_wasm_node_inventory_passed=28
sim_configs_wasm_node_inventory_skipped=131
sim_configs_wasm_node_inventory_unexpected_fail=0
browser_interp_smoke=ok
host_wasm_opfs_browser_smokes=ok
```
下一步建议:
继续推进 OPFS/session persistence 的实质链路。下一批建议抽出
`loadMachineSessionForControlPageWorkflow()` 或等价 helper让 read-only control page 能以明确
状态模型描述 embedded panel API readiness 和首帧 session state仍保持只读不新增控制按钮
不解析 G-code。

View File

@@ -15,6 +15,8 @@ import {
getIniPanelEntryByHref,
getVisibleIniPanelEntries,
createMachineSessionControlPageController,
refreshMachineSessionControlPage,
controlPageStatusText,
renderMachineSessionControlView,
} from "../runtime/ui/ini-panel/ui-shell.js";
```
@@ -45,7 +47,15 @@ and `label`.
Outer shells can call `createIniPanelShellViewModel()` when they need one
read-only model with `pages`, `launcherLinks`, and `controlPage` helpers for
mounting the existing control-page controller and renderer.
mounting the existing control-page controller, refresh workflow, and renderer.
The control-page refresh workflow lives in
`runtime/ui/ini-panel/control-page-refresh-workflow.js`. Use
`refreshMachineSessionControlPage()` when an outer shell needs a structured
refresh result with `phase`, `statusText`, `view`, `renderResult`, and `error`.
`controlPageStatusText()` formats the already-observed panel status fields for
the read-only status line, and `createControlPageRefreshState()` is the stable
state-shape helper used by the workflow.
## Boundary

View File

@@ -1,3 +1,7 @@
import {
refreshMachineSessionControlPage,
} from "./control-page-refresh-workflow.js";
export function createMachineSessionControlPageController({
panelFrame,
statusNode,
@@ -34,19 +38,16 @@ export function createMachineSessionControlPageController({
}
function render() {
const view = getControlView();
if (!view) {
statusNode.textContent = "waiting for panel";
viewNode.textContent = "";
return null;
}
return refresh().renderResult;
}
statusNode.textContent = [
view.status.lastAction ?? "-",
view.status.runStatus ?? "-",
view.status.error ?? "-",
].join(" | ");
return renderControlView(viewNode, view);
function refresh() {
return refreshMachineSessionControlPage({
getControlView,
statusNode,
viewNode,
renderControlView,
});
}
function startPolling() {
@@ -72,6 +73,7 @@ export function createMachineSessionControlPageController({
getPanelApi,
getPanelWindow,
mount,
refresh,
render,
startPolling,
};

View File

@@ -0,0 +1,89 @@
export function controlPageStatusText(status = {}) {
return [
status.lastAction ?? "-",
status.runStatus ?? "-",
status.error ?? "-",
].join(" | ");
}
export function createControlPageRefreshState({
phase,
statusText,
view = null,
renderResult = null,
error = null,
} = {}) {
return {
phase,
statusText,
view,
renderResult,
error,
};
}
export function refreshMachineSessionControlPage({
getControlView,
statusNode,
viewNode,
renderControlView,
} = {}) {
if (typeof getControlView !== "function") {
throw new Error("A control view getter is required.");
}
if (!statusNode) {
throw new Error("A control page status node is required.");
}
if (!viewNode) {
throw new Error("A control page view node is required.");
}
if (typeof renderControlView !== "function") {
throw new Error("A control view renderer is required.");
}
let view = null;
try {
view = getControlView() ?? null;
} catch (error) {
const statusText = `refresh failed | - | ${error.message}`;
statusNode.textContent = statusText;
viewNode.textContent = "";
return createControlPageRefreshState({
phase: "error",
statusText,
error: error.message,
});
}
if (!view) {
const statusText = "waiting for panel";
statusNode.textContent = statusText;
viewNode.textContent = "";
return createControlPageRefreshState({
phase: "waiting",
statusText,
});
}
const statusText = controlPageStatusText(view.status);
statusNode.textContent = statusText;
try {
const renderResult = renderControlView(viewNode, view);
return createControlPageRefreshState({
phase: "rendered",
statusText,
view,
renderResult,
});
} catch (error) {
const errorStatusText = `refresh failed | - | ${error.message}`;
statusNode.textContent = errorStatusText;
viewNode.textContent = "";
return createControlPageRefreshState({
phase: "error",
statusText: errorStatusText,
view,
error: error.message,
});
}
}

View File

@@ -21,5 +21,6 @@ controller.mount();
window.linuxCncIniPanelControlPageApi = {
getControlView: controller.getControlView,
getPanelApi: controller.getPanelApi,
refreshControlPage: controller.refresh,
renderControlPage: controller.render,
};

View File

@@ -9,6 +9,16 @@ import {
import {
renderMachineSessionControlView,
} from "./control-view-renderer.js";
import {
controlPageStatusText,
createControlPageRefreshState,
refreshMachineSessionControlPage,
} from "./control-page-refresh-workflow.js";
export {
controlPageStatusText,
createControlPageRefreshState,
refreshMachineSessionControlPage,
} from "./control-page-refresh-workflow.js";
export {
INI_PANEL_ENTRIES,
getIniPanelEntryByHref,
@@ -43,6 +53,7 @@ export function createIniPanelShellViewModel(entries = getVisibleIniPanelEntries
launcherLinks: createIniPanelLauncherLinkViewModel(entries),
controlPage: {
createController: createMachineSessionControlPageController,
refresh: refreshMachineSessionControlPage,
renderView: renderMachineSessionControlView,
},
};

View File

@@ -64,6 +64,7 @@ TOOL_TABLE = browser-tool.tbl
if (
!controlPageApi?.getControlView ||
!controlPageApi?.getPanelApi ||
!controlPageApi?.refreshControlPage ||
!controlPageApi?.renderControlPage
) {
throw new Error("control page API namespace did not expose control helpers");
@@ -82,6 +83,11 @@ TOOL_TABLE = browser-tool.tbl
() => controlPageApi.getControlView(),
"control page API view",
);
assertEqual(
controlPageApi.refreshControlPage().phase,
"rendered",
"control page refresh phase",
);
panelDoc.getElementById("ini-editor").value = iniText;
panelDoc.getElementById("query").click();

View File

@@ -3,6 +3,9 @@ import assert from "node:assert/strict";
import {
createMachineSessionControlPageController,
} from "../../../runtime/ui/ini-panel/control-page-controller.js";
import {
controlPageStatusText,
} from "../../../runtime/ui/ini-panel/control-page-refresh-workflow.js";
function createPanelFrame(panelApi = null) {
const listeners = new Map();
@@ -75,6 +78,9 @@ const renderResult = controller.render();
assert.deepEqual(renderResult, { statusNode: { textContent: "rendered" } });
assert.equal(statusNode.textContent, "run-gcode | ok | -");
assert.deepEqual(rendered, [{ container: viewNode, view: expectedView }]);
const refreshResult = controller.refresh();
assert.equal(refreshResult.phase, "rendered");
assert.equal(refreshResult.statusText, controlPageStatusText(expectedView.status));
const mountedFrame = createPanelFrame(panelApi);
const mountedStatusNode = { textContent: "" };

View File

@@ -0,0 +1,126 @@
import assert from "node:assert/strict";
import {
controlPageStatusText,
createControlPageRefreshState,
refreshMachineSessionControlPage,
} from "../../../runtime/ui/ini-panel/control-page-refresh-workflow.js";
const expectedView = {
status: {
lastAction: "run-gcode",
runStatus: "ok",
error: null,
},
sections: [
{
id: "run",
rows: [
{ label: "Canonical events", value: 2 },
],
},
],
};
assert.equal(controlPageStatusText(expectedView.status), "run-gcode | ok | -");
assert.deepEqual(createControlPageRefreshState({ phase: "waiting", statusText: "waiting for panel" }), {
phase: "waiting",
statusText: "waiting for panel",
view: null,
renderResult: null,
error: null,
});
const waitingStatusNode = { textContent: "" };
const waitingViewNode = { textContent: "previous" };
assert.deepEqual(
refreshMachineSessionControlPage({
getControlView: () => null,
statusNode: waitingStatusNode,
viewNode: waitingViewNode,
renderControlView: () => "unused",
}),
{
phase: "waiting",
statusText: "waiting for panel",
view: null,
renderResult: null,
error: null,
},
);
assert.equal(waitingStatusNode.textContent, "waiting for panel");
assert.equal(waitingViewNode.textContent, "");
const renderedStatusNode = { textContent: "" };
const renderedViewNode = { textContent: "" };
let renderedInput = null;
assert.deepEqual(
refreshMachineSessionControlPage({
getControlView: () => expectedView,
statusNode: renderedStatusNode,
viewNode: renderedViewNode,
renderControlView: (container, view) => {
renderedInput = { container, view };
return { rendered: true };
},
}),
{
phase: "rendered",
statusText: "run-gcode | ok | -",
view: expectedView,
renderResult: { rendered: true },
error: null,
},
);
assert.deepEqual(renderedInput, { container: renderedViewNode, view: expectedView });
assert.equal(renderedStatusNode.textContent, "run-gcode | ok | -");
const getterErrorStatusNode = { textContent: "" };
const getterErrorViewNode = { textContent: "previous" };
assert.deepEqual(
refreshMachineSessionControlPage({
getControlView: () => {
throw new Error("panel unavailable");
},
statusNode: getterErrorStatusNode,
viewNode: getterErrorViewNode,
renderControlView: () => "unused",
}),
{
phase: "error",
statusText: "refresh failed | - | panel unavailable",
view: null,
renderResult: null,
error: "panel unavailable",
},
);
assert.equal(getterErrorViewNode.textContent, "");
const renderErrorStatusNode = { textContent: "" };
const renderErrorViewNode = { textContent: "previous" };
assert.deepEqual(
refreshMachineSessionControlPage({
getControlView: () => expectedView,
statusNode: renderErrorStatusNode,
viewNode: renderErrorViewNode,
renderControlView: () => {
throw new Error("renderer unavailable");
},
}),
{
phase: "error",
statusText: "refresh failed | - | renderer unavailable",
view: expectedView,
renderResult: null,
error: "renderer unavailable",
},
);
assert.equal(renderErrorStatusNode.textContent, "refresh failed | - | renderer unavailable");
assert.equal(renderErrorViewNode.textContent, "");
assert.throws(
() => refreshMachineSessionControlPage(),
/control view getter/,
);
console.log("ini_panel_control_page_refresh_workflow_node_smoke=ok");

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../../.." && pwd)"
node "$ROOT_DIR/tests/ui/node/verify_ini_panel_control_page_refresh_workflow.mjs"

View File

@@ -5,8 +5,10 @@ import { dirname, resolve } from "node:path";
import {
INI_PANEL_ENTRIES,
controlPageStatusText,
createIniPanelLauncherLinkViewModel,
createIniPanelShellViewModel,
refreshMachineSessionControlPage,
getVisibleIniPanelEntries,
} from "../../../runtime/ui/ini-panel/ui-shell.js";
@@ -14,14 +16,20 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, "../../..");
const docText = readFileSync(resolve(root, "docs/panel-entry.md"), "utf8");
const shellText = readFileSync(resolve(root, "runtime/ui/ini-panel/ui-shell.js"), "utf8");
const refreshWorkflowText = readFileSync(
resolve(root, "runtime/ui/ini-panel/control-page-refresh-workflow.js"),
"utf8",
);
const requiredShellExports = [
"INI_PANEL_ENTRIES",
"controlPageStatusText",
"createIniPanelLauncherLinkViewModel",
"createIniPanelShellViewModel",
"getIniPanelEntryByHref",
"getVisibleIniPanelEntries",
"createMachineSessionControlPageController",
"refreshMachineSessionControlPage",
"renderMachineSessionControlView",
];
@@ -30,6 +38,15 @@ for (const exportName of requiredShellExports) {
assert.match(docText, new RegExp(`\\b${exportName}\\b`));
}
for (const exportName of [
"controlPageStatusText",
"createControlPageRefreshState",
"refreshMachineSessionControlPage",
]) {
assert.match(refreshWorkflowText, new RegExp(`\\bexport function ${exportName}\\b`));
assert.match(docText, new RegExp(`\\b${exportName}\\b`));
}
const requiredPages = [
["Launch", "runtime/ui/ini-panel/launch.html"],
["Edit and run", "runtime/ui/ini-panel/index.html"],
@@ -51,7 +68,9 @@ assert.deepEqual(
);
assert.deepEqual(createIniPanelShellViewModel().pages, getVisibleIniPanelEntries());
assert.deepEqual(createIniPanelShellViewModel().launcherLinks, createIniPanelLauncherLinkViewModel());
assert.equal(controlPageStatusText({ lastAction: "run-gcode", runStatus: "ok", error: null }), "run-gcode | ok | -");
assert.equal(typeof createIniPanelShellViewModel().controlPage.createController, "function");
assert.equal(createIniPanelShellViewModel().controlPage.refresh, refreshMachineSessionControlPage);
assert.equal(typeof createIniPanelShellViewModel().controlPage.renderView, "function");
for (const entry of INI_PANEL_ENTRIES) {

View File

@@ -2,19 +2,31 @@ import assert from "node:assert/strict";
import {
INI_PANEL_ENTRIES,
controlPageStatusText,
createIniPanelLauncherLinkViewModel,
createIniPanelShellViewModel,
createControlPageRefreshState,
createMachineSessionControlPageController,
getIniPanelEntryByHref,
getIniPanelEntryManifest,
getVisibleIniPanelEntries,
refreshMachineSessionControlPage,
renderMachineSessionControlView,
} from "../../../runtime/ui/ini-panel/ui-shell.js";
assert.deepEqual(getIniPanelEntryManifest(), INI_PANEL_ENTRIES);
assert.deepEqual(getVisibleIniPanelEntries(), INI_PANEL_ENTRIES);
assert.equal(controlPageStatusText({ lastAction: "x", runStatus: "ok", error: null }), "x | ok | -");
assert.deepEqual(createControlPageRefreshState({ phase: "waiting", statusText: "waiting for panel" }), {
phase: "waiting",
statusText: "waiting for panel",
view: null,
renderResult: null,
error: null,
});
assert.equal(getIniPanelEntryByHref("./index.html")?.id, "edit-run");
assert.equal(typeof createMachineSessionControlPageController, "function");
assert.equal(typeof refreshMachineSessionControlPage, "function");
assert.equal(typeof renderMachineSessionControlView, "function");
const shellManifest = getIniPanelEntryManifest();
@@ -53,6 +65,7 @@ assert.deepEqual(shellViewModel.pages, [
]);
assert.deepEqual(shellViewModel.launcherLinks, createIniPanelLauncherLinkViewModel());
assert.equal(shellViewModel.controlPage.createController, createMachineSessionControlPageController);
assert.equal(shellViewModel.controlPage.refresh, refreshMachineSessionControlPage);
assert.equal(shellViewModel.controlPage.renderView, renderMachineSessionControlView);
shellViewModel.pages[0].title = "changed";
shellViewModel.launcherLinks[0].label = "changed";
@@ -89,5 +102,6 @@ const controller = createMachineSessionControlPageController({
assert.deepEqual(controller.render(), { rendered: true });
assert.equal(statusNode.textContent, "run-gcode | ok | -");
assert.equal(renderedView.status.runStatus, "ok");
assert.equal(controller.refresh().phase, "rendered");
console.log("ini_panel_ui_shell_node_smoke=ok");

View File

@@ -10,6 +10,7 @@ ROOT_DIR="$(cd "$(dirname "$0")/../../.." && pwd)"
"$ROOT_DIR/tests/ui/node/verify_ini_panel_machine_file_summary.sh"
"$ROOT_DIR/tests/ui/node/verify_ini_panel_state_summary.sh"
"$ROOT_DIR/tests/ui/node/verify_ini_panel_control_page_controller.sh"
"$ROOT_DIR/tests/ui/node/verify_ini_panel_control_page_refresh_workflow.sh"
"$ROOT_DIR/tests/ui/node/verify_ini_panel_entry_manifest.sh"
"$ROOT_DIR/tests/ui/node/verify_ini_panel_ui_shell.sh"
"$ROOT_DIR/tests/ui/node/verify_ini_panel_entry_docs.sh"