From f8a7880da3805aa4e6fea94cdbd6cf48844dbb17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 29 Sep 2026 15:37:37 +0800 Subject: [PATCH] feat: add creative welcome and external works square links --- .../20260929-community-welcome-c724e5a1.md | 61 +++++++++++++ README.md | 2 +- .../community/CommunityInvitation.tsx | 8 ++ src/components/community/CommunityLink.tsx | 24 +++++ src/components/community/community.css | 36 ++++++++ src/components/layout/Sidebar.tsx | 2 + src/lib/community.ts | 8 ++ src/pages/Chat/CodingChatBody.tsx | 29 ++++++ src/pages/Chat/CodingChatPanel.tsx | 10 ++- src/pages/Chat/CodingStartHeading.tsx | 11 +++ src/pages/Chat/TeacherChatPanel.tsx | 6 +- tests/e2e/community-welcome.spec.ts | 90 +++++++++++++++++++ tests/e2e/fixtures/community-welcome.tsx | 51 +++++++++++ tests/e2e/pi-coding-first-chat.spec.ts | 19 +++- tests/e2e/teacher-reply-layout.spec.ts | 10 +-- tests/unit/coding-chat-panel.test.tsx | 15 +++- tests/unit/coding-teacher-ui.test.tsx | 54 +++++------ tests/unit/community.test.tsx | 33 +++++++ 18 files changed, 428 insertions(+), 41 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260929-community-welcome-c724e5a1.md create mode 100644 src/components/community/CommunityInvitation.tsx create mode 100644 src/components/community/CommunityLink.tsx create mode 100644 src/components/community/community.css create mode 100644 src/lib/community.ts create mode 100644 src/pages/Chat/CodingChatBody.tsx create mode 100644 src/pages/Chat/CodingStartHeading.tsx create mode 100644 tests/e2e/community-welcome.spec.ts create mode 100644 tests/e2e/fixtures/community-welcome.tsx create mode 100644 tests/unit/community.test.tsx diff --git a/.project-docs/30-worklog/tasks/20260929-community-welcome-c724e5a1.md b/.project-docs/30-worklog/tasks/20260929-community-welcome-c724e5a1.md new file mode 100644 index 00000000..5b7bbf65 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260929-community-welcome-c724e5a1.md @@ -0,0 +1,61 @@ +# Task: Add community inspiration to the creative welcome screen and update trial + +## Identity + +- Task ID: 20260929-community-welcome-c724e5a1 +- Mode: Feature +- Branch: codex/20260929-community-welcome-c724e5a1-community-welcome +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260929-community-welcome-c724e5a1 +- Base commit: 4199cadd1e067ce9741d6496196f4d13fed61d12 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Implement a creative empty-state and explicit external Works Square links in the existing temporary application. Latest user correction: do not fetch or surface works from the square, and do not embed the square. + +## Intent And Constraints + +- User authorizes the previous community design. No subagents, desktop focus changes, cloud publication, paid model calls, formal-app installation or remote push. Preserve project selection, drafts, history and teacher three-round introduction behavior. Public community browsing is explicitly authorized; retired upload/publish and learning flows stay retired. + +## Outcome + +- Warm welcome heading, existing composer and a compact inspiration invitation share the empty-chat layout. Both the invitation and persistent sidebar button open only https://square.nianxx.cn/#explore in the system browser on explicit click. +- No public project feed, preview cards, community store or iframe remains in this feature. Existing unrelated Main routes are unchanged. The composer remains mounted across the first send; teacher introductions keep their prior three-successful-operation-round behavior and receive responsive clearance. +- Preserved the concurrent live help-label overlay (task 20260929-teacher-help-label-83a2f67c): the two buttons and suggestions text remain 帮我整体看🧠. Ported only its three source edits and synchronized existing expectations; peer checkout untouched. +- Temporary Renderer now loads this checkout's index-SmPpxLeP.js. Existing Main PID 62668 remains running; formal installation, cloud configuration, user history and remote branches unchanged. Kept this worktree for the live trial. + +## Verification + +- Pinned corepack pnpm: typecheck passed; lint:check has 0 errors and 8 pre-existing warnings; production Renderer/Main/Preload/utility build passed. +- Final full suite: 2,654 passed, 3 skipped across 265 files including the separate pressure run. Final focused link/composer suite: 33 passed. Help-label preservation suite also passed. +- Five headless Chrome tests passed: 375/768/1280/1470px layouts, teacher clearance, reduced motion, no square network requests or iframe, click-only external URL, draft/composer preservation and first-send dismissal. Visually inspected desktop and narrow screenshots. Native Electron fixture updated and all 20 cases parse via --list; no headed Electron test or real external-browser launch was performed. +- Live verification: current asset, sidebar link present, no work cards or iframe, no alert, runtime worker ready/idle, authentication identity/route unchanged, all 13 saved drafts byte-equal. Updates used background loadFile with cache-busting; no app/window activation called. Focus monitor during refresh showed no trial activation. +- Temporary bundle was missing Info.plist/helper metadata and other resources. Matched local Electron 43.4.0 main/framework SHA-256 hashes, restored 238 missing bundle files and retained the trial bundle identity. Existing conversation hydration recovered; no model prompt was sent. This repaired only temporary bundle files, not repository runtime behavior. +- Evidence and pre-update manifests/state are in trial community-welcome-20260929/. Project-docs ownership drift and git diff checks passed. + +## Follow-ups + +- Keep the worktree while the trial references it. Merge/push only when requested. + +## Promotion Candidates + +- None recorded. + + +## Project Context Loaded + +- Feature task `20260929-community-welcome-c724e5a1`; branch/worktree in Identity and base `4199cadd1e067ce9741d6496196f4d13fed61d12` verified by official start/status. App worktree tool returned Not a git repository for the parent chat cwd, so the official skill script created this isolated checkout. Previous trial source remains read-only. +- Read entry/memory/positioning, own task, current integrated snapshot, decisions, architecture, domain, success criteria, evidence/reflection/commitment indexes and peer task scopes. Existing approved classroom behavior at 4199cad supersedes stale canonical component/welcome wording. Feature ownership prohibits canonical edits. +- Goal: student-owned creation with a welcoming input and real peer-work inspiration. Main remains the remote data/auth boundary. User approval of public community browsing explicitly covers this new entry despite the earlier general gallery restriction; do not restore publishing, uploads, learning or asset galleries. +- Peers: prior teacher cleanup/UI task owns the live trial source, formal installation/cloud-agent verification is separately active, and language-skill scope is unfinished. No changes to those checkouts or cloud. New isolated frontend feature uses committed previous UI work as base. No semantic dependency conflict. +- Evidence: public `/api/projects?limit=100` returns 21 works including runnable apps and videos; existing Main `/api/works/projects` already provides a public safe projection. Root and play-page HEAD responses have no X-Frame-Options/frame-ancestors restriction. Prefer existing public Host API and an isolated, sandboxed community overlay without unmounting the current project. +- Gate: Passed. + +## Final Plan And Scope Correction + +1. Keep the approved welcome/composer layout and teacher clearance. +2. Follow the user's latest correction: only external square links. Remove the proposed feed, recommendation cards, store and in-app viewer completely. Keep project state and drafts mounted and never send a prompt on link clicks. +3. Verify layout, click behavior, no community requests, drafts, repository checks and the actual temporary Renderer; preserve the concurrent label update and temporary profile. + +All three steps completed. Earlier public-feed investigation was feasibility work; no feed fetching or embedded browsing is present in the delivered feature. diff --git a/README.md b/README.md index fd4eacfe..8f3d1922 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Code 会话保存的模型被下架后,会提示重新选择可用模型;用 Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,源码提供四个模块入口;云智能体需配套配置 WS/Yuxi 服务。模块入口页按 Agents、Code、Canvas、Robot 排列,统一采用横向插画卡片;Agents 标语为“打造你想象中的AI助手”。工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 - 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。 + 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话显示童趣欢迎语、输入框和「去作品广场逛逛」入口;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 - `Makelore Agents|AI 智能体`:配置个人云智能体并试聊,发布后正式对话、接入个人微信或创建应用 API Key。支持流式回复、审批、排队请求、附件与产物、自动任务和活动历史;重新进入时恢复最近智能体与对话,未确认操作可按原输入重试。聊天可提出日程建议,由创建者核对能力、时间和费用后启用。知识库上传、替换及导入本人会话附件后自动解析并建立索引;文档卡片区分排队、解析、索引、可用和失败,持续刷新已加载文档,失败可原地重试;零文本内容不会显示为可用,新文档可用后才移除被替换的旧文档。支持删除知识内容;个人 MCP 凭据、Skills、子智能体可自助管理。支持智能体和会话归档、发布版本比较及恢复为草稿。除知识库 embedding 向量化外,模型调用(包括知识处理触发的对话模型调用)仍按原规则由创建者个人词元点数支付,可按智能体设置每次任务和每日上限,费用按时间、来源、应用分页统计;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限;应用调用者保有自己的内容空间。Main 管理云会话和本机文件,Renderer 通过 Host API 操作。配套服务接入见 [Yuxi MakeLore 说明](https://xerrors.github.io/Yuxi/advanced/makelore-agents.html)。 编辑采用全高桌面分栏,支持拖动调整与展开、独立滚动、小窗口配置/试用切换。首次创建按选用途、说要求、试一试引导,提供科学问答、故事、英语和笔记整理起点;回答方式可多选,特别要求直接编辑,仍以草稿的 system_prompt 保存。新助手预选云端目录的第一个可用模型并明确显示,可自行更换;已有助手不自动换模型。编辑区以“我的要求”和“高级设置”两个 Tab 切换,默认显示基础要求;高级设置包含能力、知识与限制,返回时保留上次分类,切换保留未保存的输入及当前试用。已开始使用的助手在顶部显示“修改已保存,尚未应用”或“已应用最新修改”,并提供显眼的“应用最新修改”按钮;保存只更新草稿,应用沿用同一发布操作,失败重试保留原操作身份。草稿明确保存(Ctrl/⌘+S),保存与切换页面保留当前试用及未发送文字;“保存并重新试用”使用最新保存的要求,保留上次试用供对比,并可复用上次问题,不自动发送。示例问题仅填入输入框;有运行、排队、审批或未确认操作时先处理当前试用。工具确认先展示实际操作说明与参数,完整技术详情可展开。确认“开始使用”会创建仅自己可用的发布版本并进入独立对话,微信渠道与应用接入仍单独配置。费用上限在限制中独立保存。Enter 发送、Shift+Enter 换行,F6 切换面板焦点、Esc 恢复分栏。 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 diff --git a/src/components/community/CommunityInvitation.tsx b/src/components/community/CommunityInvitation.tsx new file mode 100644 index 00000000..4faf365a --- /dev/null +++ b/src/components/community/CommunityInvitation.tsx @@ -0,0 +1,8 @@ +import { CommunityLink } from './CommunityLink'; + +export function CommunityInvitation() { + return
+

还没想到?去找找灵感吧

作品广场里,有小伙伴们用麦洛做出的奇思妙想。

+ +
; +} diff --git a/src/components/community/CommunityLink.tsx b/src/components/community/CommunityLink.tsx new file mode 100644 index 00000000..55c34eca --- /dev/null +++ b/src/components/community/CommunityLink.tsx @@ -0,0 +1,24 @@ +import { useState } from 'react'; +import { ArrowUpRight, Compass } from 'lucide-react'; +import { openCommunity } from '@/lib/community'; +import './community.css'; + +export function CommunityLink({ variant = 'inline', collapsed = false }: { + variant?: 'inline' | 'sidebar'; collapsed?: boolean; +}) { + const [opening, setOpening] = useState(false); + const [error, setError] = useState(false); + return
+ + {error && 没能打开作品广场,请再点一次试试。} +
; +} diff --git a/src/components/community/community.css b/src/components/community/community.css new file mode 100644 index 00000000..0562e3df --- /dev/null +++ b/src/components/community/community.css @@ -0,0 +1,36 @@ +.coding-chat-body { display: flex; flex: 1; flex-direction: column; min-height: 0; min-width: 0; overflow: hidden; } +.coding-chat-body[hidden] { display: none; } +.coding-chat-body[data-welcome="true"] { overflow-y: auto; padding: clamp(24px, 4vh, 48px) 24px 28px; background: radial-gradient(ellipse at 45% 18%, #fff8e8 0%, #fffdf8 28%, #fff 67%); } +.coding-chat-body[data-welcome="true"] .classroom-chat-view { flex: 0 0 auto; } +.coding-chat-body[data-welcome="true"] .classroom-composer-view { width: 100%; max-width: 740px; margin: 0 auto; } +.coding-chat-body[data-welcome="true"] [data-testid="coding-message-composer"] { width: 100%; padding: 0 0 28px; } +.coding-chat-body[data-welcome="true"] .chat-composer-surface { background: #fff; border-color: #d7dfda; border-radius: 20px; box-shadow: 0 5px 0 #eaf0e8, 0 12px 32px #69774b08; } +.coding-start-heading { position: relative; width: 100%; max-width: 740px; margin: 0 auto; padding: 0 12px 24px; text-align: center; } +.coding-start-mark { display: inline-grid; position: relative; place-items: center; width: 57px; height: 59px; border-radius: 18px; border: 1px solid #f2d6a7; background: #fff0d7; box-shadow: 0 4px 0 #eeddbd; transform: rotate(-7deg); animation: community-hello 650ms ease-out both; } +.coding-start-mark img { width: 35px; height: 35px; } +.coding-start-mark > svg { position: absolute; width: 17px; height: 17px; right: -24px; top: -2px; color: #c78b36; } +.coding-start-mark > svg:last-child { width: 12px; height: 12px; left: -23px; top: 39px; color: #83a496; } +.coding-start-eyebrow { margin: 18px 0 7px; color: #7a7769; font-size: 12px; letter-spacing: .08em; } +.coding-start-heading h1 { color: #304968; font-size: clamp(24px, 2.5vw, 34px); line-height: 1.45; font-weight: 650; letter-spacing: -.04em; } +.coding-start-heading h1 span { position: relative; white-space: nowrap; background: linear-gradient(transparent 73%, #ffe6a6 73%, #ffe6a6 94%, transparent 94%); } +.coding-start-caption { margin-top: 10px; color: #718096; font-size: 13px; line-height: 1.8; } +.community-invitation { display: flex; align-items: center; justify-content: space-between; gap: 18px; width: 100%; max-width: 740px; margin: 5px auto 0; padding: 21px 23px; border: 1px dashed #e4d9bc; border-radius: 18px; background: #fffaf0; color: #304968; } +.community-invitation h2 { margin-bottom: 5px; font-size: 15px; font-weight: 600; } +.community-invitation p { color: #7c806e; font-size: 12px; line-height: 1.7; } +.community-link { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 40px; padding: 8px 13px; border: 1px solid #dce5d0; border-radius: 12px; background: #f0f5e8; color: #526b46; font-size: 13px; cursor: pointer; white-space: nowrap; } +.community-link:hover:not(:disabled) { background: #e5eed9; border-color: #b7c6a4; } +.community-link:disabled { opacity: .6; cursor: wait; } +.community-link:focus-visible { outline: 2px solid #547ab0; outline-offset: 3px; } +.community-link svg { width: 17px; height: 17px; flex-shrink: 0; } +.community-link .community-link-arrow { width: 14px; height: 14px; } +.community-link[data-variant="sidebar"] { justify-content: flex-start; width: 100%; margin-top: 12px; border-color: transparent; background: transparent; text-align: left; } +.community-link[data-variant="sidebar"]:hover { background: #e8f0e7; } +.community-link[data-collapsed="true"] { justify-content: center; padding-inline: 0; } +.community-link-error { display: block; margin-top: 6px; color: #965a45; font-size: 12px; line-height: 1.6; } +@keyframes community-hello { 0% { transform: rotate(-15deg) translateY(5px); opacity: 0; } 70% { transform: rotate(3deg); opacity: 1; } 100% { transform: rotate(-7deg); } } +@media (max-width: 600px) { + .coding-chat-body[data-welcome="true"] { padding: 24px 16px; } + .coding-start-caption { max-width: 230px; margin: 9px auto 0; } + .community-invitation { flex-direction: column; align-items: flex-start; gap: 13px; padding: 19px; } +} +@media (prefers-reduced-motion: reduce) { .coding-start-mark { animation: none; } } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 1a59ef0a..730bb70d 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -53,6 +53,7 @@ import { useProviderStore } from '@/stores/providers'; import type { CodingProjectSummary } from '@/types/coding-project'; import { useProjectConfigStore } from '@/stores/project-config'; import { toast } from 'sonner'; +import { CommunityLink } from '@/components/community/CommunityLink'; type OpenDialogResult = { canceled: boolean; @@ -435,6 +436,7 @@ export function Sidebar({ {!sidebarCollapsed && '新建项目'} +
+ {consultationOpen && activeProject && ( diff --git a/src/pages/Chat/CodingStartHeading.tsx b/src/pages/Chat/CodingStartHeading.tsx new file mode 100644 index 00000000..f40c5e70 --- /dev/null +++ b/src/pages/Chat/CodingStartHeading.tsx @@ -0,0 +1,11 @@ +import { Sparkles, Star } from 'lucide-react'; +import mark from '@/assets/consultations/mark.svg'; + +export function CodingStartHeading() { + return
+ 麦洛 +

小点子,也能变成大惊喜

+

今天,你想创造什么?

+

一个游戏、一段故事,或是一个天马行空的想法。

+
; +} diff --git a/src/pages/Chat/TeacherChatPanel.tsx b/src/pages/Chat/TeacherChatPanel.tsx index 6203fbf7..fb5b20a1 100644 --- a/src/pages/Chat/TeacherChatPanel.tsx +++ b/src/pages/Chat/TeacherChatPanel.tsx @@ -471,7 +471,7 @@ export function TeacherChatPanel({ (request) => request.status === 'preparing' || request.status === 'running' ); const helpUnavailable = busy || !topicEnabled || Boolean(running); - const askForSuggestions = () => void send({ text: '不知道问什么,你先帮我看看👀', intent: 'suggestions' }); + const askForSuggestions = () => void send({ text: '帮我整体看🧠', intent: 'suggestions' }); const changeText = (next: string) => { setText(next); persistDraft(next, references, pending.current); }; const removeReferences = () => { setReferences([]); persistDraft(text, [], pending.current); }; const questions = definition?.suggested_questions ?? []; @@ -541,7 +541,7 @@ export function TeacherChatPanel({ {request.suggestedQuestions?.map((question, index) => )} } - {['failed', 'cancelled', 'interrupted'].includes(request.status) && } + {['failed', 'cancelled', 'interrupted'].includes(request.status) && } : request.response ? :
{['running', 'preparing'].includes(request.status) ? {label}正在想… : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}
} {request.intent !== 'suggestions' && request.status === 'completed' && !request.replyIncomplete && Boolean(request.suggestedQuestions?.length) &&
{request.suggestedQuestions?.map((question, index) => )} @@ -585,7 +585,7 @@ export function TeacherChatPanel({ className="min-h-[60px] w-full resize-none border-0 bg-transparent text-[13px] leading-6 outline-none placeholder:text-muted-foreground/65" />
- {!draftRevision && !text.trim() && } + {!draftRevision && !text.trim() && }
{running ? : } diff --git a/tests/e2e/community-welcome.spec.ts b/tests/e2e/community-welcome.spec.ts new file mode 100644 index 00000000..fe4d0ec7 --- /dev/null +++ b/tests/e2e/community-welcome.spec.ts @@ -0,0 +1,90 @@ +import { test, expect } from '@playwright/test'; +import { createServer, type ViteDevServer } from 'vite'; +import { resolve } from 'node:path'; + +let server: ViteDevServer; +let baseUrl: string; +test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' }); +test.beforeAll(async () => { + server = await createServer({ + configFile: false, cacheDir: 'node_modules/.vite-community-layout', + resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' }, + server: { host: '127.0.0.1', port: 0 }, + plugins: [{ + name: 'isolated-community', enforce: 'pre', + load(id) { + if (id.endsWith('/src/lib/host-events.ts')) return 'export function subscribeHostEvent() { return () => {}; }'; + if (id.endsWith('/src/lib/host-api.ts')) return ` + export async function createHostEventSource() { return { close() {}, addEventListener() {} }; } + export async function ensureHostApiToken() { return 'fixture'; } + export function getHostApiBase() { return ''; } + export function reportDesktopActivity() {} + export function setDesktopBackgroundLease() {} + export async function hostApiFetch() { return {}; } + `; + }, + configureServer(vite) { + vite.middlewares.use((req, res, next) => { + if (req.url !== '/' && !req.url?.startsWith('/?')) return next(); + res.setHeader('Content-Type', 'text/html'); + res.end('
'); + }); + }, + }], + }); + await server.listen(); + baseUrl = server.resolvedUrls!.local[0]; +}); +test.afterAll(async () => { await server?.close(); }); +test.beforeEach(async ({ page }) => { + page.on('pageerror', error => { throw error; }); + await page.addInitScript(() => { + const scope = window as typeof window & { __communityCalls: unknown[][] }; + scope.__communityCalls = []; + Object.defineProperty(window, 'electron', { value: { ipcRenderer: { + invoke: async (...args: unknown[]) => { scope.__communityCalls.push(args); return undefined; }, + } } }); + }); +}); + +for (const width of [375, 768, 1280, 1470]) { + test(`welcome, composer and community link clear teacher introductions at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 850 }); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(baseUrl + '?teachers'); + const heading = page.getByTestId('coding-start-heading'); + const teachers = page.getByTestId('teacher-welcomes'); + await expect(teachers.locator('.teacher-welcome-card')).toHaveCount(2); + await expect.poll(async () => { + const a = await heading.boundingBox(), b = await teachers.boundingBox(); + return !!a && !!b && (a.x + a.width <= b.x || a.y >= b.y + b.height); + }).toBe(true); + await expect(page.locator('.coding-start-mark')).toHaveCSS('animation-name', 'none'); + await page.getByTestId('community-invitation').scrollIntoViewIfNeeded(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width); + await expect(page.getByRole('button', { name: '去作品广场逛逛' })).toBeVisible(); + await teachers.getByRole('button', { name: '先自己试试' }).click(); + await heading.scrollIntoViewIfNeeded(); + await page.screenshot({ path: test.info().outputPath(`community-welcome-${width}.png`) }); + }); +} + +test('the community link opens only on click and leaves the draft mounted', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 850 }); + const communityRequests: string[] = []; + page.on('request', request => { if (request.url().includes('square.nianxx.cn')) communityRequests.push(request.url()); }); + await page.goto(baseUrl); + const composer = page.getByRole('textbox'); + await composer.fill('我想做一个自己的小镇'); + await composer.evaluate(node => node.setAttribute('data-mounted', 'same')); + await page.getByRole('button', { name: '去作品广场逛逛' }).click(); + expect(await page.evaluate(() => (window as typeof window & { __communityCalls: unknown[][] }).__communityCalls)) + .toContainEqual(['shell:openExternal', 'https://square.nianxx.cn/#explore']); + expect(communityRequests).toEqual([]); + await expect(page.locator('iframe')).toHaveCount(0); + await expect(composer).toHaveValue('我想做一个自己的小镇'); + await expect(composer).toHaveAttribute('data-mounted', 'same'); + await page.getByRole('button', { name: '发送', exact: true }).click(); + await expect(page.getByTestId('community-invitation')).toHaveCount(0); + await expect(composer).toHaveAttribute('data-mounted', 'same'); +}); diff --git a/tests/e2e/fixtures/community-welcome.tsx b/tests/e2e/fixtures/community-welcome.tsx new file mode 100644 index 00000000..5f18539e --- /dev/null +++ b/tests/e2e/fixtures/community-welcome.tsx @@ -0,0 +1,51 @@ +import { useState, type ComponentProps } from 'react'; +import { createRoot } from 'react-dom/client'; +import { CodingChatBody } from '../../../src/pages/Chat/CodingChatBody'; +import { CodingStartHeading } from '../../../src/pages/Chat/CodingStartHeading'; +import { CodingComposer } from '../../../src/pages/Chat/CodingComposer'; +import { TeacherCompanion } from '../../../src/pages/Chat/TeacherCompanion'; +import { CommunityInvitation } from '../../../src/components/community/CommunityInvitation'; +import '../../../src/styles/globals.css'; +import '../../../src/pages/Chat/classroom-workspace.css'; + +export function Layout() { + const [bubble, setBubble] = useState(null); + const [draft, setDraft] = useState(''); + const [sent, setSent] = useState(false); + const [dismissed, setDismissed] = useState([]); + const agents = ['架构工程师-陈宗琦', '产品工程师-Alice'].map((name, index) => ({ + teacher_id: String(index), version: 1, is_default: index === 0, + definition: { config_id: String(index), name, avatar_id: 'avatar-06', + description: index === 0 ? '有什么编程问题想一起讨论?' : '陪你把天马行空的点子,变成能玩、能用、眼睛一亮的小作品。' }, + })); + const companion = { + agents, selectedAgent: agents[0], definition: agents[0].definition, enabled: true, + unreadAgentIds: [], invitation: null, welcome: null, loading: false, error: null, checking: false, + refreshing: false, refreshStatus: 'idle', refresh: async () => {}, reloadCatalog: async () => {}, dismiss: () => {}, + welcomes: new URLSearchParams(location.search).has('teachers') ? agents.flatMap((agent, index) => dismissed.includes(agent.teacher_id) + ? [] : [{ agentId: agent.teacher_id, definition: agent.definition, text: agent.definition.description, tone: index }]) : [], + dismissWelcome: (id: string) => setDismissed(values => [...values, id]), + dismissWelcomes: () => setDismissed(['0', '1']), + } as ComponentProps['companion']; + return
+
+ 测试项目 › 新对话 + {}} onToggle={() => {}} bubbleRef={setBubble} /> +
+
+
+ +
+
; +} +createRoot(document.getElementById('root')!).render(); diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts index d2a48c79..fed1943a 100644 --- a/tests/e2e/pi-coding-first-chat.spec.ts +++ b/tests/e2e/pi-coding-first-chat.spec.ts @@ -1121,10 +1121,25 @@ test('first PI Conversation is editable under 500 ms and submits before runtime buffer: largePng, }); await composer.fill('Build the first PI scene'); + await electronApp.evaluate(({ shell }) => { + const scope = globalThis as typeof globalThis & { __communityOpened?: string[] }; + scope.__communityOpened = []; + shell.openExternal = async url => { scope.__communityOpened!.push(url); }; + }); + await page.getByRole('button', { name: '作品广场', exact: true }).click(); + await page.getByRole('button', { name: '去作品广场逛逛' }).click(); + await expect.poll(() => electronApp.evaluate(() => ( + (globalThis as typeof globalThis & { __communityOpened?: string[] }).__communityOpened + ))).toEqual(['https://square.nianxx.cn/#explore', 'https://square.nianxx.cn/#explore']); + await expect(composer).toHaveValue('Build the first PI scene'); + await expect(page.getByText('large.png', { exact: true })).toBeVisible(); + await expect(page.getByRole('dialog')).toHaveCount(0); + expect((await readState(electronApp)).captured.filter(item => item.path.endsWith('/prompt') && item.method === 'POST')).toHaveLength(0); await expect(page.getByRole('button', { name: '发送' })).toBeEnabled(); await page.getByTestId('coding-message-composer').evaluate( (form: HTMLFormElement) => form.requestSubmit(), ); + await expect(page.getByTestId('community-invitation')).toHaveCount(0); await expect.poll(async () => { const state = await readState(electronApp); @@ -1869,7 +1884,7 @@ test('project consultations preserve student drafts and switch between work and await expect(page.getByRole('button', { name: '与朋友聊天' })).toHaveAttribute('aria-pressed', 'true'); await expect(page.locator('#coding-consultation-dock')).toHaveCSS('width', '508px'); await expect(teacher.locator('form .consultation-hint')).toBeVisible(); - await expect(teacher.locator('form .consultation-hint')).toHaveText('不知道问什么,你先帮我看看👀'); + await expect(teacher.locator('form .consultation-hint')).toHaveText('帮我整体看🧠'); await expect(teacher.getByText('把你的困惑说出来就好')).toHaveCount(0); await expect(teacher.getByText('当前项目',{exact:true})).toHaveCount(0); await expect(teacher.getByRole('button',{name:'解释当前代码',exact:true})).toBeVisible(); @@ -1924,7 +1939,7 @@ test('project consultations preserve student drafts and switch between work and expect(request.body).not.toHaveProperty('discussion'); } await expect(teacher).toHaveAccessibleName('朋友咨询'); - expect(consultationRequests[0].body).toMatchObject({intent:'suggestions',text:'不知道问什么,你先帮我看看👀'}); + expect(consultationRequests[0].body).toMatchObject({intent:'suggestions',text:'帮我整体看🧠'}); expect(consultationRequests[1].body).toMatchObject({text:'怎样观察别人玩游戏?'}); expect(consultationRequests[1].body?.intent).toBeUndefined(); await page.screenshot({path:test.info().outputPath('project-teacher-side-chat.png')}); diff --git a/tests/e2e/teacher-reply-layout.spec.ts b/tests/e2e/teacher-reply-layout.spec.ts index 636775fd..e5680b21 100644 --- a/tests/e2e/teacher-reply-layout.spec.ts +++ b/tests/e2e/teacher-reply-layout.spec.ts @@ -29,7 +29,7 @@ test('retry stickers stay readable and preserve drafts in a 319px consultation', const panel = page.getByTestId('teacher-chat-panel'); const retry = panel.locator('.consultation-retry'); await expect(retry).toBeEnabled(); - await expect(retry).toHaveText('不知道问什么,你先帮我看看👀'); + await expect(retry).toHaveText('帮我整体看🧠'); expect(await retry.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true); const [panelBounds, retryBounds] = await Promise.all([panel.boundingBox(), retry.boundingBox()]); expect(retryBounds!.x).toBeGreaterThanOrEqual(panelBounds!.x); @@ -41,7 +41,7 @@ test('retry stickers stay readable and preserve drafts in a 319px consultation', await expect(page.getByText('我们可以先从一个小地方开始。')).toBeVisible(); await expect(input).toHaveValue('我还有一点自己的想法'); expect(await page.evaluate(() => (window as unknown as { __teacherReplyInputs: unknown[] }).__teacherReplyInputs)).toEqual([ - { requestId: expect.any(String), text: '不知道问什么,你先帮我看看👀', intent: 'suggestions', references: [], projectId: 'fixture', sourceConversationId: 'source' }, + { requestId: expect.any(String), text: '帮我整体看🧠', intent: 'suggestions', references: [], projectId: 'fixture', sourceConversationId: 'source' }, ]); } }); @@ -89,7 +89,7 @@ for (const width of [319, 508]) { const send = page.getByRole('button', { name: '提问', exact: true }); const sent = () => page.evaluate(() => (window as unknown as { __teacherReplyInputs: unknown[] }).__teacherReplyInputs); await expect(hint).toBeEnabled(); - await expect(hint).toHaveText('不知道问什么,你先帮我看看👀'); + await expect(hint).toHaveText('帮我整体看🧠'); expect(await hint.evaluate(el => el.scrollWidth <= el.clientWidth && el.scrollHeight <= el.clientHeight)).toBe(true); await expect(hint).toHaveCSS('background-color', 'rgb(255, 242, 201)'); const initialSend = (await send.boundingBox())!; @@ -110,9 +110,9 @@ for (const width of [319, 508]) { expect(await sent()).toEqual([]); await hint.click(); await expect(page.getByText('我们可以先从一个小地方开始。')).toBeVisible(); - expect(await sent()).toEqual([{ requestId: expect.any(String), text: '不知道问什么,你先帮我看看👀', intent: 'suggestions', references: [], projectId: 'fixture', sourceConversationId: 'source' }]); + expect(await sent()).toEqual([{ requestId: expect.any(String), text: '帮我整体看🧠', intent: 'suggestions', references: [], projectId: 'fixture', sourceConversationId: 'source' }]); await expect(page.getByRole('button', { name: '我也说不清,你带我看看', exact: true })).toHaveCount(0); - await expect(page.locator('.consultation-messages').getByRole('button', { name: '不知道问什么,你先帮我看看👀', exact: true })).toHaveCount(0); + await expect(page.locator('.consultation-messages').getByRole('button', { name: '帮我整体看🧠', exact: true })).toHaveCount(0); const card = page.getByRole('button', { name: '可以从哪个小地方开始?' }); await expect(card).toBeVisible(); await input.fill('留着我自己的草稿'); diff --git a/tests/unit/coding-chat-panel.test.tsx b/tests/unit/coding-chat-panel.test.tsx index 974d8b7b..b88f3e27 100644 --- a/tests/unit/coding-chat-panel.test.tsx +++ b/tests/unit/coding-chat-panel.test.tsx @@ -340,7 +340,7 @@ describe('CodingChatPanel first Conversation', () => { await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id)); fireEvent.keyDown(menu, { key: 'Enter' }); fireEvent.click(await screen.findByRole('menuitem', { name: '归档(任务继续运行)' })); - await screen.findByTestId('coding-conversation-pending'); + await screen.findByRole('heading', { name: '今天,你想创造什么?' }); expect(projectApi.create).not.toHaveBeenCalled(); expect(conversationApi.abort).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: '查看归档对话' })); @@ -434,13 +434,15 @@ describe('CodingChatPanel first Conversation', () => { expect(onOpenProjectSettings).not.toHaveBeenCalled(); }); - it('keeps an empty project editable without creating a conversation until first send', async () => { + it('keeps an empty project and its draft mounted across community visits until first send', async () => { projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); projectApi.config.mockResolvedValue({ project, config }); projectApi.conversations.mockResolvedValue([]); projectApi.create.mockResolvedValue(conversation); conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource); const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); + const api = await import('@/lib/api-client'); + const external = vi.spyOn(api, 'invokeIpc').mockResolvedValue(undefined); const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot'); conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation)); render(); @@ -448,8 +450,17 @@ describe('CodingChatPanel first Conversation', () => { expect(projectApi.create).not.toHaveBeenCalled(); expect(conversationApi.snapshot).not.toHaveBeenCalled(); fireEvent.change(textbox, {target:{value:'First prompt'}}); + fireEvent.click(screen.getByRole('button', { name: '去作品广场逛逛' })); + await waitFor(() => expect(external).toHaveBeenCalledWith('shell:openExternal', 'https://square.nianxx.cn/#explore')); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByRole('textbox')).toBe(textbox); + expect(textbox).toHaveValue('First prompt'); + expect(projectApi.create).not.toHaveBeenCalled(); + expect(conversationApi.submit).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button',{name:'发送'})); await waitFor(()=>expect(projectApi.create).toHaveBeenCalledOnce()); + await waitFor(() => expect(screen.queryByTestId('community-invitation')).not.toBeInTheDocument()); + expect(screen.getByRole('textbox')).toBe(textbox); }); it('rehydrates a preselected stale Conversation when the programming view mounts', async () => { diff --git a/tests/unit/coding-teacher-ui.test.tsx b/tests/unit/coding-teacher-ui.test.tsx index ff2dc375..608e0a6c 100644 --- a/tests/unit/coding-teacher-ui.test.tsx +++ b/tests/unit/coding-teacher-ui.test.tsx @@ -496,13 +496,13 @@ describe('teacher side chat', () => { })); render(); const input = screen.getByLabelText('向智能体提问'); - const help = () => screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' }); + const help = () => screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' }); await waitFor(() => expect(help()).toBeEnabled()); expect(screen.getByTestId('consultation-composer-actions')).toContainElement(help()); - expect(help()).toHaveAccessibleName('不知道问什么,你先帮我看看👀'); + expect(help()).toHaveAccessibleName('帮我整体看🧠'); expect(api.send).not.toHaveBeenCalled(); fireEvent.change(input, { target: { value: '我先自己想想' } }); - expect(screen.queryByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).not.toBeInTheDocument(); + expect(screen.queryByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).not.toBeInTheDocument(); expect(api.send).not.toHaveBeenCalled(); fireEvent.change(input, { target: { value: ' ' } }); expect(help()).toBeEnabled(); @@ -517,11 +517,11 @@ describe('teacher side chat', () => { expect(screen.queryByRole('button', { name: '尚未确认的问题' })).not.toBeInTheDocument(); expect(api.send).toHaveBeenCalledTimes(1); expect(api.send).toHaveBeenCalledWith('p/agent-topics', 'first', expect.objectContaining({ - intent: 'suggestions', text: '不知道问什么,你先帮我看看👀', sourceConversationId: 'c', + intent: 'suggestions', text: '帮我整体看🧠', sourceConversationId: 'c', })); expect(help()).toBeDisabled(); fireEvent.change(input, { target: { value: '我自己还想问的另一件事' } }); - expect(screen.queryByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).not.toBeInTheDocument(); + expect(screen.queryByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).not.toBeInTheDocument(); await waitFor(() => expect(streams.has('first')).toBe(true)); const sent = api.send.mock.calls[0][2] as TeacherSend; act(() => streams.get('first')!.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify({ @@ -532,7 +532,7 @@ describe('teacher side chat', () => { expect(screen.queryByText('我看看你最近做到了哪里…')).not.toBeInTheDocument(); for (const question of suggestedQuestions) expect(screen.getByRole('button', { name: question })).toBeEnabled(); expect(screen.queryByRole('button', { name: '我也说不清,你带我看看', exact: true })).not.toBeInTheDocument(); - expect(screen.queryByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).not.toBeInTheDocument(); + expect(screen.queryByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).not.toBeInTheDocument(); expect(api.send).toHaveBeenCalledTimes(1); expect(input).toHaveValue('我自己还想问的另一件事'); }); @@ -567,8 +567,8 @@ describe('teacher side chat', () => { it('keeps an uncertain suggestions request identity through reopening and draft edits', async () => { api.send.mockRejectedValue(new Error('网络暂不可用')); const view = render(); - await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).toBeEnabled()); - fireEvent.click(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })); + await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).toBeEnabled()); + fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })); await screen.findByText('网络暂不可用'); fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我还没写完的问题' } }); const originalRequest = api.send.mock.calls[0][2] as TeacherSend; @@ -578,10 +578,10 @@ describe('teacher side chat', () => { render(); await waitFor(() => expect(screen.getByLabelText('向智能体提问')).toBeEnabled()); expect(screen.getByLabelText('向智能体提问')).toHaveValue('我还没写完的问题'); - expect(screen.queryByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).not.toBeInTheDocument(); + expect(screen.queryByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).not.toBeInTheDocument(); expect(api.send).toHaveBeenCalledTimes(1); fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '' } }); - fireEvent.click(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })); + fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })); await screen.findByText('网络暂不可用'); expect(api.send.mock.calls[1][2]).toEqual(originalRequest); expect(screen.getByLabelText('向智能体提问')).toHaveValue(''); @@ -592,16 +592,16 @@ describe('teacher side chat', () => { localStorage.setItem('makelore-consultation-draft:signed-out:p:teacher', JSON.stringify({ text: '', references: [], pending: original })); api.send.mockRejectedValue(new Error('网络暂不可用')); const view = render(); - await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).toBeEnabled()); + await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).toBeEnabled()); expect(api.send).not.toHaveBeenCalled(); - fireEvent.click(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })); + fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })); await screen.findByText('网络暂不可用'); expect(JSON.parse(JSON.stringify(api.send.mock.calls[0][2]))).toEqual(original); view.unmount(); render(); - await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).toBeEnabled()); + await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).toBeEnabled()); expect(api.send).toHaveBeenCalledTimes(1); - fireEvent.click(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })); + fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })); await screen.findByText('网络暂不可用'); expect(JSON.parse(JSON.stringify(api.send.mock.calls[1][2]))).toEqual(original); }); @@ -626,13 +626,13 @@ describe('teacher side chat', () => { api.read.mockResolvedValue({ ...first, requests: [suggestionsRequest({ id: original.requestId, text: original.text, status })] }); api.send.mockResolvedValue({ ...first, revision: 2 }); render(); - await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).toBeEnabled()); + await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).toBeEnabled()); expect(api.send).not.toHaveBeenCalled(); - fireEvent.click(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })); + fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })); await waitFor(() => expect(api.send).toHaveBeenCalledTimes(1)); const sent = api.send.mock.calls[0][2] as TeacherSend; expect(sent.requestId).not.toBe(original.requestId); - expect(sent).toMatchObject({ text: '不知道问什么,你先帮我看看👀', intent: 'suggestions', references: [], sourceConversationId: 'c' }); + expect(sent).toMatchObject({ text: '帮我整体看🧠', intent: 'suggestions', references: [], sourceConversationId: 'c' }); }); it.each(['failed', 'cancelled', 'interrupted'] as const)('retries a %s suggestion request without leaking raw JSON or reusing its accepted request id', async status => { @@ -645,7 +645,7 @@ describe('teacher side chat', () => { await screen.findByText('这次没有看清楚,请再试一次。'); expect(screen.queryByText('{"intro":"未完成')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: suggestedQuestions[0] })).not.toBeInTheDocument(); - fireEvent.click(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: '.consultation-retry' })); + fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: '.consultation-retry' })); await screen.findByText('我们可以从最近遇到的这两个地方聊起。'); expect(api.send.mock.calls[0][2]).toMatchObject({ intent: 'suggestions' }); expect(api.send.mock.calls[0][2].requestId).not.toBe(failed.id); @@ -657,9 +657,9 @@ describe('teacher side chat', () => { ...first, revision: 3, requests: [accepted, suggestionsRequest({ id: input.requestId })], })); const view = render(); - await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).toBeEnabled()); + await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).toBeEnabled()); await waitFor(() => expect(streams.has('first')).toBe(true)); - fireEvent.click(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })); + fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })); await screen.findByText('响应中途断开'); fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我的自由提问继续留着' } }); const originalRequest = api.send.mock.calls[0][2] as TeacherSend; @@ -673,10 +673,10 @@ describe('teacher side chat', () => { act(() => streams.get('first')!.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(recovered) }))); } await screen.findByText('智能体已接到,但这次回复失败了'); - await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: '.consultation-retry' })).toBeEnabled()); + await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: '.consultation-retry' })).toBeEnabled()); expect(screen.getByLabelText('向智能体提问')).toHaveValue('我的自由提问继续留着'); expect(api.send).toHaveBeenCalledTimes(1); - fireEvent.click(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: '.consultation-retry' })); + fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: '.consultation-retry' })); await screen.findByText('我们可以从最近遇到的这两个地方聊起。'); expect(api.send).toHaveBeenCalledTimes(2); expect(api.send.mock.calls[1][2].requestId).not.toBe(originalRequest.requestId); @@ -699,7 +699,7 @@ describe('teacher side chat', () => { expect(api.send.mock.calls[0][2].intent).toBeUndefined(); expect(screen.getByLabelText('向智能体提问')).toHaveValue(''); expect(screen.queryByRole('button', { name: '移除引用' })).not.toBeInTheDocument(); - expect(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).toBeEnabled(); + expect(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).toBeEnabled(); view.unmount(); render(); expect(screen.getByLabelText('向智能体提问')).toHaveValue(''); @@ -787,7 +787,7 @@ describe('teacher side chat', () => { fireEvent.click(screen.getByRole('button', { name: '运营配置的预览问题' })); expect(screen.getByLabelText('向智能体提问')).toHaveValue('运营配置的预览问题'); expect(api.send).not.toHaveBeenCalled(); - expect(screen.queryByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).not.toBeInTheDocument(); + expect(screen.queryByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '智能体新话题' })); await waitFor(() => expect(api.create).toHaveBeenCalledWith('preview/sample', 7, '学生的练习代码', undefined)); }); @@ -911,7 +911,7 @@ describe('merged cloud teacher classroom', () => { api.sendConversation.mockRejectedValue(new Error('结果暂未确认')); const props = { projectId: 'p', sourceId: 'original-source', selectedAgent: agent }; const view = render(); - const hint = () => screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' }); + const hint = () => screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' }); await waitFor(() => expect(hint()).toBeEnabled()); expect(api.sendConversation).not.toHaveBeenCalled(); fireEvent.click(hint()); @@ -984,10 +984,10 @@ describe('merged cloud teacher classroom', () => { render(); await waitFor(() => expect(screen.getByText('智能体暂未开放,历史仍可查看。')).toBeVisible()); const input = screen.getByLabelText('向智能体提问'); - expect(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).toBeDisabled(); + expect(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).toBeDisabled(); fireEvent.change(input, { target: { value: '继续讨论' } }); expect(screen.getByRole('button', { name: '提问', exact: true })).toBeDisabled(); - expect(screen.queryByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).not.toBeInTheDocument(); + expect(screen.queryByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).not.toBeInTheDocument(); fireEvent.keyDown(input, { key: 'Enter' }); expect(api.send).not.toHaveBeenCalled(); }); diff --git a/tests/unit/community.test.tsx b/tests/unit/community.test.tsx new file mode 100644 index 00000000..29257c4d --- /dev/null +++ b/tests/unit/community.test.tsx @@ -0,0 +1,33 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { invokeIpc } from '@/lib/api-client'; +import { COMMUNITY_URL } from '@/lib/community'; +import { CommunityLink } from '@/components/community/CommunityLink'; +import { CommunityInvitation } from '@/components/community/CommunityInvitation'; + +vi.mock('@/lib/api-client', () => ({ invokeIpc: vi.fn() })); +beforeEach(() => { vi.mocked(invokeIpc).mockReset().mockResolvedValue(undefined); }); +afterEach(cleanup); + +it('only opens the public explore URL after a click, without fetching or embedding works', async () => { + const fetch = vi.spyOn(globalThis, 'fetch'); + render(); + expect(invokeIpc).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + expect(document.querySelector('iframe')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '去作品广场逛逛' })); + await waitFor(() => expect(invokeIpc).toHaveBeenCalledExactlyOnceWith('shell:openExternal', COMMUNITY_URL)); + fetch.mockRestore(); +}); + +it('provides an accessible collapsed sidebar entry and a retry after opening fails', async () => { + vi.mocked(invokeIpc).mockRejectedValueOnce(new Error('unavailable')); + render(); + const button = screen.getByRole('button', { name: '作品广场' }); + fireEvent.click(button); + await screen.findByRole('alert'); + await waitFor(() => expect(button).toBeEnabled()); + fireEvent.click(button); + await waitFor(() => expect(invokeIpc).toHaveBeenCalledTimes(2)); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); +});