Restore default template binding across browser sessions

This commit is contained in:
2026-10-10 10:51:59 +08:00
parent 608229a497
commit 5db283c05a
20 changed files with 334 additions and 8 deletions
@@ -0,0 +1,22 @@
# 默认画面保存绑定修复
本轮只补齐设备网页的保存绑定与保存选项,正式版本保持 1.1.3,功能更新时间为 `2026-10-10T10:18+08:00`。用户静态模板可以覆盖;演示静态图和演示动图第一帧的“保存到当前模板”为黑色受限态,只能另存。新设备开机会话载入默认画面,同次开机刷新保留草稿、原修订与保存基线;用户动图仍使用原逐帧流程。
## 验证证据
- 原工作区完整本机 `all` 套件通过,包括 Python、Node、静态 JavaScript 语法和文案目录校验;新增模板保存模型行为测试 6 项通过。
- 实际浏览器验证了另存、覆盖、取消、刷新绑定、默认引用不变、新开机会话、两个演示案例的黑色受限按钮、`409` 冲突、删除后的 `404`、读取失败、未来版本草稿、初始化期间编辑和多标签外部草稿绑定。用户动图实际保存帧返回 `200`,退出后恢复普通画布绑定。桌面和手机保存弹窗截图为隔离 mock 数据,见本目录 PNG。
- 初次隔离副本缺少内核依赖及内核源码材料,依赖完整性测试失败;补齐这些离线材料后重新验证。不复制、检查或重建历史 IMG。
- 初次隔离测试还出现一次 `app/persistence.py` 原子替换的 `PermissionError / WinError 5`,导致动图 API 错误响应;独立定向重测及下一轮完整测试均未复现该项。没有足够证据确定其系统根因,没有为此修改动画或生产写入保护。
- 补齐材料后的隔离全量 Python 结果为 505 通过、10 跳过、1 失败;失败项为 OTA 故障注入测试的目录 rename,同样返回 `WinError 5`,定向重测 1 项通过。隔离前端 84 项全部通过,全部静态 JavaScript 语法及文案目录校验通过。隔离环境从文档入口重新创建 venv 和安装依赖,没有复用原工作区环境;本轮 Windows 间歇性文件访问失败的根因仍未确认,不把这一轮全量运行写成零失败。
- 板端通过现有 `update_walnutpi.sh` 原子更新:全部依赖离线安装,原生 bitplane 与 fake safeoff 测试通过,Python 509 项通过、7 项按平台/应用载荷边界跳过;生产切换和服务 restart 健康检查通过,真实驱动为 `walnutpi-h618-hub75`。9 个安装后的实现/版本文件与本机源码摘要一致。
- 生产网页临时模板覆盖返回 `200`,保留 ID、名称和创建时间,刷新后恢复保存绑定;用户默认引用不变,临时模板删除返回 `200`。当前设备默认为用户动图,沿用现有逐帧流程;两个演示案例的黑色受限状态在隔离 mock 中验证,没有为此切换设备默认或实际画面。
- 部署前后全部 430 个持久文件数量和 SHA-256 一致,用户配置、模板、动图、字体等内容未被修改。汇总证据见 `验收结果.json`,未保存实际凭据或设备标识。
- 远端 `/var/tmp/qms-default-save-20261010` 已清理,更新脚本的 staging、候选数据、回滚数据和测试运行目录均不存在;当前正式运行程序及其 venv 保留。本轮本机 mock 服务、SSH 验收隧道和专用浏览器已停止。本机临时目录的递归删除被工具安全策略拒绝(`blocked by policy`),未换手段绕过。
- 可手动删除 `测试相关资料/如何测试/本机测试环境/.local/work/default-save-20261010/`:仅含本轮测试副本及其重建 venv、mock 数据、专用浏览器状态、上传包、中间日志和摘要;交付证据已保存到本归档。该目录不包含生产数据或长期复用的 SSH/本机测试环境,不要连带删除上级 `.local/`。
## 可重复经验
稳定验收条目和保存交互经验已加入 `测试相关资料/如何测试/核桃派点阵屏控制服务测试流程.md` 的默认画面保存绑定小节;隔离依赖边界及 Windows 文件替换诊断记录在本机测试环境 README。跨端兼容登记说明 REST、BLE、scene v2、配置和驱动契约不变,无需 App 更新。
本次实屏视觉验收不适用,没有摄像头、接线、调压或测试图案操作。真实断电开机的行为通过 mock 开机会话验证;板端采用服务重启验收,不将其写成物理断电验证。
@@ -0,0 +1,19 @@
# Windows 隔离测试失败证据摘要
以下调用和错误类型来自本轮实际测试日志;省略本机绝对路径及无关依赖安装日志,完整结果计数见验收结果 JSON。
## 首轮
测试:`tests/test_animations.py::test_default_animation_delete_and_last_frame_delete_fall_back`。
第二个用户动图添加帧时,`app/animations/store.py::_write_at` 调用 `app/persistence.py::atomic_write_many_bytes`,`os.replace(temporaries[target], target)` 返回 `PermissionError: [WinError 5]`。API 返回错误响应后,测试访问 `second["id"]` 产生 `KeyError: 'id'`。独立定向运行和下一轮全量运行未复现该项;没有确认其系统根因。
同轮另有内核依赖完整性测试失败,原因是隔离副本没有复制该测试要求的内核载荷和源码归档;补齐项目内登记的原材料后该项通过。
## 补齐材料后的全量运行
测试:`tests/test_ota_checkpoints.py::test_power_loss_between_data_renames_recovers_original`。
测试中的 `c.DATA.rename(backup)` 经 `pathlib.Path.rename` 调用 `os.rename`,返回 `PermissionError: [WinError 5]`,尚未进入恢复断言。该轮为 505 通过、10 跳过、1 失败;使用独立测试目录定向重测该项,结果 `1 passed in 0.06s`。没有修改 OTA、动画、文件写入保护或测试断言来掩盖失败。
上述现象仍保留为本机间歇性文件访问边界,不能仅凭重测通过推断具体原因。板端 Linux 原子部署验证为 509 通过、7 跳过,生产保存接口和数据保留验证通过。
@@ -0,0 +1,16 @@
{
"staticOverwrite": true,
"refreshBinding": true,
"defaultReferencePreserved": true,
"demoStaticBlack": true,
"demoAnimationBlack": true,
"conflict409": true,
"deleted404": true,
"readFailurePreservesDraft": true,
"futureDraftUntouched": true,
"initializationEditRace": true,
"newBootReload": true,
"mobileNoOverflow": true,
"externalDraftBinding": true,
"animationFrameSaveAndRestore": true
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

@@ -0,0 +1,38 @@
{
"deployment": {
"result": "success",
"native_unit": "passed",
"python_passed": 509,
"python_skipped": 7,
"restart_health": "passed"
},
"production_browser": {
"featureUpdated": "2026-10-10T10:18+08:00",
"version": "1.1.3",
"bootMarkerPresent": true,
"defaultType": "animation",
"defaultIsDemo": false,
"overwriteStatus": 200,
"refreshRestoresBinding": true,
"keepsTemplateIdentity": true,
"defaultUnchanged": true,
"temporaryDeleted": true
},
"persistent_files": 430,
"persistent_contents_unchanged": true,
"installed_source_matches": true,
"local_original_all": "passed",
"isolated_python_full": {
"passed": 505,
"skipped": 10,
"failed": 1,
"failed_case": "test_power_loss_between_data_renames_recovers_original",
"failure": "Windows WinError 5 directory rename",
"targeted_retest": "1 passed"
},
"isolated_frontend": {
"passed": 84,
"failed": 0
},
"payload_sha256": "84527ee7b383c6bacdc22e3b4ab74ef05ad3fa7b95582c07d0a24efb277e9385"
}
@@ -400,6 +400,7 @@ WantedBy=multi-user.target
- 静态模板缩略图必须由核桃派后端按场景顺序生成原生 `64x64 PNG`;动图卡片复用各帧同规格 PNG,按完整帧顺序和原始 `duration_ms` 在普通 `<img>` 中切换,不得抽帧、截断或在浏览器重新合成场景。动态缩略图只在当前可见工作区和视口内运行,页面隐藏、离开视口、启用减少动态效果或加载失败时必须安全暂停或回退,不得影响真实屏幕输出。 - 静态模板缩略图必须由核桃派后端按场景顺序生成原生 `64x64 PNG`;动图卡片复用各帧同规格 PNG,按完整帧顺序和原始 `duration_ms` 在普通 `<img>` 中切换,不得抽帧、截断或在浏览器重新合成场景。动态缩略图只在当前可见工作区和视口内运行,页面隐藏、离开视口、启用减少动态效果或加载失败时必须安全暂停或回退,不得影响真实屏幕输出。
- 静态模板“播放”直接显示该模板并结束活动动图,但不得替换当前浏览器草稿或修改默认显示引用;动图“播放”保持相同的编辑解耦语义。静态模板“编辑”才读取完整 scene 并进入像素画布,且不得自动更新真实屏幕。 - 静态模板“播放”直接显示该模板并结束活动动图,但不得替换当前浏览器草稿或修改默认显示引用;动图“播放”保持相同的编辑解耦语义。静态模板“编辑”才读取完整 scene 并进入像素画布,且不得自动更新真实屏幕。
- 普通画布记录当前绑定模板、修订值和最近保存的 scene 基线。未绑定的新草稿,或绑定模板后相对基线发生修改,均视为未保存;静态编辑将替换该画布时必须提示“当前内容将会丢失”,取消不得读取或切换目标。干净的已保存模板可以直接切换。动图帧编辑继续使用独立的未保存修改守卫,并完整保存、恢复普通画布及其模板编辑状态。 - 普通画布记录当前绑定模板、修订值和最近保存的 scene 基线。未绑定的新草稿,或绑定模板后相对基线发生修改,均视为未保存;静态编辑将替换该画布时必须提示“当前内容将会丢失”,取消不得读取或切换目标。干净的已保存模板可以直接切换。动图帧编辑继续使用独立的未保存修改守卫,并完整保存、恢复普通画布及其模板编辑状态。
- `WEB-DEFAULT-DISPLAY`、`WEB-TEMPLATES`:浏览器识别新的设备开机会话后载入默认静态模板及其编辑绑定;同次开机刷新恢复本地草稿和对应绑定,保存可以覆盖当前模板或另存。两个演示案例均提供保存选择,“保存到当前模板”为黑色受限态,只能另存;演示动图以第一帧作为可另存的静态编辑画面,来源仍为只读动图,不能误写静态模板 API。用户动图沿用现有逐帧编辑保存流程。绑定信息独立于 scene v2 和设备配置,保留原修订与基线以识别未保存修改和并发冲突;读取失败、草稿损坏或初始化期间发生编辑不得覆盖现有草稿。
- 编辑普通静态模板时,页面顶部提供单个“保存模板”入口,点击后选择“保存到当前模板”“另存为新模板”或取消。保存当前模板保留 ID、名称和创建时间并更新修改时间、内容摘要、缩略图及修订值;另存生成独立 ID 和唯一名称并把编辑上下文绑定到新模板。普通未绑定草稿沿用“保存为模板”;演示静态图的当前模板保存受限,只能另存。保存成功更新基线,失败或冲突保留当前画布和未保存状态。 - 编辑普通静态模板时,页面顶部提供单个“保存模板”入口,点击后选择“保存到当前模板”“另存为新模板”或取消。保存当前模板保留 ID、名称和创建时间并更新修改时间、内容摘要、缩略图及修订值;另存生成独立 ID 和唯一名称并把编辑上下文绑定到新模板。普通未绑定草稿沿用“保存为模板”;演示静态图的当前模板保存受限,只能另存。保存成功更新基线,失败或冲突保留当前画布和未保存状态。
- 删除需要确认,并删除模板及其所有有效、过期缩略图。页面同时显示单模板占用、模板总占用和模板目录所在文件系统的总/已用/可用空间。 - 删除需要确认,并删除模板及其所有有效、过期缩略图。页面同时显示单模板占用、模板总占用和模板目录所在文件系统的总/已用/可用空间。
- 模板管理固定把两个随程序发布的只读演示案例放在所有用户内容之前:第一个为“演示静态图”,内容与开机笑脸睁眼帧完全相同;第二个为“演示动图”,按开机笑脸的睁眼 `1300ms`、闭眼 `200ms` 两帧无限循环。二者来自可替换程序资源,不写入、不迁移也不计入用户模板持久空间。 - 模板管理固定把两个随程序发布的只读演示案例放在所有用户内容之前:第一个为“演示静态图”,内容与开机笑脸睁眼帧完全相同;第二个为“演示动图”,按开机笑脸的睁眼 `1300ms`、闭眼 `200ms` 两帧无限循环。二者来自可替换程序资源,不写入、不迁移也不计入用户模板持久空间。
+1 -1
View File
@@ -1 +1 @@
2026-10-09T16:12+08:00 2026-10-10T10:18+08:00
+6 -1
View File
@@ -5,6 +5,7 @@ import hashlib
import json import json
import logging import logging
import os import os
from html import escape
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Literal from typing import Any, Callable, Literal
@@ -801,7 +802,11 @@ def create_app(
@app.get("/") @app.get("/")
def index() -> HTMLResponse: def index() -> HTMLResponse:
network.dismiss_for_boot() network.dismiss_for_boot()
return HTMLResponse(index_html) return HTMLResponse(index_html.replace(
"</head>",
f'<meta name="device-boot-id" content="{escape(wifi_store.boot_id, quote=True)}"></head>',
1,
))
if ui_copy_store is not None: if ui_copy_store is not None:
@app.get("/api/dev/ui-copy") @app.get("/api/dev/ui-copy")
+2 -1
View File
@@ -1,7 +1,7 @@
import { DEFAULT_WORKSPACE_ID, announce, apiJson, getWorkspaces, markAppStale } from "./core.js"; import { DEFAULT_WORKSPACE_ID, announce, apiJson, getWorkspaces, markAppStale } from "./core.js";
import "./interaction-feedback.js"; import "./interaction-feedback.js";
import { setControlBusy, setControlReady, setControlUnavailable } from "./interaction-feedback.js"; import { setControlBusy, setControlReady, setControlUnavailable } from "./interaction-feedback.js";
import { setBoardWorkspace } from "./scene-board.js"; import { setBoardWorkspace, initializeStartupBoard } from "./scene-board.js";
import { renderUiCopy } from "./ui-copy-runtime.js"; import { renderUiCopy } from "./ui-copy-runtime.js";
import { moveWorkspaceOrder, resolveWorkspaceOrder } from "./workspace-order.js"; import { moveWorkspaceOrder, resolveWorkspaceOrder } from "./workspace-order.js";
import { import {
@@ -343,6 +343,7 @@ window.addEventListener("keydown", (event) => {
}); });
buildNavigation(); buildNavigation();
initializeStartupBoard();
setWorkspaceOrderToggleLabel(); setWorkspaceOrderToggleLabel();
navigate(resolveWorkspaceId(), false); navigate(resolveWorkspaceId(), false);
loadWorkspaceOrder().catch((error) => { loadWorkspaceOrder().catch((error) => {
@@ -0,0 +1,58 @@
export const BOARD_SESSION_KEY = "matrix-board-session-v1";
function validContext(context) {
return context === null || (
context && ["template", "animation"].includes(context.type)
&& ["id", "name", "revision", "baseline"].every((key) => typeof context[key] === "string" && context[key])
&& typeof context.readOnly === "boolean"
&& (context.type !== "animation" || context.readOnly)
);
}
export function readBoardSession(storage, draft, bootId) {
const raw = storage?.getItem(BOARD_SESSION_KEY);
if (!raw) return { bootId: null, context: null };
try {
const value = JSON.parse(raw);
if (value.version !== 1 || typeof value.bootId !== "string" || !validContext(value.context)) {
throw new Error("invalid session");
}
return {
bootId: value.bootId,
context: value.bootId === bootId && value.draft === draft ? value.context : null,
};
} catch {
throw new Error("画板保存绑定损坏或来自未来版本;原值已保留,请另存当前内容后处理浏览器存储。");
}
}
export function writeBoardSession(storage, bootId, draft, context) {
storage.setItem(BOARD_SESSION_KEY, JSON.stringify({ version: 1, bootId, draft, context }));
}
// Resolve only editable static defaults and the read-only demo animation.
// Ordinary animations retain their existing explicit frame editing workflow.
export async function loadDefaultBoard(apiJson) {
const reference = await apiJson("/api/display/default-content");
if (reference.type === "template") {
return { ...await apiJson(`/api/templates/${reference.id}`), type: "template" };
}
if (reference.type !== "animation" || !reference.is_demo) return null;
const animation = await apiJson(`/api/animations/${reference.id}/frames`);
const first = animation.frames[0];
if (!first) throw new Error("默认演示动图没有帧");
const frame = await apiJson(`/api/animations/${reference.id}/frames/${first.id}`);
return { ...animation, type: "animation", read_only: true, scene: frame.scene };
}
export async function initializeDefaultBoard({ canLoad, load, apply, retain }) {
if (!canLoad()) return false;
const content = await load();
if (!canLoad()) {
retain();
return false;
}
if (content) apply(content);
else retain();
return Boolean(content);
}
@@ -1,5 +1,6 @@
import { attachScene, createLayer, cloneLayer, cloneScene, composeScene, clipTextPixels, translatedOverflow, clipBrushPixels, textClipAt, normalizeTextElement, layerToStorage } from "./scene-model.js"; import { attachScene, createLayer, cloneLayer, cloneScene, composeScene, clipTextPixels, translatedOverflow, clipBrushPixels, textClipAt, normalizeTextElement, layerToStorage } from "./scene-model.js";
import { announce, apiBlob, isAppStale } from "./core.js"; import { announce, apiBlob, apiJson, isAppStale } from "./core.js";
import { readBoardSession, writeBoardSession, loadDefaultBoard, initializeDefaultBoard } from "./board-startup-model.js";
import { renderUiCopy, renderUiValue } from "./ui-copy-runtime.js"; import { renderUiCopy, renderUiValue } from "./ui-copy-runtime.js";
import { import {
RGB_BYTE_LENGTH, RGB_BYTE_LENGTH,
@@ -102,10 +103,61 @@ let lastExactRgb = null;
let sceneSource = null; let sceneSource = null;
let draftConflict = false; let draftConflict = false;
let externalDraftValue = null; let externalDraftValue = null;
let externalDraftContext = null;
let draftPersistenceBlocked = !draftLoad.canPersist; let draftPersistenceBlocked = !draftLoad.canPersist;
let draftProtectionNotice = ( let draftProtectionNotice = (
draftPersistenceBlocked || draftLoad.status === "cleanup-warning" draftPersistenceBlocked || draftLoad.status === "cleanup-warning"
) ? draftLoad.notice : ""; ) ? draftLoad.notice : "";
const deviceBootId = document.querySelector('meta[name="device-boot-id"]')?.content || "";
let boardBootId = "";
let boardMutation = 0;
let startupBoardPromise = null;
if (draftLoad.canPersist && deviceBootId) {
try {
const session = readBoardSession(draftStorage, serializeScene(scene), deviceBootId);
boardBootId = session.bootId || "";
templateEdit = session.context;
if (templateEdit && !deserializeScene(templateEdit.baseline)) throw new Error("画板保存基线无效;原草稿已保留。");
if (templateEdit && !sceneNeedsTemplateSave(templateEdit, serializeScene(scene)) && templateEdit.type === "template") {
sceneSource = { type: "template", id: templateEdit.id, revision: templateEdit.revision };
}
} catch (error) {
templateEdit = null;
draftPersistenceBlocked = true;
draftProtectionNotice = error.message;
}
}
export function initializeStartupBoard() {
if (startupBoardPromise) return startupBoardPromise;
const mutation = boardMutation;
const canLoad = () => Boolean(deviceBootId) && boardBootId !== deviceBootId
&& !draftPersistenceBlocked && !draftConflict && !animationEdit && boardMutation === mutation;
const retain = () => {
if (draftPersistenceBlocked || draftConflict || animationEdit) return;
boardBootId = deviceBootId;
persistScene();
};
startupBoardPromise = initializeDefaultBoard({
canLoad,
load: () => loadDefaultBoard(apiJson),
apply: (content) => {
// Validate before changing either the session marker or the current draft.
if (!deserializeScene(content.scene)) throw new Error("默认画面场景无效");
boardBootId = deviceBootId;
replaceSceneFromStorage(content.scene, {
reason: "startup-default-load",
source: content.type === "template" ? { type: "template", id: content.id, revision: content.revision } : null,
templateEditContext: content,
});
},
retain,
}).catch((error) => {
announce(`默认画面读取失败,已保留当前草稿:${error.message}`, true);
return false;
});
return startupBoardPromise;
}
const draftConflictNotice = document.getElementById("draft-conflict-notice"); const draftConflictNotice = document.getElementById("draft-conflict-notice");
const loadExternalDraftButton = document.getElementById("load-external-draft"); const loadExternalDraftButton = document.getElementById("load-external-draft");
@@ -122,6 +174,7 @@ function blockDraftPersistence(message) {
} }
export function persistScene() { export function persistScene() {
boardMutation += 1;
if (animationEdit) { if (animationEdit) {
animationEdit.dirty = serializeScene(scene) !== animationEdit.baseline; animationEdit.dirty = serializeScene(scene) !== animationEdit.baseline;
updateBoardUi(); updateBoardUi();
@@ -131,7 +184,10 @@ export function persistScene() {
if (draftConflict || draftPersistenceBlocked) return false; if (draftConflict || draftPersistenceBlocked) return false;
try { try {
if (!draftStorage) throw new Error("browser storage unavailable"); if (!draftStorage) throw new Error("browser storage unavailable");
draftStorage.setItem(SCENE_STORAGE_KEY, serializeScene(scene)); const draft = serializeScene(scene);
// Commit metadata first: a torn pair never restores the wrong binding.
writeBoardSession(draftStorage, boardBootId, draft, templateEdit);
draftStorage.setItem(SCENE_STORAGE_KEY, draft);
return true; return true;
} catch (_error) { } catch (_error) {
blockDraftPersistence("浏览器无法保存统一画板草稿;原草稿已保留,本页不会继续覆盖或应用它。"); blockDraftPersistence("浏览器无法保存统一画板草稿;原草稿已保留,本页不会继续覆盖或应用它。");
@@ -157,6 +213,12 @@ window.addEventListener("storage", (event) => {
return; return;
} }
if (external.status !== "valid") return; if (external.status !== "valid") return;
try {
externalDraftContext = readBoardSession(draftStorage, event.newValue, deviceBootId).context;
} catch (error) {
blockDraftPersistence(error.message);
return;
}
externalDraftValue = event.newValue; externalDraftValue = event.newValue;
setDraftConflict(true); setDraftConflict(true);
announce("另一标签页修改了画板草稿,请选择要保留的版本", true); announce("另一标签页修改了画板草稿,请选择要保留的版本", true);
@@ -164,11 +226,12 @@ window.addEventListener("storage", (event) => {
loadExternalDraftButton.addEventListener("click", async () => { loadExternalDraftButton.addEventListener("click", async () => {
const value = externalDraftValue; const value = externalDraftValue;
const context = externalDraftContext;
if (!value) return; if (!value) return;
if (animationEdit && !await sceneReplacementGuard()) return; if (animationEdit && !await sceneReplacementGuard()) return;
externalDraftValue = null; externalDraftValue = null;
setDraftConflict(false); setDraftConflict(false);
replaceSceneFromStorage(value, { persist: false, reason: "tab-sync" }); replaceSceneFromStorage(value, { persist: false, reason: "tab-sync", templateEditContext: context });
announce("已载入另一标签页的最新画板草稿"); announce("已载入另一标签页的最新画板草稿");
}); });
@@ -670,6 +733,7 @@ export function replaceSceneFromStorage(
templateEditContext = null, templateEditContext = null,
} = {}, } = {},
) { ) {
boardMutation += 1;
const restored = deserializeScene(value); const restored = deserializeScene(value);
if (!restored) throw new Error("模板场景无效,无法载入"); if (!restored) throw new Error("模板场景无效,无法载入");
textLayers.forEach((entry) => { textLayers.forEach((entry) => {
@@ -719,6 +783,8 @@ export function markTemplateSaved(template) {
clearEditHistory(); clearEditHistory();
templateEdit = createTemplateEditContext(template, serializeScene(scene)); templateEdit = createTemplateEditContext(template, serializeScene(scene));
sceneSource = { type: "template", id: template.id, revision: template.revision }; sceneSource = { type: "template", id: template.id, revision: template.revision };
boardBootId = deviceBootId;
persistScene();
updateBoardUi(); updateBoardUi();
document.dispatchEvent(new CustomEvent("matrix:template-edit-change", { detail: templateEditState() })); document.dispatchEvent(new CustomEvent("matrix:template-edit-change", { detail: templateEditState() }));
return true; return true;
@@ -1663,7 +1663,8 @@ dd {
font-size: 0.8rem; font-size: 0.8rem;
} }
.template-card-actions .demo-restricted-action[aria-disabled="true"] { .template-card-actions .demo-restricted-action[aria-disabled="true"],
.template-save-choice-actions .demo-restricted-action[aria-disabled="true"] {
border-color: #000; border-color: #000;
background: #050505; background: #050505;
color: #9aa1ab; color: #9aa1ab;
@@ -1,9 +1,10 @@
export function createTemplateEditContext(template, baseline) { export function createTemplateEditContext(template, baseline) {
return { return {
type: template.type || "template",
id: template.id, id: template.id,
name: template.name, name: template.name,
revision: template.revision, revision: template.revision,
readOnly: Boolean(template.read_only ?? template.readOnly), readOnly: template.type === "animation" || Boolean(template.read_only ?? template.readOnly),
baseline, baseline,
}; };
} }
@@ -11,6 +12,7 @@ export function createTemplateEditContext(template, baseline) {
export function templateEditSnapshot(context, serializedScene) { export function templateEditSnapshot(context, serializedScene) {
if (!context) return null; if (!context) return null;
return { return {
type: context.type || "template",
id: context.id, id: context.id,
name: context.name, name: context.name,
revision: context.revision, revision: context.revision,
@@ -558,6 +558,10 @@ async function saveSceneAsNew({ initial = "", note = "", bind = true } = {}) {
} }
async function saveSceneToCurrentTemplate(context) { async function saveSceneToCurrentTemplate(context) {
if (context.readOnly || context.type !== "template") {
announce("演示案例不得覆盖,请另存为新模板", true);
return;
}
await mutate(async () => { await mutate(async () => {
const result = await apiJson(`/api/templates/${context.id}`, { const result = await apiJson(`/api/templates/${context.id}`, {
method: "PUT", method: "PUT",
@@ -23,6 +23,16 @@ def solid_scene(color=(12, 34, 56)):
} }
def test_homepage_exposes_escaped_boot_session_without_changing_rest_contract(tmp_path):
boot_id = 'boot-"<unsafe>&'
with TestClient(create_app(project_root=tmp_path, driver_kind="mock", startup_boot_id=boot_id)) as client:
response = client.get("/")
assert response.status_code == 200
assert '<meta name="device-boot-id" content="boot-&quot;&lt;unsafe&gt;&amp;">' in response.text
assert response.headers["cache-control"] == "no-store"
assert 'name="device-boot-id"' not in client.get("/api/status").text
def test_status_config_and_display_api(tmp_path): def test_status_config_and_display_api(tmp_path):
client = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) client = TestClient(create_app(project_root=tmp_path, driver_kind="mock"))
@@ -1,6 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import { BOARD_SESSION_KEY, readBoardSession, writeBoardSession, loadDefaultBoard, initializeDefaultBoard } from "../app/static/board-startup-model.js";
import { import {
createTemplateEditContext, createTemplateEditContext,
@@ -22,6 +23,7 @@ test("template edit baseline distinguishes clean, dirty, and unbound scenes", ()
assert.equal(sceneNeedsTemplateSave(context, "scene-a"), false); assert.equal(sceneNeedsTemplateSave(context, "scene-a"), false);
assert.equal(sceneNeedsTemplateSave(context, "scene-b"), true); assert.equal(sceneNeedsTemplateSave(context, "scene-b"), true);
assert.deepEqual(templateEditSnapshot(context, "scene-a"), { assert.deepEqual(templateEditSnapshot(context, "scene-a"), {
type: "template",
id: "template-1", id: "template-1",
name: "测试模板", name: "测试模板",
revision: "revision-1", revision: "revision-1",
@@ -31,6 +33,71 @@ test("template edit baseline distinguishes clean, dirty, and unbound scenes", ()
assert.equal(templateEditSnapshot(context, "scene-b").dirty, true); assert.equal(templateEditSnapshot(context, "scene-b").dirty, true);
}); });
test("same boot restores binding and original dirty baseline; new boot or mismatched draft does not", () => {
const values = new Map();
const storage = { getItem: (key) => values.get(key), setItem: (key, value) => values.set(key, value) };
const context = createTemplateEditContext({ id: "saved", name: "默认画面", revision: "original" }, "clean-scene");
writeBoardSession(storage, "boot-a", "dirty-scene", context);
const restored = readBoardSession(storage, "dirty-scene", "boot-a");
assert.deepEqual(restored.context, context);
assert.equal(templateEditSnapshot(restored.context, "dirty-scene").dirty, true);
assert.equal(restored.context.revision, "original");
assert.equal(readBoardSession(storage, "dirty-scene", "boot-b").context, null);
assert.equal(readBoardSession(storage, "another-scene", "boot-a").context, null);
assert.equal(readBoardSession(storage, "another-scene", "boot-a").bootId, "boot-a");
values.set(BOARD_SESSION_KEY, '{"version":99}');
assert.throws(() => readBoardSession(storage, "dirty-scene", "boot-a"), /原值已保留/);
assert.equal(values.get(BOARD_SESSION_KEY), '{"version":99}');
});
test("default static loads its complete latest scene and both demos keep read-only ownership", async () => {
const staticDetail = { id: "static", name: "默认静态", revision: "r2", scene: "complete-scene" };
const calls = [];
const normal = await loadDefaultBoard(async (url) => {
calls.push(url);
return url.endsWith("default-content") ? { type: "template", id: "static" } : staticDetail;
});
assert.deepEqual(normal, { ...staticDetail, type: "template" });
assert.deepEqual(calls, ["/api/display/default-content", "/api/templates/static"]);
const staticDemo = createTemplateEditContext({ ...normal, read_only: true }, "baseline");
assert.equal(staticDemo.readOnly, true);
const demo = await loadDefaultBoard(async (url) => {
if (url.endsWith("default-content")) return { type: "animation", id: "demo", is_demo: true };
if (url.endsWith("/frames")) return { id: "demo", name: "演示动图", revision: "r1", frames: [{ id: "first" }, { id: "second" }] };
assert.equal(url, "/api/animations/demo/frames/first");
return { scene: "first-scene" };
});
assert.equal(demo.scene, "first-scene");
const context = createTemplateEditContext(demo, demo.scene);
assert.equal(context.type, "animation");
assert.equal(context.readOnly, true);
assert.equal(context.id, "demo");
assert.equal(await loadDefaultBoard(async () => ({ type: "animation", is_demo: false })), null);
});
test("startup skips protected drafts and never replaces intervening edits or failed reads", async () => {
let allowed = false, applied = 0, retained = 0, loaded = 0;
const options = {
canLoad: () => allowed,
load: async () => { loaded++; return { scene: "default" }; },
apply: () => applied++, retain: () => retained++,
};
assert.equal(await initializeDefaultBoard(options), false);
assert.equal(loaded, 0);
allowed = true;
assert.equal(await initializeDefaultBoard(options), true);
assert.equal(applied, 1);
options.load = async () => { allowed = false; return { scene: "late-default" }; };
assert.equal(await initializeDefaultBoard(options), false);
assert.equal(applied, 1);
assert.equal(retained, 1);
allowed = true;
options.load = async () => { throw Error("deleted or unavailable"); };
await assert.rejects(initializeDefaultBoard(options), /deleted or unavailable/);
assert.equal(applied, 1);
assert.equal(retained, 1);
});
test("template manager uses a monitor and unified four-row card actions", async () => { test("template manager uses a monitor and unified four-row card actions", async () => {
const [html, board, templates, style] = await Promise.all([ const [html, board, templates, style] = await Promise.all([
readFile(new URL("index.html", staticRoot), "utf8"), readFile(new URL("index.html", staticRoot), "utf8"),
@@ -54,6 +121,7 @@ test("template manager uses a monitor and unified four-row card actions", async
assert.match(html, /id="template-save-choice-dialog"/); assert.match(html, /id="template-save-choice-dialog"/);
assert.match(html, /value="current">保存到当前模板/); assert.match(html, /value="current">保存到当前模板/);
assert.match(html, /value="new">另存为新模板/); assert.match(html, /value="new">另存为新模板/);
assert.match(style, /\.template-save-choice-actions \.demo-restricted-action\[aria-disabled="true"\][\s\S]*background: #050505/);
}); });
test("animation editing backs up and restores template edit ownership", async () => { test("animation editing backs up and restores template edit ownership", async () => {
@@ -99,6 +99,10 @@ python -X utf8 "测试相关资料/如何测试/本机测试环境/local_env.py"
### 复制项目与持续维护 ### 复制项目与持续维护
隔离副本执行 `test --suite all` 时,除源码和测试入口外,还须复制该套件校验的内核离线依赖、内核源码归档及依赖登记表;不能只带 Python wheel 和 FRP 后把缺少材料算成软件回归。普通开发不需要复制或重建历史 IMG。只含应用的板端更新载荷沿用 `MATRIX_SOURCE_ONLY_UPDATE_TESTS=1` 的既有选测边界,其跳过项不能写成完整工作区依赖验收通过。
Windows 原子替换遇到 `PermissionError / WinError 5` 时保留失败调用和软件错误响应,核对文件是否被占用,再在独立测试目录定向重测。单次重测通过只能说明未复现,不足以认定杀毒软件、路径长度或其他根因;不要据此放宽生产写入保护。
Git 克隆后按上述命令重新搭建。复制整个工作区时,旧 venv 含绝对位置和旧 Python 绑定,不能当作可移植程序:先保留并改名旧 `.local/ssh/venv`、`.local/image-editor/venv`,再 setup;wheel 和数据可继续复用。Android 在新机器重新配置工具路径并 setup,刷新本工具管理的 AVD 路径。源码入口、锁文件、搭建文档与包登记必须随测试修订一起维护。 Git 克隆后按上述命令重新搭建。复制整个工作区时,旧 venv 含绝对位置和旧 Python 绑定,不能当作可移植程序:先保留并改名旧 `.local/ssh/venv`、`.local/image-editor/venv`,再 setup;wheel 和数据可继续复用。Android 在新机器重新配置工具路径并 setup,刷新本工具管理的 AVD 路径。源码入口、锁文件、搭建文档与包登记必须随测试修订一起维护。
每轮相关测试先查已有排障记录;已验证解法更新本 README 或移动端排障,依赖更新同时维护原始 requirements、完整锁文件和 wheel 哈希。临时修补不能长期只存在 `.local/work/`:通用化且脱敏后加入可复用源码,或把复建步骤写进文档。单次日志保留在 `.local/work/`;需要归档的结果先脱敏再写入 `各种归档/`。 每轮相关测试先查已有排障记录;已验证解法更新本 README 或移动端排障,依赖更新同时维护原始 requirements、完整锁文件和 wheel 哈希。临时修补不能长期只存在 `.local/work/`:通用化且脱敏后加入可复用源码,或把复建步骤写进文档。单次日志保留在 `.local/work/`;需要归档的结果先脱敏再写入 `各种归档/`。
@@ -73,6 +73,15 @@ NetworkManager DNS 往返:`tests/test_wifi.py::test_nmcli_multiple_dns_can_rou
## 1. 本机 mock 与自动化 ## 1. 本机 mock 与自动化
### 默认画面保存绑定(TEST-TEMPLATE-MANAGEMENT / TEST-DEFAULT-CONTENT)
- 覆盖 `WEB-DEFAULT-DISPLAY`、`WEB-TEMPLATES`:新设备开机会话载入默认静态模板并绑定 ID、修订及基线;覆盖保留 ID、名称、创建时间和默认引用,另存绑定新模板,取消不发写请求。同次开机刷新保留未保存草稿及原修订;新开机会话载入最新默认模板。服务 restart 不作为设备重新开机。
- 演示静态图和演示动图第一帧均显示保存弹窗;“保存到当前模板”须为黑色受限态,点击只解释原因且不关闭弹窗、不发覆盖请求;另存生成可写静态模板。演示动图绑定类型仍是只读 animation。用户动图保持原逐帧编辑、保存和普通草稿恢复流程。
- 定向反例:过期修订返回 `409`、删除目标返回 `404`、默认读取失败、损坏/未来版本草稿、初始化响应期间新增图层;都保留当前草稿,不误绑定或自动重写。多标签选择载入外部草稿时仅恢复与该草稿精确匹配的绑定。
- Node 行为用例在 `tests/test_template_management_ui.mjs`,首页开机会话标记及 HTML 属性转义在 `tests/test_api.py`。浏览器验证覆盖桌面与手机弹窗、真实保存请求及刷新,不以代码文本匹配替代交互验收。
- 已验证经验:场景持久化不等于模板来源持久化;绑定须单独保存原修订与基线,并核对草稿对应关系。仅设置 `aria-disabled` 不会把主按钮变黑,保存弹窗也须应用演示受限样式。另存弹窗关闭后请求仍可能在途,自动化必须等待写请求成功及绑定更新后再刷新。DELETE 同样要求 `If-Match`,不能把缺少请求头的 `428` 当成删除成功。
- 本项不改驱动、扫描或输出实现,实屏视觉验收不适用;板端验收检查服务、静态资源和临时模板保存,不改写用户原件,不启动摄像头或切换测试图案。
从独立项目根执行: 从独立项目根执行:
```powershell ```powershell
@@ -26,6 +26,8 @@
## 后续登记模板 ## 后续登记模板
2026-10-10,WEB-DEFAULT-DISPLAY / WEB-TEMPLATES:设备网页补齐开机默认静态模板及只读演示案例的保存绑定,浏览器单独保留来源、修订与基线;首页增加不含身份凭据的设备开机会话 meta 标记。REST、BLE、scene v2、设备配置、驱动和固定部署路径不变,用户动图仍按原流程逐帧保存。无需修改或发布 App;本轮按网页、模板 API、隔离构建和原子部署影响选测,Android 回归及实屏视觉验收不适用。
2026-09-27,DEPLOY-OTA-OFFLINE-CLOSURE/DEPLOY-OTA-CANDIDATE:设备端移除误加入的电脑测试专用 Paramiko 依赖,改为检查 OTA 离线依赖闭包并按实机验证的原字节候选发布 1.1.3。REST、BLE、驱动及移动端协议均不变;无需 App 更新或重新签名。实机试装和恢复结果以本轮验收记录为准。 2026-09-27,DEPLOY-OTA-OFFLINE-CLOSURE/DEPLOY-OTA-CANDIDATE:设备端移除误加入的电脑测试专用 Paramiko 依赖,改为检查 OTA 离线依赖闭包并按实机验证的原字节候选发布 1.1.3。REST、BLE、驱动及移动端协议均不变;无需 App 更新或重新签名。实机试装和恢复结果以本轮验收记录为准。
2026-09-26,WEB-PERFORMANCE-MODE/DEPLOY-OTA-KERNEL-RECOVERY:设备 REST 的 `/api/ota/status` 增加可选 `kernel_recovery` 状态,`/api/status.system.performance_mode.last_error` 对缺失 cpufreq 给出具体原因;已有字段和 BLE 协议不变。旧 App 忽略新增 REST 字段,无需 App 更新。本轮仅改设备端;本机和实机验证结果以本次交付记录为准。 2026-09-26,WEB-PERFORMANCE-MODE/DEPLOY-OTA-KERNEL-RECOVERY:设备 REST 的 `/api/ota/status` 增加可选 `kernel_recovery` 状态,`/api/status.system.performance_mode.last_error` 对缺失 cpufreq 给出具体原因;已有字段和 BLE 协议不变。旧 App 忽略新增 REST 字段,无需 App 更新。本轮仅改设备端;本机和实机验证结果以本次交付记录为准。