374 lines
19 KiB
Python
374 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def test_console_contains_ai_assistant_surface():
|
|
html = (ROOT / "apps/web-console/index.html").read_text(encoding="utf-8")
|
|
assert 'id="assistantTranscript"' in html
|
|
assert 'id="assistantMessages"' in html
|
|
assert "你好,我是 AI助手。你可以向我提问" in html
|
|
assert 'aria-label="AI助手对话"' in html
|
|
assert 'placeholder="向 AI助手提问或交代工作"' in html
|
|
assert 'aria-label="发送消息"' in html
|
|
assert 'href="/assistant-chat.css?v=codex-assistant-26"' in html
|
|
assert 'src="/assistant-chat.js?v=codex-assistant-26"' in html
|
|
|
|
|
|
def test_codex_assistant_assets_are_versioned_together_in_the_service_worker():
|
|
service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8")
|
|
assert 'const CACHE_VERSION = "cognitive-agent-pad-v277"' in service_worker
|
|
assert '"/assistant-chat.css?v=codex-assistant-26"' in service_worker
|
|
assert '"/assistant-chat.js?v=codex-assistant-26"' in service_worker
|
|
|
|
|
|
def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events():
|
|
script = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
|
|
assert '"/api/assistant/stream"' in script
|
|
assert 'Accept: "application/x-ndjson"' in script
|
|
assert '"action.started", "action.progress"' in script
|
|
assert 'event.type === "action.completed"' in script
|
|
assert 'event.type === "answer.delta"' in script
|
|
assert '"work.started", "work.progress"' in script
|
|
assert 'event.type === "work.completed"' in script
|
|
assert 'Object.prototype.hasOwnProperty.call(event, "answer")' in script
|
|
assert 'pendingController.abort("user_stop")' in script
|
|
assert 'signal.addEventListener("abort", cancelReader, { once: true })' in script
|
|
assert "reader.cancel(signal.reason)" in script
|
|
assert 'signal.removeEventListener("abort", cancelReader)' in script
|
|
assert "reader.releaseLock()" in script
|
|
assert 'if (!answerCompleted) throw new Error("助手响应意外中断,请重试。")' in script
|
|
assert "event.stopImmediatePropagation()" in script
|
|
assert "confirmation" not in script.lower()
|
|
|
|
|
|
def test_unsupported_control_suggestions_are_clickable_accessible_and_deduplicated():
|
|
script = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
|
|
css = (ROOT / "apps/web-console/assistant-chat.css").read_text(encoding="utf-8")
|
|
|
|
assert 'event.type === "control.suggestions"' in script
|
|
assert "event.suggestions || event.recommended_commands" in script
|
|
assert "function normalizeControlSuggestions(rawSuggestions)" in script
|
|
assert "seenCommands.has(commandKey)" in script
|
|
assert "MAX_CONTROL_SUGGESTIONS = 4" in script
|
|
assert "function appendControlSuggestions(message, rawSuggestions)" in script
|
|
assert 'message.article?.classList.add("assistant-message-control-suggestions")' in script
|
|
assert 'group.setAttribute("role", "group")' in script
|
|
assert 'group.setAttribute("aria-label", "可以尝试的其他控制指令")' in script
|
|
assert 'button.type = "button"' in script
|
|
assert 'button.setAttribute("aria-label", `执行控制指令:${suggestion.label}`)' in script
|
|
assert "submitSuggestedCommand(suggestion.command, group, button)" in script
|
|
assert "setSuggestionGroupPending(group, button, true)" in script
|
|
assert "await submitAssistant(command)" in script
|
|
assert ".assistant-control-suggestions" in css
|
|
assert ".assistant-message-control-suggestions .assistant-message-body" in css
|
|
assert ".assistant-control-suggestion:focus-visible" in css
|
|
assert ".assistant-control-suggestion.is-sending" in css
|
|
|
|
|
|
def test_codex_request_keeps_trusted_vehicle_context_without_legacy_model_routing():
|
|
script = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
|
|
request_body = script.split("function requestBody(message)", 1)[1].split(
|
|
"async function readErrorResponse", 1
|
|
)[0]
|
|
assert 'client_type: "web_console_codex_assistant"' in request_body
|
|
for field in (
|
|
"execution_mode",
|
|
"control_transport",
|
|
"vehicle_id",
|
|
"hardware_client_id",
|
|
"simulation_fault",
|
|
):
|
|
assert field in request_body
|
|
assert "history" not in request_body
|
|
assert "provider" not in request_body
|
|
assert "reasoning_effort" not in request_body
|
|
|
|
|
|
def test_starting_a_new_codex_conversation_rotates_the_conversation_id():
|
|
script = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
|
|
assert 'const CONVERSATION_STORAGE_KEY = "cognitive-agent.conversation-id"' in script
|
|
assert "function createConversationId()" in script
|
|
assert "sessionStorage.setItem(CONVERSATION_STORAGE_KEY, createConversationId())" in script
|
|
|
|
|
|
def test_assistant_uses_the_shared_top_right_dock_and_opens_by_default():
|
|
script = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
|
|
css = (ROOT / "apps/web-console/assistant-chat.css").read_text(encoding="utf-8")
|
|
assert 'trigger.id = "assistantDrawerToggle"' in script
|
|
assert 'drawer.id = "assistantDrawer"' in script
|
|
assert 'drawer.setAttribute("role", "dialog")' in script
|
|
assert 'drawer.setAttribute("aria-label", "AI助手")' in script
|
|
assert 'trigger.setAttribute("aria-controls", drawer.id)' in script
|
|
assert 'trigger.setAttribute("aria-pressed", "false")' in script
|
|
assert 'trigger.classList.toggle("active", open)' in script
|
|
assert 'trigger.setAttribute("aria-pressed", String(open))' in script
|
|
assert 'drawer.toggleAttribute("inert", !open)' in script
|
|
assert 'trigger.className = "assistant-drawer-toggle v2-panel-control-button"' in script
|
|
assert '($("#v2PanelControlDock") || document.body).append(trigger)' in script
|
|
assert 'id = "assistantDrawerClose"' not in script
|
|
assert "setDrawerOpen(true)" in script
|
|
assert 'event.key === "Escape"' in script
|
|
assert "#v2PanelControlDock > .assistant-drawer-toggle" in css
|
|
assert ".assistant-drawer-open .v2-operation-shell" in css
|
|
assert "position: fixed" in css
|
|
assert "position: relative" in css
|
|
assert "grid-template-rows: 56px auto minmax(0, 1fr)" in css
|
|
assert ".v2-output-shell" in css
|
|
|
|
|
|
def test_assistant_mode_creation_previews_then_saves_to_the_compact_mode_strip():
|
|
app = (ROOT / "apps/web-console/app.js").read_text(encoding="utf-8")
|
|
assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
|
|
css = (ROOT / "apps/web-console/assistant-chat.css").read_text(encoding="utf-8")
|
|
|
|
assert "function startModeCreation()" in assistant
|
|
assert 'input.value = "请帮我创建一个"' in assistant
|
|
assert "function appendModeDraftState(message, draft, actions)" in assistant
|
|
assert "function modeDraftRows(draft, rawActions)" in assistant
|
|
assert "event.payload.mode_profile_draft" in assistant
|
|
assert 'event.payload.orchestration_route === "mode_profile_create_preview"' in assistant
|
|
assert "appendModeDraftState(" in assistant
|
|
assert "保存为功能模式" in assistant
|
|
assert "先试运行" in assistant
|
|
|
|
save_flow = assistant.split(
|
|
"async function saveModeProfileDraft", 1
|
|
)[1].split("async function tryModeProfileDraft", 1)[0]
|
|
assert 'const existingModeId = String(draft.existing_mode_id || "").trim()' in save_flow
|
|
assert "encodeURIComponent(existingModeId)" in save_flow
|
|
assert '"/api/mode-profiles"' in save_flow
|
|
assert "request(endpoint" in save_flow
|
|
assert 'method: "POST"' in save_flow
|
|
for field in ("name", "instruction", "source_request_id", "vehicle_id"):
|
|
assert field in save_flow
|
|
assert "response.ok" in save_flow
|
|
assert "refreshModeProfiles?.(payload.mode_profile?.id)" in save_flow
|
|
assert "appendModeCreatedState(message, payload.mode_profile" in save_flow
|
|
assert "updated: Boolean(existingModeId)" in save_flow
|
|
assert "确认更新此模式" in assistant
|
|
assert "已载入现有功能模式" in assistant
|
|
for action_label in (
|
|
"座椅前后位置",
|
|
"坐垫角度",
|
|
"靠背角度",
|
|
"腿托高度",
|
|
"空调循环",
|
|
"内循环",
|
|
):
|
|
assert action_label in assistant
|
|
|
|
assert "tryModeDraft?.({ ...draft, actions })" in assistant
|
|
assert '["mode_profile_create", "mode_profile_update"].includes(' in assistant
|
|
assert "appendModeCreatedState(message, event.payload.mode_profile, {" in assistant
|
|
assert (
|
|
'updated: event.payload.orchestration_route === "mode_profile_update"'
|
|
in assistant
|
|
)
|
|
assert "refreshModeProfiles?.(" in assistant
|
|
assert "revealModeProfile?.(String(profile.id), { focus: true })" in assistant
|
|
assert "function revealModeProfile(modeProfileId" in app
|
|
assert "refreshModeProfiles" in app
|
|
assert "tryModeDraft" in app
|
|
assert "const actionSignature = (action) =>" in app
|
|
assert 'apiRequest("/api/mode-profile-drafts/trial"' in app
|
|
assert "source_request_id: draft.source_request_id" in app
|
|
assert "const expectedSignatures = Array.isArray(draft?.actions)" in app
|
|
assert "signature === actualSignatures[index]" in app
|
|
assert "|| !sameActions" in app
|
|
assert 'button.classList.add("just-created")' in app
|
|
assert 'window.addEventListener("cognitive-agent:request-complete"' in app
|
|
assert ".assistant-mode-draft" in css
|
|
assert ".assistant-mode-draft-row" in css
|
|
assert ".assistant-mode-draft-save" in css
|
|
assert ".assistant-mode-draft-status.failed" in css
|
|
assert ".assistant-mode-created" in css
|
|
|
|
|
|
def test_assistant_carries_only_the_active_mode_draft_reference_to_follow_up_turns():
|
|
assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
|
|
request_body = assistant.split("function requestBody(message)", 1)[1].split(
|
|
"async function readErrorResponse", 1
|
|
)[0]
|
|
context_flow = assistant.split("function sanitizeModeDraftContext", 1)[1].split(
|
|
"function loadStoredMessages", 1
|
|
)[0]
|
|
|
|
assert (
|
|
'const ACTIVE_MODE_DRAFT_STORAGE_KEY = "cognitive-agent.active-mode-draft"'
|
|
in assistant
|
|
)
|
|
assert "mode_profile_draft_context: modeProfileDraftContext" in request_body
|
|
assert "requestModeDraftContext(message)" in request_body
|
|
assert "name: context.name" in context_flow
|
|
assert "source_request_id: context.source_request_id" in context_flow
|
|
assert "vehicle_id: context.vehicle_id" in context_flow
|
|
assert "actions" not in context_flow
|
|
assert "rememberModeDraftContext(draft)" in assistant
|
|
assert "activeModeDraftContext = loadActiveModeDraftContext()" in assistant
|
|
assert "function isModeDraftUpdateRequest(message, context)" in assistant
|
|
assert "normalizeModeName(matched[1]) === normalizeModeName(context.name)" in assistant
|
|
|
|
|
|
def test_latest_same_name_mode_draft_retires_stale_card_and_lifecycle_clears_context():
|
|
assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
|
|
css = (ROOT / "apps/web-console/assistant-chat.css").read_text(encoding="utf-8")
|
|
replace_flow = assistant.split("function supersedeModeDraftCards", 1)[1].split(
|
|
"function appendModeDraftState", 1
|
|
)[0]
|
|
save_flow = assistant.split("async function saveModeProfileDraft", 1)[1].split(
|
|
"async function tryModeProfileDraft", 1
|
|
)[0]
|
|
new_conversation_flow = assistant.split(
|
|
'if (!target?.closest("#newConversation")) return;', 1
|
|
)[1].split("}, true);", 1)[0]
|
|
|
|
assert "card.dataset.assistantModeName !== nextName" in replace_flow
|
|
assert "card.dataset.assistantModeDraft === nextRequestId" in replace_flow
|
|
assert 'card.classList.add("is-superseded")' in replace_flow
|
|
assert "button.disabled = true" in replace_flow
|
|
assert "已由下方最新草稿替换" in replace_flow
|
|
assert "clearActiveModeDraftContext(draft.source_request_id)" in save_flow
|
|
assert "clearActiveModeDraftContext()" in new_conversation_flow
|
|
assert ".assistant-mode-draft.is-superseded" in css
|
|
assert ".assistant-mode-draft-status.superseded" in css
|
|
|
|
|
|
def test_assistant_composer_grows_to_a_cap_then_scrolls_its_own_content():
|
|
assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
|
|
css = (ROOT / "apps/web-console/assistant-chat.css").read_text(encoding="utf-8")
|
|
|
|
resize_flow = assistant.split(
|
|
"function resizeComposerInput", 1
|
|
)[1].split("function createMessage", 1)[0]
|
|
assert "const COMPOSER_MIN_HEIGHT = 64" in assistant
|
|
assert "const COMPOSER_MAX_HEIGHT = 116" in assistant
|
|
assert 'input.style.height = "auto"' in resize_flow
|
|
assert "input.scrollHeight" in resize_flow
|
|
assert "Math.min(contentHeight, COMPOSER_MAX_HEIGHT)" in resize_flow
|
|
assert 'input.style.height = `${nextHeight}px`' in resize_flow
|
|
assert 'input.classList.toggle("is-scrollable", scrollable)' in resize_flow
|
|
assert "if (!scrollable) input.scrollTop = 0" in resize_flow
|
|
assert 'input.addEventListener("input", () => resizeComposerInput(input))' in assistant
|
|
assert "resizeComposerInput(input)" in assistant
|
|
|
|
composer_styles = css.split(
|
|
".v2-request-form .command-composer", 1
|
|
)[1].split("}", 1)[0]
|
|
assert "height: auto" in composer_styles
|
|
input_styles = css.split(
|
|
".v2-request-form #utterance", 1
|
|
)[1].split("}", 1)[0]
|
|
assert "max-height: 116px" in input_styles
|
|
assert "overflow-y: hidden" in input_styles
|
|
scrollable_styles = css.split("#utterance.is-scrollable", 1)[1].split("}", 1)[0]
|
|
assert "overflow-y: auto" in scrollable_styles
|
|
assert "scrollbar-width: thin" in scrollable_styles
|
|
assert "overscroll-behavior: contain" in scrollable_styles
|
|
|
|
|
|
def test_assistant_composer_keeps_the_microphone_visible_and_aligned():
|
|
html = (ROOT / "apps/web-console/index.html").read_text(encoding="utf-8")
|
|
css = (ROOT / "apps/web-console/assistant-chat.css").read_text(encoding="utf-8")
|
|
assert 'id="speechDictationButton"' in html
|
|
assert 'data-lucide="mic"' in html
|
|
assert ".v2-request-form .speech-dictation" in css
|
|
assert "right: 53px" in css
|
|
assert "bottom: auto" in css
|
|
assert "display: grid !important" in css
|
|
assert "transform: translateY(-50%)" in css
|
|
assert ".v2-request-form .speech-status:empty" in css
|
|
|
|
|
|
def test_voice_input_supports_direct_manual_capture_and_background_wake_standby():
|
|
html = (ROOT / "apps/web-console/index.html").read_text(encoding="utf-8")
|
|
script = (ROOT / "apps/web-console/speech-input.js").read_text(encoding="utf-8")
|
|
assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
|
|
|
|
assert 'aria-label="开始语音输入"' in html
|
|
assert 'const DEFAULT_WAKE_WORD = "你好,小迪"' in script
|
|
assert 'new Set(["arming", "armed", "capturing", "finalizing"])' in script
|
|
assert "async function startManualDictation()" in script
|
|
assert "async function startWakeStandby({ automatic = true } = {})" in script
|
|
assert 'return startCapture("manual")' in script
|
|
assert 'return startCapture("wake", { automatic })' in script
|
|
assert 'else void startManualDictation()' in script
|
|
assert 'const manualInput = speech.inputMode === "manual"' in script
|
|
assert "const detectedWake = manualInput || wakeWord.detected === true" in script
|
|
assert 'fetch("/api/speech/preview"' in script
|
|
assert '"X-Speech-Preview-Sequence"' in script
|
|
assert '"X-Speech-Input-Mode": speech.inputMode' in script
|
|
assert 'result.stale === true' in script
|
|
assert 'sequence <= speech.lastAppliedPreviewSequence' in script
|
|
assert "wakeWord.remainder ?? result.live_text" in script
|
|
assert "status.wake_word.primary" in script
|
|
assert 'if (speech.wakeDetected && liveText)' in script
|
|
assert 'replaceInput(liveText, "partial")' in script
|
|
assert '"X-Speech-Input-Mode": inputMode' in script
|
|
assert '"X-Speech-Wake-Word-Required": inputMode === "wake" ? "1" : "0"' in script
|
|
assert (
|
|
"result.assistant_text || result.clean_text || result.live_text || "
|
|
"result.raw_text || result.text"
|
|
) in script
|
|
assert "commands.length" not in script
|
|
assert "input.dataset.speechExecutionEligible =" not in script
|
|
assert "void speech.setAssistantOpen(open)" in assistant
|
|
assert '["capturing", "finalizing"].includes(speechPhase)' in assistant
|
|
assert 'speechPhase === "arming" && speechInputMode === "manual"' in assistant
|
|
assert "请等待语音整理完成后再发送" in assistant
|
|
|
|
|
|
def test_voice_lifecycle_releases_hidden_or_collapsed_microphone():
|
|
script = (ROOT / "apps/web-console/speech-input.js").read_text(encoding="utf-8")
|
|
assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
|
|
|
|
assert 'document.addEventListener("visibilitychange"' in script
|
|
assert "ACTIVE_PHASES.has(speech.phase)" in script
|
|
assert 'window.addEventListener("pagehide"' in script
|
|
assert "speech.stream?.getTracks().forEach((track) => track.stop())" in script
|
|
assert "speech.previewController?.abort" in script
|
|
assert "speech.finalController?.abort" in script
|
|
assert 'typeof speech?.setAssistantOpen === "function"' in assistant
|
|
assert "void speech.setAssistantOpen(open)" in assistant
|
|
|
|
|
|
def test_model_text_is_rendered_through_text_nodes_not_raw_html():
|
|
script = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
|
|
assert "document.createTextNode(part)" in script
|
|
assert "code.textContent =" in script
|
|
assert "contentNode.textContent = content" in script
|
|
assert "innerHTML = content" not in script
|
|
|
|
|
|
def test_chat_styles_include_accessible_responsive_and_reduced_motion_states():
|
|
css = (ROOT / "apps/web-console/assistant-chat.css").read_text(encoding="utf-8")
|
|
assert ".assistant-message-user" in css
|
|
assert ".assistant-action-status.success" in css
|
|
assert "@media (max-width: 900px)" in css
|
|
assert "@media (max-width: 560px)" in css
|
|
assert ".v2-unified-right-rail" in css
|
|
assert "grid-template-columns: minmax(0, 1fr) !important" in css
|
|
assert "@media (prefers-reduced-motion: reduce)" in css
|
|
|
|
|
|
def test_assistant_does_not_restore_collapsed_sidebars_and_uses_mobile_overlays():
|
|
script = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8")
|
|
css = (ROOT / "apps/web-console/assistant-chat.css").read_text(encoding="utf-8")
|
|
assert "--assistant-drawer-width: clamp(340px, 26vw, 410px);" in css
|
|
assert "@media (min-width: 901px)" in css
|
|
assert ".assistant-drawer-open.v2-sidebar-collapsed .workspace" in css
|
|
assert "grid-template-columns: 0 minmax(0, 1fr);" in css
|
|
assert ".assistant-drawer-open.v2-sidebar-collapsed .command-panel.v2-sidebar" in css
|
|
assert "visibility: hidden;" in css
|
|
assert "grid-template-columns: var(--assistant-drawer-width) minmax(0, 1fr);" in css
|
|
assert "#v2PanelControlDock > .assistant-drawer-toggle" in css
|
|
assert "opacity: 1 !important;" in css
|
|
assert "pointer-events: auto !important;" in css
|
|
assert "@media (max-width: 900px)" in css
|
|
assert "width: min(86vw, 320px);" in css
|
|
assert "width: min(86vw, 360px);" in css
|
|
assert "top: 60px;" in css
|
|
assert 'event.key === "Escape"' in script
|