188 lines
8.5 KiB
Python
188 lines
8.5 KiB
Python
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from web.static_files import NoCacheStaticFiles
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
STATIC_DIR = ROOT / "web" / "static"
|
|
DEV_HTML = STATIC_DIR / "dev.html"
|
|
UI_CSS = STATIC_DIR / "ui.css"
|
|
JS_DIR = STATIC_DIR / "js"
|
|
VENDOR_DIR = STATIC_DIR / "vendor" / "markdown"
|
|
MERMAID_VENDOR_DIR = STATIC_DIR / "vendor" / "mermaid"
|
|
|
|
|
|
def _frontend_source() -> str:
|
|
"""dev.html + 拆出的 ES module 源(路径 1 模块化后逻辑散落 js/*.js)合并文本。"""
|
|
parts = [DEV_HTML.read_text(encoding="utf-8")]
|
|
parts += [p.read_text(encoding="utf-8") for p in sorted(JS_DIR.glob("*.js"))]
|
|
return "\n".join(parts)
|
|
|
|
|
|
class StaticVendorTests(unittest.TestCase):
|
|
def test_dev_html_uses_local_markdown_vendor_assets(self) -> None:
|
|
html = DEV_HTML.read_text(encoding="utf-8")
|
|
|
|
self.assertNotIn("cdn.jsdelivr.net", html)
|
|
self.assertIn('src="vendor/markdown/marked.umd.js"', html)
|
|
self.assertIn('src="vendor/markdown/purify.min.js"', html)
|
|
self.assertIn('src="vendor/markdown/highlight.min.js"', html)
|
|
self.assertIn('src="vendor/mermaid/mermaid.min.js"', html)
|
|
self.assertIn('href="vendor/markdown/github.min.css"', html)
|
|
|
|
for name in (
|
|
"marked.umd.js",
|
|
"purify.min.js",
|
|
"highlight.min.js",
|
|
"github.min.css",
|
|
):
|
|
path = VENDOR_DIR / name
|
|
self.assertTrue(path.exists(), f"missing vendored asset: {path}")
|
|
self.assertGreater(path.stat().st_size, 0, f"empty vendored asset: {path}")
|
|
|
|
mermaid_bundle = MERMAID_VENDOR_DIR / "mermaid.min.js"
|
|
mermaid_license = MERMAID_VENDOR_DIR / "LICENSE.txt"
|
|
self.assertTrue(mermaid_bundle.exists(), f"missing vendored asset: {mermaid_bundle}")
|
|
self.assertGreater(mermaid_bundle.stat().st_size, 1_000_000)
|
|
self.assertTrue(mermaid_license.exists(), f"missing vendored license: {mermaid_license}")
|
|
|
|
def test_dev_html_uses_local_ui_foundation(self) -> None:
|
|
html = DEV_HTML.read_text(encoding="utf-8")
|
|
|
|
self.assertIn('href="ui.css"', html)
|
|
self.assertTrue(UI_CSS.exists(), f"missing UI foundation: {UI_CSS}")
|
|
css = UI_CSS.read_text(encoding="utf-8")
|
|
self.assertIn("--accent:", css)
|
|
self.assertIn(":focus-visible", css)
|
|
self.assertIn("@media (max-width: 640px)", css)
|
|
|
|
def test_hidden_directory_toggle_stays_visible(self) -> None:
|
|
html = DEV_HTML.read_text(encoding="utf-8")
|
|
files_js = (JS_DIR / "files.js").read_text(encoding="utf-8")
|
|
|
|
self.assertIn('id="btn-toggle-hidden" class="small"', html)
|
|
self.assertNotIn(
|
|
'id="btn-toggle-hidden" class="small" title="显示点目录" '
|
|
'aria-label="显示点目录" aria-pressed="false" hidden',
|
|
html,
|
|
)
|
|
self.assertNotIn("hiddenToggle.hidden", files_js)
|
|
self.assertIn('id="btn-new-folder" class="small"', html)
|
|
self.assertIn('$("btn-new-folder").onclick = createFolder;', files_js)
|
|
self.assertIn('await api("POST", "/v1/files/mkdir"', files_js)
|
|
|
|
def test_ui_polish_keeps_mobile_composer_and_reading_rail_contracts(self) -> None:
|
|
html = DEV_HTML.read_text(encoding="utf-8")
|
|
css = UI_CSS.read_text(encoding="utf-8")
|
|
frontend = _frontend_source()
|
|
|
|
self.assertIn("--list-divider:", css)
|
|
self.assertIn(".msg.assistant", css)
|
|
self.assertIn(".composer-tool .composer-label", css)
|
|
self.assertIn('aria-label="添加附件"', html)
|
|
self.assertIn("await ensureTaskFromDraft();", frontend)
|
|
self.assertIn('"[用户上传的文件]"', frontend)
|
|
self.assertNotIn("发送第一条消息创建对话后即可添加附件", frontend)
|
|
self.assertIn('aria-label="语音输入"', html)
|
|
self.assertIn('aria-label="润色提示词"', html)
|
|
self.assertIn("hasVisibleAssistantContent", frontend)
|
|
self.assertIn('input.placeholder = mqPhone.matches', frontend)
|
|
self.assertIn("#chat-hint:empty", css)
|
|
self.assertNotIn("请选择文件保存位置", frontend)
|
|
self.assertIn("function setMicButtonState(", frontend)
|
|
self.assertNotIn('_micBtn.textContent = "■ 停止"', frontend)
|
|
self.assertIn("const toolCallsById = new Map()", frontend)
|
|
self.assertIn("const runningDet = finishRunningTool(ctx)", frontend)
|
|
self.assertNotIn('<div class="role">科研助手</div>', frontend)
|
|
self.assertIn(".tool-call.activity > summary", css)
|
|
self.assertIn("#task-progress-dock .task-progress", css)
|
|
|
|
def test_frontend_surfaces_context_and_cache_stats(self) -> None:
|
|
src = _frontend_source()
|
|
|
|
self.assertIn("formatContextStats", src)
|
|
self.assertIn("context_original_chars", src)
|
|
self.assertIn("cache_hit_tokens", src)
|
|
|
|
def test_custom_task_name_is_optional_with_explicit_directory(self) -> None:
|
|
html = DEV_HTML.read_text(encoding="utf-8")
|
|
newtask_js = (JS_DIR / "newtask.js").read_text(encoding="utf-8")
|
|
|
|
self.assertIn("任务名(可选)", html)
|
|
self.assertIn("工作目录(必填)", html)
|
|
self.assertIn('id="nt-wd-combobox" class="new-chat-dir-combobox"', html)
|
|
self.assertIn('id="nt-wd-picker" role="combobox"', html)
|
|
self.assertNotIn('id="nt-wd-sel"', html)
|
|
self.assertNotIn('id="nt-wd-new"', html)
|
|
self.assertIn("if (name) body.name = name;", newtask_js)
|
|
self.assertNotIn("任务名为必填项", newtask_js)
|
|
|
|
def test_quick_new_conversation_can_choose_model(self) -> None:
|
|
chat_js = (JS_DIR / "chat.js").read_text(encoding="utf-8")
|
|
state_js = (JS_DIR / "state.js").read_text(encoding="utf-8")
|
|
html = DEV_HTML.read_text(encoding="utf-8")
|
|
|
|
self.assertIn('id="new-chat-model-sel"', chat_js)
|
|
self.assertIn("draftModelProfile", state_js)
|
|
self.assertIn("state.draftModelProfile = defaultDraftModelProfile();", chat_js)
|
|
self.assertIn('model_profile: state.draftModelProfile || ""', chat_js)
|
|
self.assertIn("function renderDraftChatMeta()", chat_js)
|
|
self.assertIn("${renderMediaModelTrigger()}", chat_js)
|
|
self.assertIn("bindMediaModelTrigger();", chat_js)
|
|
self.assertIn("#chat-meta.draft-meta { flex-wrap:nowrap; }", html)
|
|
self.assertIn(".mdl-wrap { white-space: nowrap; }", html)
|
|
self.assertNotIn("new-chat-model-row", chat_js)
|
|
self.assertIn("function positionDraftFolderOptions()", chat_js)
|
|
self.assertIn("#new-chat-dir-options { position:fixed;", html)
|
|
|
|
def test_embed_initial_task_skips_root_directory_bootstrap(self) -> None:
|
|
main_js = (JS_DIR / "main.js").read_text(encoding="utf-8")
|
|
embed_js = (JS_DIR / "embed.js").read_text(encoding="utf-8")
|
|
|
|
self.assertIn('export function enterApp(initialTaskId = "")', main_js)
|
|
self.assertIn("selectTask(initialTaskId);", main_js)
|
|
self.assertIn(
|
|
"showNewConversationDraft(); // 登录空态与“+ 新对话”共用同一草稿页",
|
|
main_js,
|
|
)
|
|
self.assertIn("enterApp(takeInitialTaskId());", embed_js)
|
|
self.assertNotIn("gotoInitialTask()", embed_js)
|
|
self.assertIn('state.token = "";', embed_js)
|
|
self.assertNotIn(
|
|
'if (state.token) {\n enterApp(takeInitialTaskId());',
|
|
embed_js,
|
|
)
|
|
|
|
def test_static_js_is_served_with_revalidation_header(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
static_dir = Path(tmp)
|
|
(static_dir / "app.js").write_text("export const ok = true;\n", encoding="utf-8")
|
|
app = FastAPI()
|
|
app.mount("/static", NoCacheStaticFiles(directory=str(static_dir)), name="static")
|
|
client = TestClient(app)
|
|
|
|
resp = client.get("/static/app.js")
|
|
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertEqual(resp.headers.get("cache-control"), "no-cache")
|
|
|
|
def test_ui_css_is_served_as_css_with_revalidation(self) -> None:
|
|
app = FastAPI()
|
|
app.mount("/static", NoCacheStaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
client = TestClient(app)
|
|
|
|
resp = client.get("/static/ui.css")
|
|
|
|
self.assertEqual(resp.status_code, 200)
|
|
self.assertTrue(resp.headers.get("content-type", "").startswith("text/css"))
|
|
self.assertEqual(resp.headers.get("cache-control"), "no-cache")
|
|
self.assertIn("--accent:", resp.text)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|