diff --git a/muse-studio/e2e/block-structure.spec.ts b/muse-studio/e2e/block-structure.spec.ts new file mode 100644 index 00000000..31272cad --- /dev/null +++ b/muse-studio/e2e/block-structure.spec.ts @@ -0,0 +1,43 @@ +import { expect, test, type Page } from '@playwright/test'; + +/** + * Block 分割/合并端到端(真实后端,MSW off)。 + * + * 覆盖 D 链路:进 work1 workspace → BlockStructureBar 显示章节结构 → 分割首节(真实 split,1→2)→ + * 验证 2 个小节 → 合并首节与下一节(真实 merge,2→1)→ 验证回到 1 个小节。 + * globalSetup 已复位 work1 chapter1 为单 Block(幂等:即使中途失败残留也清掉)。 + */ +const TOKEN = 'test1'; + +async function seedToken(page: Page): Promise { + await page.addInitScript((t) => { + window.localStorage.setItem('accessToken', t); + window.localStorage.setItem('tenantId', '1'); + }, TOKEN); +} + +test('用户分割与合并章节内 Block(真实后端结构编辑)', async ({ page }) => { + await seedToken(page); + + await page.goto('/workspace/1'); + await expect(page.getByRole('heading', { name: /正在创作/ })).toBeVisible({ timeout: 15_000 }); + + // 章节结构条出现,初始 1 个小节(globalSetup 已复位)。 + await expect(page.getByText(/章节结构 · 1 个小节/)).toBeVisible({ timeout: 15_000 }); + + // 分割首节(真实 split)→ chapterDetail 重拉 → 2 个小节。 + const splitResp = page.waitForResponse( + (r) => /\/blocks\/[^/]+\/split/.test(r.url()) && r.request().method() === 'POST' + ); + await page.getByRole('button', { name: '分割小节 1' }).click(); + expect((await splitResp).status()).toBe(200); + await expect(page.getByText(/章节结构 · 2 个小节/)).toBeVisible({ timeout: 10_000 }); + + // 合并首节与下一节(真实 merge)→ 回到 1 个小节。 + const mergeResp = page.waitForResponse( + (r) => /\/blocks\/[^/]+\/merge/.test(r.url()) && r.request().method() === 'POST' + ); + await page.getByRole('button', { name: '合并小节 1 与下一节' }).click(); + expect((await mergeResp).status()).toBe(200); + await expect(page.getByText(/章节结构 · 1 个小节/)).toBeVisible({ timeout: 10_000 }); +}); diff --git a/muse-studio/e2e/global-setup.ts b/muse-studio/e2e/global-setup.ts index 018404ee..75cc1e22 100644 --- a/muse-studio/e2e/global-setup.ts +++ b/muse-studio/e2e/global-setup.ts @@ -223,7 +223,14 @@ async function globalSetup(): Promise { // work1 绑 setting(其 target_type=novel_work → 投影列出同 targetType 的 setting+worldview)。 await client.query('UPDATE muse_content_work SET work_schema_id=$1 WHERE tenant_id=1 AND id=1', [settingSchemaId]); - console.log('[e2e globalSetup] 已复位每轮 fixture(graph/confirm-draft/accept/security-ack/appeal/binding/meta-schema)'); + // 8) block split/merge:复位 work1 chapter1 为单 Block——删 split 残留的多余 Block(保留 order_no 最小的首 Block), + // 供 D block 结构编辑 e2e 幂等重跑(split→2→merge→1,中途失败的残留也清掉)。 + await client.query( + `DELETE FROM muse_content_block WHERE tenant_id=1 AND work_id=1 AND chapter_id=1 + AND id <> (SELECT id FROM muse_content_block WHERE tenant_id=1 AND work_id=1 AND chapter_id=1 ORDER BY order_no, id LIMIT 1)` + ); + + console.log('[e2e globalSetup] 已复位每轮 fixture(graph/confirm-draft/accept/security-ack/appeal/binding/meta-schema/block)'); } finally { await client.end(); } diff --git a/muse-studio/src/features/editor/components/BlockStructureBar.tsx b/muse-studio/src/features/editor/components/BlockStructureBar.tsx index 1763558b..22941bf6 100644 --- a/muse-studio/src/features/editor/components/BlockStructureBar.tsx +++ b/muse-studio/src/features/editor/components/BlockStructureBar.tsx @@ -1,8 +1,8 @@ import { useSplitBlock, useMergeBlock } from '../hooks/useBlockStructure'; import type { BlockVO } from '@/types/openapi'; -/** 从 Tiptap JSON 字符串提取纯文本(用于摘要 + 计算分割中点)。 */ -function extractText(contentJson: string): string { +/** 从正文提取纯文本(用于摘要 + 计算分割中点);兼容 Tiptap JSON 与纯文本 content(基础种子可能是纯文本)。 */ +function extractText(content: string): string { try { const out: string[] = []; const walk = (node: unknown): void => { @@ -11,10 +11,11 @@ function extractText(contentJson: string): string { if (typeof n.text === 'string') out.push(n.text); if (Array.isArray(n.content)) n.content.forEach(walk); }; - walk(JSON.parse(contentJson)); - return out.join(''); + walk(JSON.parse(content)); + const joined = out.join(''); + return joined || content; // 是 JSON 但无文本节点 → 退回原串。 } catch { - return ''; + return content; // content 非 JSON(纯文本)→ 直接用。 } }