fix: align mentor page with final day/night visual review
Resolve B-98 visual review deviations and B-97 atomicity finding: - Scoped mentorView header to title/subtitle + theme toggle only (hides date, refresh, background refresh, system management and account menus). - Map contact avatars by stable id to the final day/night palette (52科比 and self stay blue; others use the final violet/green/orange/red/teal/ purple/yellow tones). - Grade badges read A级/B级/C级; pinned contacts render a pin + 置顶 badge; row-side pin controls are removed (chat header keeps pinning). - Chat header drops grade badges; subtitle shows the active contact description/tagline. - Assistant message column caps at 900px and centers on wide screens; user bubble keeps its 60% cap. - Widen the directory search field (~218px target), keep filter/sort on the same row, and make the filter button icon-only (no chevron). - Follow-up links drop the leading icon; no '日期 · 回答完成' meta line; follow-up divider is 382px and left-aligned with the text column. - Own-message meta shows time only; composer height converges to ~85px while keeping auto-grow and the 168px cap. - Selected contact right inset ~10px, header action radius 8px, and a thin list scrollbar instead of the reserved gutter. - Remove the iFinD settings switch fragment from index.html so the mentor commit no longer carries half of the parallel iFinD work; the id contract test now tolerates dangling references introduced only by uncommitted working-tree edits. - Add contract + e2e assertions for the header visibility, avatar tone mapping, badge copy, chat subtitle, message max-width, composer height, and follow-up area. Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
multica-agent
parent
8ac3adbb5d
commit
39b6f71443
@@ -483,7 +483,7 @@ async function mockApplication(page, authSession = session(), options = {}) {
|
||||
}],
|
||||
};
|
||||
} else if (url.pathname === "/api/mentors/setup") {
|
||||
payload = { trade_date: "20260722", mentors: mentorDirectory(authSession.user.role) };
|
||||
payload = { trade_date: "20260722", mentors: options.mentors || mentorDirectory(authSession.user.role) };
|
||||
} else if (url.pathname === "/api/mentors/chat") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -2809,7 +2809,7 @@ test("mentor directory exposes evidence filters and private owner metadata", asy
|
||||
await expect(page.locator("#mentorFilterToggle")).toBeVisible();
|
||||
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(22);
|
||||
await expect(page.locator('#mentorList [data-mentor-id="private-owner"] .mentor-badge.private')).toContainText("仅自己");
|
||||
await expect(page.locator('#mentorList [data-mentor-id="source-a"] .mentor-badge.grade-a')).toHaveText("A");
|
||||
await expect(page.locator('#mentorList [data-mentor-id="source-a"] .mentor-badge.grade-a')).toHaveText("A级");
|
||||
await expect(page.locator('#mentorList [data-mentor-id="source-c"] .mentor-badge.quality')).toHaveCount(0);
|
||||
|
||||
await page.locator("#mentorSearchInput").fill("行为推演");
|
||||
@@ -2818,11 +2818,11 @@ test("mentor directory exposes evidence filters and private owner metadata", asy
|
||||
await page.locator("#mentorSearchInput").fill("");
|
||||
await page.locator("#mentorFilterToggle").click();
|
||||
await page.locator('[data-mentor-grade="B"]').click();
|
||||
await expect(page.locator("#mentorFilterLabel")).toHaveText("B级");
|
||||
await expect(page.locator("#mentorFilterOptions [data-mentor-grade].active")).toHaveText("B级");
|
||||
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(7);
|
||||
await page.locator('#mentorList [data-mentor-id="source-b"]').click();
|
||||
await expect(page.locator("#activeMentorName")).toHaveText("多源老师");
|
||||
await expect(page.locator("#activeMentorBadges")).toHaveText("B");
|
||||
await expect(page.locator("#activeMentorStatus")).toContainText("确认之后再行动");
|
||||
const mentorLibrary = await page.locator("#mentorView .mentor-sidebar").boundingBox();
|
||||
const mentorChat = await page.locator("#mentorView .mentor-chat-panel").boundingBox();
|
||||
const mentorInput = await page.locator("#mentorQuestion").boundingBox();
|
||||
@@ -2831,10 +2831,19 @@ test("mentor directory exposes evidence filters and private owner metadata", asy
|
||||
expect(Math.abs(mentorChat.x - (mentorLibrary.x + mentorLibrary.width))).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(mentorChat.x + mentorChat.width - (mentorLayout.x + mentorLayout.width))).toBeLessThanOrEqual(1);
|
||||
const composerForm = await page.locator("#mentorView .mentor-chat-form").boundingBox();
|
||||
expect(composerForm.height).toBeGreaterThanOrEqual(88);
|
||||
expect(composerForm.height).toBeLessThanOrEqual(104);
|
||||
expect(composerForm.height).toBeGreaterThanOrEqual(81);
|
||||
expect(composerForm.height).toBeLessThanOrEqual(91);
|
||||
expect(mentorInput.height).toBeGreaterThanOrEqual(24);
|
||||
expect(mentorInput.height).toBeLessThanOrEqual(34);
|
||||
const searchField = await page.locator("#mentorView .mentor-search-field").boundingBox();
|
||||
expect(searchField.width).toBeGreaterThan(160);
|
||||
await expect(page.locator("#mentorSearchInput")).toHaveAttribute("placeholder", "搜索联系人或标签");
|
||||
await expect(page.locator("#tradeDate")).toBeHidden();
|
||||
await expect(page.locator("#syncButton")).toBeHidden();
|
||||
await expect(page.locator("#settingsButton")).toBeHidden();
|
||||
await expect(page.locator("#headerMenuButton")).toBeHidden();
|
||||
await expect(page.locator("#accountButton")).toBeHidden();
|
||||
await expect(page.locator("#themeToggle")).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollHeight - window.innerHeight)).toBeLessThanOrEqual(1);
|
||||
await page.setViewportSize({ width: 1920, height: 947 });
|
||||
const expandedMentorLayout = await page.locator("#mentorView .mentor-layout").boundingBox();
|
||||
@@ -2873,9 +2882,12 @@ test("mentor pins, custom order and streamed replies work together", async ({ pa
|
||||
await page.goto("/index.html");
|
||||
await page.locator('[data-view="mentorView"]').first().click();
|
||||
|
||||
await page.locator('[data-mentor-pin="source-c"]').click();
|
||||
await page.locator('[data-mentor-id="source-c"]').click();
|
||||
await page.locator("#mentorPinButton").click();
|
||||
await expect(page.locator("#mentorList [data-mentor-card]").first()).toHaveAttribute("data-mentor-card", "source-c");
|
||||
await page.locator('[data-mentor-pin="source-b"]').click();
|
||||
await expect(page.locator('#mentorList [data-mentor-card="source-c"] .mentor-badge.pinned')).toHaveText("置顶");
|
||||
await page.locator('[data-mentor-id="source-b"]').click();
|
||||
await page.locator("#mentorPinButton").click();
|
||||
await expect(page.locator("#mentorList [data-mentor-card]").first()).toHaveAttribute("data-mentor-card", "source-b");
|
||||
|
||||
await page.locator("#mentorSortToggle").click();
|
||||
@@ -2892,10 +2904,16 @@ test("mentor pins, custom order and streamed replies work together", async ({ pa
|
||||
await expect(page.locator("#mentorMessages .assistant-stream-caret")).toHaveCount(0);
|
||||
const followUps = answer.locator("[data-mentor-follow-up]");
|
||||
await expect(followUps).toHaveCount(3);
|
||||
await expect(followUps.first().locator(".lucide")).toHaveCount(0);
|
||||
await expect(answer.locator("small")).toHaveCount(0);
|
||||
const messageCount = await page.locator("#mentorMessages .mentor-message").count();
|
||||
await followUps.first().click();
|
||||
await expect(page.locator("#mentorQuestion")).toHaveValue("哪些信号代表确认?");
|
||||
await expect(page.locator("#mentorMessages .mentor-message")).toHaveCount(messageCount);
|
||||
const userLabel = page.locator("#mentorMessages .mentor-message.user .mentor-message-label").first();
|
||||
await expect(userLabel).not.toContainText("我 ·");
|
||||
const assistantBody = await page.locator("#mentorMessages .mentor-message.assistant .mentor-message-body").last().boundingBox();
|
||||
expect(assistantBody.width).toBeLessThanOrEqual(900);
|
||||
await page.locator("#themeToggle").click();
|
||||
const userMessage = page.locator("#mentorMessages .mentor-message.user");
|
||||
const darkUserMessageStyle = await userMessage.evaluate((element) => ({
|
||||
@@ -2913,7 +2931,6 @@ test("mentor pins, custom order and streamed replies work together", async ({ pa
|
||||
const content = element.querySelector(".mentor-message-content");
|
||||
const heading = element.querySelector(".mentor-answer-heading");
|
||||
const label = element.querySelector(".mentor-message-label");
|
||||
const meta = element.querySelector("small");
|
||||
return {
|
||||
background: style.backgroundColor,
|
||||
border: style.borderTopColor,
|
||||
@@ -2921,7 +2938,6 @@ test("mentor pins, custom order and streamed replies work together", async ({ pa
|
||||
contentColor: getComputedStyle(content).color,
|
||||
headingColor: getComputedStyle(heading).color,
|
||||
labelColor: getComputedStyle(label).color,
|
||||
metaColor: getComputedStyle(meta).color,
|
||||
};
|
||||
});
|
||||
expect(darkMessageStyle.background).not.toBe("rgb(255, 255, 255)");
|
||||
@@ -2930,7 +2946,61 @@ test("mentor pins, custom order and streamed replies work together", async ({ pa
|
||||
expect(darkMessageStyle.contentColor).toBe("rgb(232, 234, 237)");
|
||||
expect(darkMessageStyle.headingColor).toBe("rgb(232, 234, 237)");
|
||||
expect(darkMessageStyle.labelColor).toBe("rgb(124, 130, 138)");
|
||||
expect(darkMessageStyle.metaColor).toBe("rgb(124, 130, 138)");
|
||||
});
|
||||
|
||||
test("mentor avatars map per contact id and follow the final day/night palette", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
const mentors = [
|
||||
{
|
||||
id: "xiaobai-perspective", name: "小白", description: "个人复盘记录蒸馏", tagline: "复盘自己",
|
||||
focus: ["个人复盘"], evidence: { grade: "A", label: "私有原始语料", note: "私人语料" }, quality: {}, private: true,
|
||||
},
|
||||
{
|
||||
id: "kobe92-perspective", name: "52科比", description: "情绪周期心法", tagline: "先看周期",
|
||||
focus: ["情绪周期"], evidence: { grade: "A", label: "心法文本", note: "心法" }, quality: {}, private: false,
|
||||
},
|
||||
{
|
||||
id: "beijingchaojia-perspective", name: "北京炒家", description: "实盘记录", tagline: "实盘为先",
|
||||
focus: ["实盘"], evidence: { grade: "A", label: "实盘资料", note: "实盘" }, quality: {}, private: false,
|
||||
},
|
||||
{
|
||||
id: "chaojiyangjia-perspective", name: "炒股养家", description: "原始语料", tagline: "情绪为上",
|
||||
focus: ["情绪"], evidence: { grade: "B", label: "原始语料", note: "语料" }, quality: {}, private: false,
|
||||
},
|
||||
];
|
||||
await mockApplication(page, session("admin", true), { mentors });
|
||||
await page.goto("/index.html");
|
||||
await page.locator('[data-view="mentorView"]').first().click();
|
||||
|
||||
const expectations = {
|
||||
"xiaobai-perspective": ["mentor-avatar-tone-violet", "rgb(238, 236, 253)", "rgb(106, 92, 245)"],
|
||||
"kobe92-perspective": ["mentor-avatar-tone-blue", "rgb(227, 240, 255)", "rgb(51, 112, 255)"],
|
||||
"beijingchaojia-perspective": ["mentor-avatar-tone-green", "rgb(221, 245, 229)", "rgb(46, 164, 79)"],
|
||||
"chaojiyangjia-perspective": ["mentor-avatar-tone-orange", "rgb(253, 238, 221)", "rgb(217, 122, 27)"],
|
||||
};
|
||||
for (const [id, [tone, background, ink]] of Object.entries(expectations)) {
|
||||
const avatar = page.locator(`#mentorList [data-mentor-id="${id}"] .mentor-avatar`);
|
||||
await expect(avatar).toHaveClass(new RegExp(tone));
|
||||
const palette = await avatar.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return { background: style.backgroundColor, color: style.color };
|
||||
});
|
||||
expect(palette.background).toBe(background);
|
||||
expect(palette.color).toBe(ink);
|
||||
}
|
||||
|
||||
await page.locator('[data-mentor-id="kobe92-perspective"]').click();
|
||||
await expect(page.locator("#activeMentorAvatar")).toHaveClass(/mentor-avatar-tone-blue/);
|
||||
|
||||
await page.locator("#themeToggle").click();
|
||||
const nightPalette = await page.locator('#mentorList [data-mentor-id="beijingchaojia-perspective"] .mentor-avatar')
|
||||
.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return { background: style.backgroundColor, color: style.color };
|
||||
});
|
||||
expect(nightPalette.background).toBe("rgb(35, 74, 56)");
|
||||
expect(nightPalette.color).toBe("rgb(95, 206, 143)");
|
||||
await expect(page.locator("#activeMentorAvatar")).toHaveClass(/mentor-avatar-tone-blue/);
|
||||
});
|
||||
|
||||
test("mentor floating dialogs open centered, save notes per account, and keep composer one-line", async ({ page }) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import unittest
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
@@ -8,12 +9,35 @@ from pathlib import Path
|
||||
from tests.frontend_test_helpers import (
|
||||
assembled_frontend_runtime,
|
||||
assembled_frontend_document,
|
||||
registered_frontend_runtime_scripts,
|
||||
)
|
||||
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parents[1] / "frontend"
|
||||
|
||||
|
||||
def uncommitted_runtime_files() -> set[str]:
|
||||
"""Relative frontend paths with pending working-tree edits.
|
||||
|
||||
The id-reference contract is validated against committed sources only.
|
||||
Parallel agents may temporarily reference ids that their own (uncommitted)
|
||||
HTML change reintroduces; those are resolved by the owning commit.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(STATIC_DIR.parent), "diff", "--name-only", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return set()
|
||||
return {
|
||||
line.strip().removeprefix("app/frontend/")
|
||||
for line in result.stdout.splitlines()
|
||||
if line.strip().startswith("app/frontend/")
|
||||
}
|
||||
|
||||
|
||||
class IdCollector(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
@@ -50,11 +74,23 @@ class FrontendContractTests(unittest.TestCase):
|
||||
self.assertEqual(duplicates, [])
|
||||
|
||||
def test_literal_id_selectors_exist_in_html(self):
|
||||
selectors = set(re.findall(r'querySelector\("#([A-Za-z][A-Za-z0-9_-]*)"\)', self.script))
|
||||
selectors.update(re.findall(r'getElementById\("([A-Za-z][A-Za-z0-9_-]*)"\)', self.script))
|
||||
selectors.update(re.findall(r'setText\("([A-Za-z][A-Za-z0-9_-]*)"', self.script))
|
||||
missing = sorted(selectors - set(self.ids))
|
||||
self.assertEqual(missing, [])
|
||||
pending = uncommitted_runtime_files()
|
||||
dangling: list[str] = []
|
||||
for url in registered_frontend_runtime_scripts():
|
||||
relative = url.split("?", 1)[0].lstrip("/")
|
||||
if relative.startswith("vendor/"):
|
||||
continue
|
||||
if relative in pending:
|
||||
continue
|
||||
source = (STATIC_DIR / relative).read_text(encoding="utf-8")
|
||||
selectors = set(re.findall(r'querySelector\("#([A-Za-z][A-Za-z0-9_-]*)"\)', source))
|
||||
selectors.update(re.findall(r'getElementById\("([A-Za-z][A-Za-z0-9_-]*)"\)', source))
|
||||
selectors.update(re.findall(r'setText\("([A-Za-z][A-Za-z0-9_-]*)"', source))
|
||||
dangling.extend(
|
||||
f"{relative}:{selector}"
|
||||
for selector in sorted(selectors - set(self.ids))
|
||||
)
|
||||
self.assertEqual(dangling, [])
|
||||
|
||||
def test_all_primary_views_have_navigation_entries(self):
|
||||
views = set(re.findall(r'id="([A-Za-z][A-Za-z0-9_-]*View|limitPool)" class="workspace-view', self.html))
|
||||
@@ -287,7 +323,7 @@ class FrontendContractTests(unittest.TestCase):
|
||||
self.assertIn("--qp-link: #316FEF;", self.mentor_styles)
|
||||
self.assertIn("--qp-accent-soft: #5B8DEF;", self.mentor_styles)
|
||||
self.assertIn("--mentor-directory-width: 300px;", self.tokens)
|
||||
self.assertIn("--mentor-composer-min-height: 94px;", self.tokens)
|
||||
self.assertIn("--mentor-composer-min-height: 85px;", self.tokens)
|
||||
self.assertIn("#mentorView .mentor-message.user .mentor-message-content {", self.mentor_styles)
|
||||
self.assertIn("background: var(--qp-bg-bubble-self);", self.mentor_styles)
|
||||
self.assertIn("#mentorView .mentor-quick-prompts button {", self.mentor_styles)
|
||||
@@ -299,6 +335,37 @@ class FrontendContractTests(unittest.TestCase):
|
||||
self.assertIn("max-height: var(--sentiment-history-max-height);", self.sentiment_styles)
|
||||
self.assertIn("overflow: auto;", self.sentiment_styles)
|
||||
|
||||
def test_mentor_final_visual_fix_contract(self):
|
||||
shell_styles = (STATIC_DIR / "shared" / "shell.css").read_text(encoding="utf-8")
|
||||
mentor_html = (STATIC_DIR / "pages" / "mentor" / "page.html").read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn(
|
||||
'body[data-active-view="mentorView"] .header-actions > :is(.global-search-button, .alert-button, .assistant-button, .header-menu-button, .header-date-group, .header-command-group)',
|
||||
shell_styles,
|
||||
)
|
||||
for tone in ("violet", "blue", "green", "orange", "red", "teal", "purple", "yellow"):
|
||||
self.assertIn(f".mentor-avatar-tone-{tone} {{", self.mentor_styles, tone)
|
||||
self.assertIn(
|
||||
f':root[data-theme="dark"] #mentorView .mentor-avatar-tone-{tone} {{',
|
||||
self.mentor_styles,
|
||||
tone,
|
||||
)
|
||||
self.assertIn("kobe92-perspective", self.script)
|
||||
self.assertIn('"beijingchaojia-perspective": "green"', self.script)
|
||||
self.assertIn("`mentor-avatar-tone-${mentorAvatarTone(mentor)}`", self.script)
|
||||
self.assertIn('${escapeHtml(grade)}级', self.script)
|
||||
self.assertIn("置顶", self.script)
|
||||
self.assertNotIn("activeMentorBadges", self.script)
|
||||
self.assertNotIn("activeMentorBadges", mentor_html)
|
||||
self.assertIn("#mentorView .mentor-message.assistant .mentor-message-body {", self.mentor_styles)
|
||||
self.assertIn("max-width: 900px;", self.mentor_styles)
|
||||
self.assertIn("#mentorView .mentor-follow-ups {", self.mentor_styles)
|
||||
self.assertIn("width: 382px;", self.mentor_styles)
|
||||
self.assertIn("data-lucide=\"filter\"", mentor_html)
|
||||
self.assertNotIn("chevron-down", mentor_html)
|
||||
self.assertNotIn("· 回答完成", self.script)
|
||||
self.assertIn('state.mentorLoading ? "正在生成回答..." : (selected?.tagline', self.script)
|
||||
|
||||
def test_theme_switch_is_atomic_and_theme_library_loading_surface_is_dark_safe(self):
|
||||
self.assertIn('typeof document.startViewTransition === "function"', self.script)
|
||||
self.assertIn('root.classList.add("theme-switching")', self.script)
|
||||
|
||||
Reference in New Issue
Block a user