test(studio): D-c block 分割/合并 e2e 真跑 + 幂等复位[P1]

- block-structure.spec.ts:真后端端到端——进 work1 workspace→章节结构条(1 小节)→分割(真实 split POST 200→2 小节)→合并(真实 merge POST 200→1 小节);'N 个小节'来自 chapterDetail 真数据,证明 split 真产生 block。

- global-setup section 8:幂等复位 work1 chapter1 为单 Block(删 split 残留,保留 order_no 最小首 Block)。

- BlockStructureBar.extractText 兼容纯文本 content(基础种子 block content 为纯文本非 ProseMirror JSON,JSON.parse 失败时退回原串,使 split 可用)。

验证:playwright block-structure 1 passed(真后端活体) + eslint 0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-21 09:01:11 -07:00
parent af781163a9
commit c76cdec60a
3 changed files with 57 additions and 6 deletions

View File

@ -0,0 +1,43 @@
import { expect, test, type Page } from '@playwright/test';
/**
* Block /(,MSW off)
*
* D 链路: work1 workspace BlockStructureBar ( split,12)
* 2 ( merge,21) 1
* globalSetup work1 chapter1 Block(幂等:即使中途失败残留也清掉)
*/
const TOKEN = 'test1';
async function seedToken(page: Page): Promise<void> {
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 });
});

View File

@ -223,7 +223,14 @@ async function globalSetup(): Promise<void> {
// 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();
}

View File

@ -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(纯文本)→ 直接用。
}
}