c812a1a595
Integrate the remote firmware workbench, OTA delivery, and timed mode schedules while preserving the local N7 scene and runtime changes.
552 lines
21 KiB
Python
552 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from cognitive_agent.application.mode_profile import ModeProfileRegistry
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
WEB = ROOT / "apps" / "web-console"
|
|
|
|
|
|
def test_runtime_mode_catalog_exposes_expected_static_and_dynamic_membership() -> None:
|
|
registry = ModeProfileRegistry(ROOT / "config" / "modes")
|
|
modes = registry.public_modes(vehicle_id="denza_n7")
|
|
grouped = {
|
|
behavior: {mode["id"] for mode in modes if mode["behavior"] == behavior}
|
|
for behavior in ("static", "cyclic")
|
|
}
|
|
|
|
assert grouped["static"] == {
|
|
"zero_gravity",
|
|
"office",
|
|
"nap",
|
|
"queen_passenger_driving",
|
|
"leisure_lounger",
|
|
"cruise_sofa",
|
|
"easy_entry_exit",
|
|
"electric_beach_chair",
|
|
"calf_decompression_float",
|
|
"baby_nap",
|
|
"meditation_space",
|
|
}
|
|
assert grouped["cyclic"] == {"rocking_horse", "dynamic_cradle"}
|
|
assert len(modes) == len(grouped["static"] | grouped["cyclic"])
|
|
|
|
|
|
def test_runtime_mode_catalog_exposes_safe_executable_action_details() -> None:
|
|
registry = ModeProfileRegistry(ROOT / "config" / "modes")
|
|
office = next(
|
|
mode
|
|
for mode in registry.public_modes(vehicle_id="denza_n7")
|
|
if mode["id"] == "office"
|
|
)
|
|
|
|
assert len(office["operations"]) == office["operation_count"] == 12
|
|
assert office["operations"][0] == {
|
|
"domain": "seat",
|
|
"capability": "seat.set_driver_ventilation_native",
|
|
"target_scope": "driver",
|
|
"operation": "set",
|
|
"parameters": {"state": "on", "level": 2},
|
|
}
|
|
assert next(
|
|
operation for operation in office["operations"]
|
|
if operation["capability"] == "seat.set_longitudinal_position"
|
|
) == {
|
|
"domain": "seat",
|
|
"capability": "seat.set_longitudinal_position",
|
|
"target_scope": "passenger",
|
|
"operation": "set",
|
|
"parameters": {"position_pct": 72},
|
|
}
|
|
assert all(
|
|
set(operation) == {
|
|
"domain",
|
|
"capability",
|
|
"target_scope",
|
|
"operation",
|
|
"parameters",
|
|
}
|
|
for operation in office["operations"]
|
|
)
|
|
assert all("reason" not in operation for operation in office["operations"])
|
|
|
|
|
|
def node_runtime() -> str:
|
|
candidates = [
|
|
shutil.which("node"),
|
|
Path.home()
|
|
/ ".cache"
|
|
/ "codex-runtimes"
|
|
/ "codex-primary-runtime"
|
|
/ "dependencies"
|
|
/ "node"
|
|
/ "bin"
|
|
/ "node.exe",
|
|
Path.home()
|
|
/ ".cache"
|
|
/ "codex-runtimes"
|
|
/ "codex-primary-runtime"
|
|
/ "dependencies"
|
|
/ "node"
|
|
/ "bin"
|
|
/ "node",
|
|
]
|
|
for candidate in candidates:
|
|
if candidate and Path(candidate).is_file():
|
|
return str(candidate)
|
|
raise AssertionError("Node.js is required to execute the mode gallery grouping contract")
|
|
|
|
|
|
def test_mode_gallery_classifies_profiles_into_exclusive_custom_static_dynamic_groups() -> None:
|
|
app = (WEB / "app.js").read_text(encoding="utf-8")
|
|
start = app.index("const MODE_GALLERY_GROUPS")
|
|
end = app.index("\n\nfunction createModeGalleryCard", start)
|
|
snippet = app[start:end]
|
|
script = "const snippet = " + json.dumps(snippet) + ";\n" + r"""
|
|
const vm = require("node:vm");
|
|
const modes = [
|
|
{ id: "user-static", origin: "user", deletable: true, behavior: "static" },
|
|
{ id: "office", origin: "preset", behavior: "static" },
|
|
{ id: "future-cycle", origin: "user", deletable: true, behavior: "cyclic" },
|
|
{ id: "timeline-only", origin: "preset", timeline: { repeat: "once" } },
|
|
{ id: "rocking_horse", origin: "preset" },
|
|
{ id: "unknown", origin: "preset" },
|
|
{ id: "future-behavior", origin: "preset", behavior: "scheduled", timeline: { repeat: "loop" } },
|
|
];
|
|
const context = { state: { runtime: { modes } } };
|
|
vm.createContext(context);
|
|
vm.runInContext(
|
|
"function modeProfiles() { return state.runtime.modes; }\n"
|
|
+ snippet
|
|
+ "\nthis.result = {"
|
|
+ "groups: modeGalleryBuckets().map((group) => ({ id: group.id, title: group.title, modeIds: group.modes.map((mode) => mode.id) })),"
|
|
+ "dynamicFlags: state.runtime.modes.map((mode) => isDynamicModeProfile(mode))"
|
|
+ "};",
|
|
context,
|
|
);
|
|
console.log(JSON.stringify(context.result));
|
|
"""
|
|
completed = subprocess.run(
|
|
[node_runtime(), "-e", script],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
)
|
|
result = json.loads(completed.stdout)
|
|
|
|
assert [(group["id"], group["title"]) for group in result["groups"]] == [
|
|
("custom", "自定义模式"),
|
|
("static", "静态模式"),
|
|
("dynamic", "动态模式"),
|
|
]
|
|
assert result["groups"][0]["modeIds"] == ["user-static", "future-cycle"]
|
|
assert result["groups"][1]["modeIds"] == ["office", "unknown"]
|
|
assert result["groups"][2]["modeIds"] == [
|
|
"rocking_horse",
|
|
"timeline-only",
|
|
"future-behavior",
|
|
]
|
|
assert result["dynamicFlags"] == [False, False, True, True, True, False, True]
|
|
|
|
|
|
def test_mode_gallery_layout_and_copy_expose_three_management_sections() -> None:
|
|
app = (WEB / "app.js").read_text(encoding="utf-8")
|
|
template = (WEB / "src" / "App.vue").read_text(encoding="utf-8")
|
|
styles = (WEB / "styles.css").read_text(encoding="utf-8")
|
|
definitions = app[
|
|
app.index("const MODE_GALLERY_GROUPS")
|
|
: app.index("const MODE_DOMAIN_LABELS")
|
|
]
|
|
|
|
assert definitions.count('title: "自定义模式"') == 1
|
|
assert definitions.count('title: "静态模式"') == 1
|
|
assert definitions.count('title: "动态模式"') == 1
|
|
for legacy_group in ('id: "personal"', 'id: "frequent"', 'id: "comfort"'):
|
|
assert legacy_group not in definitions
|
|
assert 'if (group.id === "custom") grid.append(createModeGalleryAddCard())' in app
|
|
assert "function createModeGalleryEmpty(group)" in app
|
|
assert 'section.setAttribute("aria-labelledby", headingId)' in app
|
|
assert "自定义模式 · 静态模式 · 动态模式" in template
|
|
assert "按来源与运行方式整理,快速定位并管理模式" in template
|
|
assert "grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));" in styles
|
|
|
|
|
|
def test_top_mode_cards_render_real_actions_in_a_body_level_hover_and_focus_preview() -> None:
|
|
app = (WEB / "app.js").read_text(encoding="utf-8")
|
|
styles = (WEB / "console-v2.css").read_text(encoding="utf-8")
|
|
|
|
assert "function modeExecutionActionViews(mode)" in app
|
|
assert "Array.isArray(mode?.operations)" in app
|
|
assert "function renderModeActionPopover(mode)" in app
|
|
assert "modeEditorReadableCommands(mode?.command_flow)" in app
|
|
assert 'eyebrow.dataset.modeActionEyebrow = "true"' in app
|
|
assert '"最新控制指令流"' in app
|
|
assert 'popover.setAttribute("role", "tooltip")' in app
|
|
assert "document.body.append(popover)" in app
|
|
assert 'appendMetadata("语义", action.semantic || action.operation_ref)' in app
|
|
assert 'appendMetadata("实际", action.command)' in app
|
|
assert 'appendMetadata("通道", action.channel)' in app
|
|
assert 'appendMetadata("设备", action.device)' in app
|
|
assert 'appendMetadata("目标", action.target)' in app
|
|
assert 'appendMetadata("参数", action.parameters)' in app
|
|
assert 'button.setAttribute("aria-describedby", popover.id)' in app
|
|
assert 'group.addEventListener("pointerover"' in app
|
|
assert 'group.addEventListener("pointerout"' in app
|
|
assert 'group.addEventListener("focusin"' in app
|
|
assert 'group.addEventListener("focusout"' in app
|
|
assert 'group.addEventListener("scroll", () => hideModeActionPopover({ immediate: true })' in app
|
|
assert 'window.addEventListener("resize", () => hideModeActionPopover({ immediate: true })' in app
|
|
assert "installModeActionPreview();" in app
|
|
assert ".mode-chip-action-popover" in styles
|
|
popover_styles = styles.split(".mode-chip-action-popover {", 1)[1].split("}", 1)[0]
|
|
assert "position: fixed" in popover_styles
|
|
assert "max-height:" in popover_styles
|
|
assert "overflow: hidden" in popover_styles
|
|
assert ".mode-chip-action-popover ol" in styles
|
|
assert "overflow-y: auto" in styles.split(".mode-chip-action-popover ol", 1)[1].split("}", 1)[0]
|
|
|
|
|
|
def test_top_mode_action_formatter_uses_device_target_action_and_parameter_values() -> None:
|
|
registry = ModeProfileRegistry(ROOT / "config" / "modes")
|
|
office = next(
|
|
mode
|
|
for mode in registry.public_modes(vehicle_id="denza_n7")
|
|
if mode["id"] == "office"
|
|
)
|
|
app = (WEB / "app.js").read_text(encoding="utf-8")
|
|
start = app.index("const MODE_TARGET_LABELS")
|
|
end = app.index("\n\nfunction isUserModeProfile", start)
|
|
snippet = app[start:end]
|
|
script = (
|
|
'const MODE_DOMAIN_LABELS = { seat: "座椅", climate: "空调", roof: "天幕", '
|
|
'ambient_light: "氛围灯" };\n'
|
|
'const CONFIG_PARAMETER_LABELS = { position_pct: "目标位置", '
|
|
'temperature_c: "目标温度", level: "档位", mode: "工作模式", '
|
|
'shade_position_pct: "遮阳帘位置", brightness_pct: "目标亮度", '
|
|
'color_hex: "目标颜色" };\n'
|
|
'const CONFIG_CAPABILITY_LABELS = { '
|
|
'"seat.set_longitudinal_position": "调节座椅前后位置", '
|
|
'"climate.set_temperature": "设置空调温度" };\n'
|
|
'function formatConfigValue(name, value) { '
|
|
'if (name === "temperature_c") return `${value} ℃`; '
|
|
'if (name.endsWith("_pct")) return `${value}%`; return String(value); }\n'
|
|
'function modeSecondsLabel(value) { return String(Number(value) / 1000); }\n'
|
|
+ snippet
|
|
+ "\nconst mode = "
|
|
+ json.dumps(office, ensure_ascii=False)
|
|
+ "; console.log(JSON.stringify(modeExecutionActionViews(mode)));"
|
|
)
|
|
completed = subprocess.run(
|
|
[node_runtime(), "-e", script],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
)
|
|
actions = json.loads(completed.stdout)
|
|
|
|
assert len(actions) == office["operation_count"]
|
|
assert next(action for action in actions if action["action"] == "调节座椅前后位置") == {
|
|
"device": "座椅",
|
|
"target": "副驾",
|
|
"action": "调节座椅前后位置",
|
|
"parameters": "目标位置:72%",
|
|
}
|
|
temperature = next(action for action in actions if action["action"] == "设置空调温度")
|
|
assert temperature == {
|
|
"device": "空调",
|
|
"target": "副驾",
|
|
"action": "设置空调温度",
|
|
"parameters": "目标温度:25 ℃",
|
|
}
|
|
|
|
|
|
def test_mode_chip_scroller_keeps_clicks_and_only_captures_real_drags() -> None:
|
|
app = (WEB / "app.js").read_text(encoding="utf-8")
|
|
start = app.index("function installModeChipScroller")
|
|
end = app.index("\n\nfunction ensureModeActionPopover", start)
|
|
snippet = app[start:end]
|
|
|
|
threshold = snippet.index('if (!dragged && Math.abs(distance) < 5) return;')
|
|
capture = snippet.index("group.setPointerCapture(pointerId);")
|
|
assert capture > threshold
|
|
assert "let pointerCaptured = false;" in snippet
|
|
assert "const finishedPointerId = pointerId;" in snippet
|
|
assert "pointerId = null;" in snippet
|
|
assert "group.hasPointerCapture?.(finishedPointerId)" in snippet
|
|
assert "group.releasePointerCapture(finishedPointerId);" in snippet
|
|
assert 'group.addEventListener("lostpointercapture", finishPointerDrag);' in snippet
|
|
assert 'event.stopImmediatePropagation();' in snippet
|
|
|
|
|
|
def test_top_mode_triangle_is_an_explicit_execution_target() -> None:
|
|
app = (WEB / "app.js").read_text(encoding="utf-8")
|
|
vue_ui = (WEB / "vue-ui.js").read_text(encoding="utf-8")
|
|
handlers = app[app.index('document.addEventListener("DOMContentLoaded"') :]
|
|
|
|
assert '"data-mode-run": "true"' in vue_ui
|
|
assert 'runIcon.dataset.modeRun = "true";' in app
|
|
assert 'const runControl = target?.closest("[data-mode-run]");' in handlers
|
|
assert '(runControl || target)?.closest("button[data-mode-command]")' in handlers
|
|
|
|
|
|
def test_saved_mode_cards_open_assistant_and_execute_the_saved_command_flow() -> None:
|
|
app = (WEB / "app.js").read_text(encoding="utf-8")
|
|
start = app.index("function applyModeCommand")
|
|
end = app.index("\n\nfunction applyRuntimeTargetPolicy", start)
|
|
snippet = app[start:end]
|
|
script = "const snippet = " + json.dumps(snippet) + ";\n" + r"""
|
|
const vm = require("node:vm");
|
|
const calls = [];
|
|
const runButton = { disabled: false };
|
|
const savedMode = {
|
|
id: "reading-user",
|
|
name: "阅读模式",
|
|
command_flow: { source_request_id: "saved-request" },
|
|
};
|
|
const context = {
|
|
state: { pendingModeProfileId: null },
|
|
Event: class Event {
|
|
constructor(type, options = {}) { this.type = type; this.bubbles = options.bubbles; }
|
|
},
|
|
window: {
|
|
CognitiveAgentAssistant: {
|
|
isPending() { return false; },
|
|
open() { calls.push(["open"]); },
|
|
},
|
|
},
|
|
modeProfiles() { return [savedMode]; },
|
|
setModeRailSelection(mode) { calls.push(["select", mode?.id]); },
|
|
closeModeGallery(options) { calls.push(["close", options.restoreFocus]); },
|
|
hideModeActionPopover() {},
|
|
showToast(message) { calls.push(["toast", message]); },
|
|
hasExecutableModeCommandFlow(mode) {
|
|
const flow = mode?.command_flow;
|
|
return Boolean(flow?.source_request_id)
|
|
|| Boolean(flow?.plans && Object.keys(flow.plans).length);
|
|
},
|
|
executeSavedModeProfile(mode, options) {
|
|
calls.push(["execute", mode.id, options.command]);
|
|
return Promise.resolve(true);
|
|
},
|
|
$(selector) {
|
|
return { "#runButton": runButton }[selector];
|
|
},
|
|
};
|
|
vm.createContext(context);
|
|
vm.runInContext(snippet + '\nthis.result = applyModeCommand(" 进入阅读模式 ", "reading-user");', context);
|
|
console.log(JSON.stringify({
|
|
result: context.result,
|
|
calls,
|
|
pendingModeProfileId: context.state.pendingModeProfileId,
|
|
}));
|
|
"""
|
|
completed = subprocess.run(
|
|
[node_runtime(), "-e", script],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
)
|
|
result = json.loads(completed.stdout)
|
|
|
|
assert result["result"] is True
|
|
assert result["calls"] == [
|
|
["select", "reading-user"],
|
|
["close", False],
|
|
["open"],
|
|
["execute", "reading-user", "进入阅读模式"],
|
|
]
|
|
assert result["pendingModeProfileId"] is None
|
|
assert "executeSavedModeProfile" in snippet
|
|
|
|
|
|
def test_n7_mode_command_flow_is_directly_executable_without_source_request_id() -> None:
|
|
app = (WEB / "app.js").read_text(encoding="utf-8")
|
|
start = app.index("function hasExecutableModeCommandFlow")
|
|
end = app.index("\n\nasync function executeSavedModeProfile", start)
|
|
helper = app[start:end]
|
|
script = helper + r"""
|
|
console.log(JSON.stringify({
|
|
imported: hasExecutableModeCommandFlow({
|
|
id: "cinema",
|
|
command_flow: {
|
|
source_topic: "from_n7/scenes",
|
|
plans: { from_n7_scene: { spec: { commands: [] } } },
|
|
},
|
|
}),
|
|
generated: hasExecutableModeCommandFlow({
|
|
id: "reading-user",
|
|
command_flow: { source_request_id: "req-reading" },
|
|
}),
|
|
missing: hasExecutableModeCommandFlow({ id: "office" }),
|
|
}));
|
|
"""
|
|
completed = subprocess.run(
|
|
[node_runtime(), "-e", script],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
)
|
|
|
|
assert json.loads(completed.stdout) == {
|
|
"imported": True,
|
|
"generated": True,
|
|
"missing": False,
|
|
}
|
|
|
|
|
|
def test_mode_card_without_saved_flow_falls_back_to_assistant_submission() -> None:
|
|
app = (WEB / "app.js").read_text(encoding="utf-8")
|
|
start = app.index("function applyModeCommand")
|
|
end = app.index("\n\nfunction applyRuntimeTargetPolicy", start)
|
|
snippet = app[start:end]
|
|
script = "const snippet = " + json.dumps(snippet) + ";\n" + r"""
|
|
const vm = require("node:vm");
|
|
const calls = [];
|
|
const input = {
|
|
value: "",
|
|
dataset: { speechStage: "final", speechUtteranceId: "old", speechValue: "old" },
|
|
dispatchEvent(event) { calls.push(["input", event.type, event.bubbles, this.value]); },
|
|
};
|
|
const form = { requestSubmit() { calls.push(["submit", input.value]); } };
|
|
const runButton = { disabled: false };
|
|
const unsavedMode = { id: "future-mode", name: "未来模式" };
|
|
const context = {
|
|
state: { pendingModeProfileId: null },
|
|
Event: class Event {
|
|
constructor(type, options = {}) { this.type = type; this.bubbles = options.bubbles; }
|
|
},
|
|
window: {
|
|
CognitiveAgentAssistant: {
|
|
isPending() { return false; },
|
|
open() { calls.push(["open"]); },
|
|
},
|
|
},
|
|
modeProfiles() { return [unsavedMode]; },
|
|
setModeRailSelection(mode) { calls.push(["select", mode?.id]); },
|
|
setModeExecutionState(modeId, status) { calls.push(["state", modeId, status]); },
|
|
closeModeGallery(options) { calls.push(["close", options.restoreFocus]); },
|
|
hideModeActionPopover() {},
|
|
showToast(message) { calls.push(["toast", message]); },
|
|
hasExecutableModeCommandFlow(mode) {
|
|
const flow = mode?.command_flow;
|
|
return Boolean(flow?.source_request_id)
|
|
|| Boolean(flow?.plans && Object.keys(flow.plans).length);
|
|
},
|
|
$(selector) {
|
|
return { "#utterance": input, "#requestForm": form, "#runButton": runButton }[selector];
|
|
},
|
|
};
|
|
vm.createContext(context);
|
|
vm.runInContext(snippet + '\nthis.result = applyModeCommand(" 进入未来模式 ", "future-mode");', context);
|
|
console.log(JSON.stringify({ result: context.result, calls, value: input.value }));
|
|
"""
|
|
completed = subprocess.run(
|
|
[node_runtime(), "-e", script],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
)
|
|
result = json.loads(completed.stdout)
|
|
|
|
assert result["result"] is True
|
|
assert result["calls"] == [
|
|
["select", "future-mode"],
|
|
["close", False],
|
|
["open"],
|
|
["input", "input", True, "进入未来模式"],
|
|
["state", "future-mode", "running"],
|
|
["submit", "进入未来模式"],
|
|
]
|
|
assert result["value"] == "进入未来模式"
|
|
|
|
|
|
def test_mode_cards_do_not_submit_while_assistant_is_busy() -> None:
|
|
app = (WEB / "app.js").read_text(encoding="utf-8")
|
|
start = app.index("function applyModeCommand")
|
|
end = app.index("\n\nfunction applyRuntimeTargetPolicy", start)
|
|
snippet = app[start:end]
|
|
script = "const snippet = " + json.dumps(snippet) + ";\n" + r"""
|
|
const vm = require("node:vm");
|
|
const calls = [];
|
|
const context = {
|
|
window: { CognitiveAgentAssistant: { isPending() { return true; } } },
|
|
showToast(message) { calls.push(message); },
|
|
};
|
|
vm.createContext(context);
|
|
vm.runInContext(snippet + '\nthis.result = applyModeCommand("进入办公模式", "office");', context);
|
|
console.log(JSON.stringify({ result: context.result, calls }));
|
|
"""
|
|
completed = subprocess.run(
|
|
[node_runtime(), "-e", script],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
)
|
|
result = json.loads(completed.stdout)
|
|
|
|
assert result == {
|
|
"result": False,
|
|
"calls": ["AI助手正在处理上一条指令,请稍候"],
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(("phase", "input_mode"), (
|
|
("capturing", "manual"),
|
|
("capturing", "wake"),
|
|
("finalizing", "manual"),
|
|
("finalizing", "wake"),
|
|
("arming", "manual"),
|
|
))
|
|
def test_mode_click_during_dictation_does_not_create_stuck_running_state(
|
|
phase: str, input_mode: str,
|
|
) -> None:
|
|
app = (WEB / "app.js").read_text(encoding="utf-8")
|
|
start = app.index("function applyModeCommand")
|
|
end = app.index("\n\nfunction applyRuntimeTargetPolicy", start)
|
|
script = "const snippet = " + json.dumps(app[start:end]) + ";\n"
|
|
script += "const speech = " + json.dumps({"phase": phase, "inputMode": input_mode}) + ";\n"
|
|
script += r"""
|
|
const vm = require("node:vm");
|
|
const calls = [];
|
|
const context = {
|
|
window: {
|
|
CognitiveAgentAssistant: { isPending() { return false; } },
|
|
CognitiveAgentSpeech: speech,
|
|
},
|
|
showToast(message) { calls.push(message); },
|
|
};
|
|
vm.createContext(context);
|
|
vm.runInContext(snippet + '\nthis.result = applyModeCommand("进入办公模式", "office");', context);
|
|
console.log(JSON.stringify({ result: context.result, calls }));
|
|
"""
|
|
completed = subprocess.run(
|
|
[node_runtime(), "-e", script], check=True, capture_output=True, encoding="utf-8",
|
|
)
|
|
assert json.loads(completed.stdout) == {
|
|
"result": False,
|
|
"calls": ["请等待语音整理完成后再发送"],
|
|
}
|
|
|
|
|
|
def test_top_and_management_mode_cards_share_the_assistant_submission_entrypoint() -> None:
|
|
app = (WEB / "app.js").read_text(encoding="utf-8")
|
|
install_start = app.index('document.addEventListener("DOMContentLoaded"')
|
|
handlers = app[install_start:]
|
|
|
|
assert handlers.count("if (button) applyModeCommand(") == 2
|
|
assert '$(".mode-chips").addEventListener("click"' in handlers
|
|
assert '$("#modeGalleryGroups").addEventListener("click"' in handlers
|
|
assert app.count('button.dataset.modeCommand = `进入${mode.name}`;') == 2
|