chore: 初始化 BlockFlow Workbench 仓库

建立前端与 Tauri 桌面端的首个版本提交,包含核心编辑器、项目文件读写、测试与构建配置。

补充 Git 忽略规则和换行规范,排除依赖、构建产物、本地运行日志与临时验证文件,方便在其他电脑继续开发。
This commit is contained in:
2026-05-29 17:23:43 +08:00
commit 589ff15213
88 changed files with 31656 additions and 0 deletions

265
tests/app.test.tsx Normal file
View File

@@ -0,0 +1,265 @@
import "@testing-library/jest-dom/vitest"
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import { App } from "../src/app/App"
import {
defaultDatasetId,
defaultDocumentId,
defaultResourcePath,
getCurrentDocument,
getCurrentRenderResult,
setProjectPersistenceForTests,
useWorkbenchStore
} from "../src/app/store/workbenchStore"
describe("workbench UI shell", () => {
beforeEach(() => {
setProjectPersistenceForTests(null)
useWorkbenchStore.getState().resetDemo()
})
afterEach(() => {
cleanup()
})
it("starts with the demo entry template and dataset selected", () => {
const state = useWorkbenchStore.getState()
expect(state.selectedDocumentId).toBe(defaultDocumentId)
expect(state.currentDatasetId).toBe(defaultDatasetId)
expect(state.selectedResourcePath).toBe(defaultResourcePath)
})
it("renders the resource tree from the project snapshot", () => {
render(<App />)
expect(screen.getByTestId("resource-panel")).toHaveTextContent("main.json")
expect(screen.getByTestId("resource-panel")).toHaveTextContent("common-footer.json")
expect(screen.getByTestId("resource-panel")).toHaveTextContent("default.json")
})
it("switches inspector tabs", () => {
render(<App />)
fireEvent.click(screen.getByRole("button", { name: "预览" }))
expect(useWorkbenchStore.getState().rightPanelTab).toBe("preview")
expect(screen.getByTestId("preview-tab")).toHaveTextContent("张三")
fireEvent.click(screen.getByRole("button", { name: "引用" }))
expect(useWorkbenchStore.getState().rightPanelTab).toBe("references")
expect(screen.getByTestId("references-tab")).toHaveTextContent("missing fragment_signature")
})
it("generates preview output from the existing renderer", () => {
const state = useWorkbenchStore.getState()
const result = getCurrentRenderResult(state)
expect(result.output).toContain("张三")
expect(result.output).toContain("- 产品 A: 99")
expect(result.errors).toContainEqual(
expect.objectContaining({ code: "MISSING_FRAGMENT", fragmentId: "fragment_signature" })
)
})
it("shows missing reference status", () => {
render(<App />)
expect(screen.getByText("missing refs: 1")).toBeInTheDocument()
})
it("opens a project through the persistence adapter", async () => {
const diskSnapshot = {
...useWorkbenchStore.getState().snapshot,
project: {
...useWorkbenchStore.getState().snapshot.project,
name: "Disk Project",
paths: {
...useWorkbenchStore.getState().snapshot.project.paths,
root: "D:/DiskProject"
}
}
}
setProjectPersistenceForTests({
isAvailable: () => true,
openProjectFromDisk: async () => diskSnapshot,
saveProjectChanges: async () => diskSnapshot
})
useWorkbenchStore.getState().resetDemo()
render(<App />)
fireEvent.click(screen.getByRole("button", { name: "Open project" }))
await waitFor(() => {
const state = useWorkbenchStore.getState()
expect(state.projectOpenStatus).toBe("open")
expect(state.snapshot.project.name).toBe("Disk Project")
expect(state.dirtyDocumentIds).toEqual([])
})
expect(screen.getByText("Disk Project")).toBeInTheDocument()
})
it("creates a fragment from the resource panel and selects it", () => {
render(<App />)
fireEvent.click(screen.getByRole("button", { name: "Create fragment" }))
const state = useWorkbenchStore.getState()
expect(state.snapshot.fragments.fragment_2).toMatchObject({
kind: "fragment",
name: "Fragment 2"
})
expect(state.selectedDocumentId).toBe("fragment_2")
expect(state.selectedResourcePath).toBe("fragments/fragment-2.json")
expect(state.saveStatus).toBe("dirty")
expect(screen.getByTestId("resource-panel")).toHaveTextContent("fragment-2.json")
})
it("saves dirty fragment changes through the persistence adapter", async () => {
const savedDirtyIds: string[][] = []
setProjectPersistenceForTests({
isAvailable: () => true,
openProjectFromDisk: async () => useWorkbenchStore.getState().snapshot,
saveProjectChanges: async (snapshot, dirtyDocumentIds) => {
savedDirtyIds.push(dirtyDocumentIds)
return snapshot
}
})
useWorkbenchStore.getState().resetDemo()
render(<App />)
fireEvent.click(screen.getByRole("button", { name: "Create fragment" }))
fireEvent.click(screen.getByRole("button", { name: "Save project" }))
await waitFor(() => {
const state = useWorkbenchStore.getState()
expect(savedDirtyIds).toEqual([["fragment_2"]])
expect(state.saveStatus).toBe("saved")
expect(state.dirtyDocumentIds).toEqual([])
})
})
it("shows persistence save errors", async () => {
setProjectPersistenceForTests({
isAvailable: () => true,
openProjectFromDisk: async () => useWorkbenchStore.getState().snapshot,
saveProjectChanges: async () => {
throw new Error("disk full")
}
})
useWorkbenchStore.getState().resetDemo()
render(<App />)
fireEvent.click(screen.getByRole("button", { name: "Create fragment" }))
fireEvent.click(screen.getByRole("button", { name: "Save project" }))
await waitFor(() => {
const state = useWorkbenchStore.getState()
expect(state.saveStatus).toBe("error")
expect(state.lastSaveError).toBe("disk full")
})
expect(screen.getByText("disk full")).toBeInTheDocument()
})
it("opens a referenced fragment from the references panel", () => {
useWorkbenchStore.getState().setRightPanelTab("references")
render(<App />)
fireEvent.click(screen.getByRole("button", { name: "Open fragment_common_footer" }))
const state = useWorkbenchStore.getState()
expect(state.selectedDocumentId).toBe("fragment_common_footer")
expect(state.selectedResourcePath).toBe("fragments/common-footer.json")
expect(getCurrentDocument(state).kind).toBe("fragment")
})
it("expands the selected fragment reference into editable blocks", async () => {
useWorkbenchStore.getState().setRightPanelTab("references")
useWorkbenchStore.getState().setSelectedBlockId("block_footer")
render(<App />)
fireEvent.click(screen.getByRole("button", { name: "Expand ref" }))
await waitFor(() => {
const state = useWorkbenchStore.getState()
const outgoing = state.snapshot.referenceIndex.outgoing.template_main?.map((item) => item.toFragmentId) ?? []
expect(outgoing).not.toContain("fragment_common_footer")
expect(JSON.stringify(state.snapshot.templates.template_main?.children)).not.toContain("block_footer")
expect(state.saveStatus).toBe("dirty")
expect(screen.getByTestId("blockflow-editor")).not.toHaveTextContent("fragment_common_footer")
})
})
it("updates the current dataset from valid JSON and refreshes preview data", async () => {
useWorkbenchStore.getState().setRightPanelTab("data")
render(<App />)
fireEvent.change(screen.getByLabelText("Dataset JSON"), {
target: {
value: JSON.stringify(
{
user: { name: "Ada", isVip: false },
items: []
},
null,
2
)
}
})
await waitFor(() => {
const state = useWorkbenchStore.getState()
expect(state.dataEditorStatus).toBe("valid")
expect(getCurrentRenderResult(state).output).toContain("Ada")
expect(state.saveStatus).toBe("dirty")
})
})
it("keeps the last valid dataset when JSON editing is invalid", async () => {
useWorkbenchStore.getState().setRightPanelTab("data")
render(<App />)
fireEvent.change(screen.getByLabelText("Dataset JSON"), {
target: { value: "{" }
})
await waitFor(() => {
const state = useWorkbenchStore.getState()
expect(state.dataEditorStatus).toBe("invalid")
expect(state.snapshot.datasets[state.currentDatasetId]?.data.user).toEqual(
expect.objectContaining({ name: "张三" })
)
})
expect(screen.getByTestId("data-tab")).toHaveTextContent("Expected")
})
it("renders the selected block preview", () => {
useWorkbenchStore.getState().setRightPanelTab("preview")
useWorkbenchStore.getState().setSelectedBlockId("block_hello")
render(<App />)
fireEvent.click(screen.getByRole("button", { name: "当前块" }))
const expected = getCurrentRenderResult(useWorkbenchStore.getState(), { targetBlockId: "block_hello" }).output
expect(screen.getByTestId("preview-tab")).toHaveTextContent("block_hello")
expect(screen.getByTestId("preview-output").textContent).toBe(expected)
})
it("shows missing variable details in preview", () => {
useWorkbenchStore.getState().setCurrentDataset("dataset_empty")
useWorkbenchStore.getState().setRightPanelTab("preview")
render(<App />)
expect(screen.getByTestId("preview-tab")).toHaveTextContent("user.name")
})
it("groups render errors, warnings, and logs in the debug tab", () => {
useWorkbenchStore.getState().setRightPanelTab("debug")
render(<App />)
expect(screen.getByTestId("debug-tab")).toHaveTextContent("Errors")
expect(screen.getByTestId("debug-tab")).toHaveTextContent("Warnings")
expect(screen.getByTestId("debug-tab")).toHaveTextContent("condition user.isVip")
expect(screen.getByTestId("debug-tab")).toHaveTextContent("loop items")
expect(screen.getByTestId("debug-tab")).toHaveTextContent("fragment fragment_common_footer")
})
})