@allbluecn/web-app 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +239 -30
- package/src/components/iteration/auto-schedule-dialog.tsx +150 -0
- package/src/components/iteration/burndown-chart.tsx +63 -0
- package/src/components/iteration/constants.ts +28 -0
- package/src/components/iteration/create-iteration-dialog.tsx +140 -0
- package/src/components/iteration/iteration-card.tsx +60 -0
- package/src/components/iteration/iteration-editor.tsx +116 -0
- package/src/components/iteration/iteration-progress-bar.tsx +44 -0
- package/src/components/iteration/iteration-status-badge.tsx +33 -0
- package/src/components/knowledge/dialog/knowledge-form-dialog.tsx +21 -30
- package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
- package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +2 -2
- package/src/components/layout/sidebar-03/nav-workspace.tsx +2 -2
- package/src/components/project/create-project-dialog.tsx +44 -57
- package/src/components/shared/__tests__/lucide-curated-icons.test.ts +83 -0
- package/src/components/shared/__tests__/lucide-icon-data.test.ts +131 -0
- package/src/components/shared/__tests__/lucide-icon-picker.test.tsx +197 -0
- package/src/components/shared/lucide-icon-picker/curated-icons.ts +478 -0
- package/src/components/shared/lucide-icon-picker/icon-data.ts +109 -0
- package/src/components/shared/lucide-icon-picker/index.tsx +82 -0
- package/src/components/shared/lucide-icon-picker/lucide-icon-by-name.tsx +78 -0
- package/src/components/shared/lucide-icon-picker/panel.tsx +163 -0
- package/src/components/story/__tests__/constants.test.ts +255 -0
- package/src/components/story/ai/story-ai-panel.tsx +23 -15
- package/src/components/story/ai/story-ai-preview-list.tsx +19 -0
- package/src/components/story/constants.ts +43 -1
- package/src/components/story/create-story-dialog.tsx +42 -16
- package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
- package/src/components/story/detail/__tests__/categories.test.tsx +62 -0
- package/src/components/story/detail/__tests__/dependencies-button.test.tsx +49 -0
- package/src/components/story/detail/activity-timeline.tsx +26 -4
- package/src/components/story/detail/story-detail-body.tsx +90 -38
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +857 -0
- package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
- package/src/components/story/detail/story-detail-toolbar/children.tsx +9 -4
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +95 -27
- package/src/components/story/detail/story-detail-toolbar.tsx +159 -8
- package/src/components/story/detail/story-properties.tsx +21 -4
- package/src/components/story/display/__tests__/group-fields.test.ts +107 -0
- package/src/components/story/display/group-resolver.tsx +2 -2
- package/src/components/story/inline-editors/__tests__/dependency-type-select.test.tsx +155 -0
- package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +155 -0
- package/src/components/story/inline-editors/__tests__/priority-editor.test.tsx +173 -0
- package/src/components/story/inline-editors/__tests__/state-editor.test.tsx +174 -0
- package/src/components/story/inline-editors/dependency-type-select.tsx +180 -0
- package/src/components/story/inline-editors/index.ts +2 -1
- package/src/components/story/inline-editors/labels-editor.tsx +159 -3
- package/src/components/story/inline-editors/story-select-dialog.tsx +41 -63
- package/src/components/story/peek-panel.tsx +121 -21
- package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
- package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
- package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
- package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
- package/src/components/story/relations/file-preview.tsx +173 -0
- package/src/components/story/relations/format.ts +120 -0
- package/src/components/story/relations/general-groups.ts +32 -0
- package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
- package/src/components/story/relations/link-manage-dialog.tsx +192 -0
- package/src/components/story/relations/story-ref-row.tsx +17 -27
- package/src/components/story/story-panel-header.tsx +25 -19
- package/src/components/story/story-toolbar.tsx +3 -3
- package/src/components/story/transfer-story-popover.tsx +2 -2
- package/src/components/story/types.ts +14 -0
- package/src/components/story/views/create-view-dialog.tsx +3 -3
- package/src/components/story/views/story-list/__tests__/group-header.test.tsx +219 -0
- package/src/components/story/views/story-list/group.tsx +1 -1
- package/src/components/story/views/story-list/inline-story-creator.tsx +2 -2
- package/src/components/story/views/story-list/row.tsx +1 -1
- package/src/components/workspace/create-workspace-dialog.tsx +41 -49
- package/src/lib/__tests__/project-display-id.test.ts +8 -5
- package/src/lib/changelog/sdk-versions.ts +20 -20
- package/src/lib/iteration/__tests__/status.test.ts +41 -0
- package/src/lib/iteration/status.ts +28 -0
- package/src/lib/project-display-id.ts +14 -5
- package/src/lib/project-module-meta.ts +77 -0
- package/src/lib/story/__tests__/video-embed.test.ts +77 -0
- package/src/lib/story/video-embed.ts +59 -0
- package/src/lib/utils.d.ts +1 -1
- package/src/plugins/.gen-manifest.json +13 -0
- package/src/plugins/modules.gen.ts +20 -0
- package/src/plugins/plugins.gen.ts +20 -0
- package/src/plugins/ui.gen.ts +8 -0
- package/src/providers/index.tsx +6 -0
- package/src/routeTree.gen.ts +96 -0
- package/src/routes/__root.tsx +0 -8
- package/src/routes/_nav/inspiration/route.tsx +8 -0
- package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
- package/src/routes/_nav/prd/$id/route.tsx +9 -0
- package/src/routes/_nav/prd/index.tsx +9 -0
- package/src/routes/_nav/prd/route.tsx +8 -0
- package/src/routes/_nav/projects/$projectId/$storyId/route.lazy.tsx +52 -25
- package/src/routes/_nav/projects/$projectId/$storyId/route.tsx +6 -3
- package/src/routes/_nav/projects/$projectId/index.lazy.tsx +32 -15
- package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.lazy.tsx +289 -0
- package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.tsx +27 -0
- package/src/routes/_nav/projects/$projectId/iterations/index.lazy.tsx +111 -0
- package/src/routes/_nav/projects/$projectId/iterations/index.tsx +24 -0
- package/src/routes/_nav/projects/$projectId/route.tsx +4 -2
- package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +78 -7
- package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
- package/src/routes/_nav/stories/$storyId/route.lazy.tsx +5 -1
- package/src/routes/_nav/stories/$storyId/route.tsx +4 -0
- package/src/routes/_nav/tags/$id/route.tsx +8 -0
- package/src/routes/_nav/tags/index.tsx +8 -0
- package/src/routes/_nav/tags/route.tsx +8 -0
- package/src/routes/_nav/tags/settings/route.tsx +9 -0
- package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +3 -3
- package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +4 -4
- package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
- package/src/routes/api/attachments/$id/content.ts +60 -0
- package/src/routes/api/attachments/upload.ts +148 -0
- package/src/routes/share/prd/$prdId/route.tsx +10 -0
- package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
- package/src/server/serverFns/iteration/__tests__/auto-schedule-core.test.ts +62 -0
- package/src/server/serverFns/iteration/__tests__/burndown-core.test.ts +90 -0
- package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +211 -0
- package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +155 -0
- package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +136 -0
- package/src/server/serverFns/iteration/auto-schedule-core.ts +54 -0
- package/src/server/serverFns/iteration/auto-schedule-internal.ts +88 -0
- package/src/server/serverFns/iteration/burndown-core.ts +60 -0
- package/src/server/serverFns/iteration/index.ts +22 -0
- package/src/server/serverFns/iteration/iteration-auto-schedule.ts +66 -0
- package/src/server/serverFns/iteration/iteration-burndown.ts +70 -0
- package/src/server/serverFns/iteration/iteration-crud.ts +208 -0
- package/src/server/serverFns/iteration/iteration-stories.ts +111 -0
- package/src/server/serverFns/iteration/iteration-transfer.ts +42 -0
- package/src/server/serverFns/iteration/schemas.ts +58 -0
- package/src/server/serverFns/iteration/transfer-internal.ts +62 -0
- package/src/server/serverFns/knowledge.ts +50 -4
- package/src/server/serverFns/project/module-overview.ts +109 -0
- package/src/server/serverFns/project.ts +45 -2
- package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
- package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +100 -0
- package/src/server/serverFns/story/__tests__/labels.test.ts +67 -0
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +132 -2
- package/src/server/serverFns/story/index.ts +9 -4
- package/src/server/serverFns/story/kb-general.ts +39 -0
- package/src/server/serverFns/story/kb-scope.ts +52 -0
- package/src/server/serverFns/story/link-preview.ts +115 -0
- package/src/server/serverFns/story/story-assoc.ts +372 -51
- package/src/server/serverFns/story/story-attachments.ts +32 -45
- package/src/{components/project/project-icon.tsx → server/serverFns/story/story-crud/label-palette.ts} +16 -19
- package/src/server/serverFns/story/story-crud/label-suggest-core.ts +88 -0
- package/src/server/serverFns/story/story-crud/label-suggest.ts +38 -0
- package/src/{components/knowledge/knowledge-icon.tsx → server/serverFns/story/story-crud/label-upsert.ts} +13 -19
- package/src/server/serverFns/story/story-crud/labels.ts +3 -5
- package/src/server/serverFns/story/story-crud.ts +53 -6
- package/src/server/serverFns/story/story-dependencies.ts +158 -5
- package/src/server/serverFns/story/story-links.ts +43 -7
- package/src/server/serverFns/story/story-transfer.ts +5 -4
- package/src/server/serverFns/story/story-tree.ts +28 -1
- package/src/server/serverFns/story/types.ts +38 -0
- package/src/styles/globals.css +25 -0
- package/src/types/project.ts +5 -5
- package/vite.shared.ts +2 -0
- package/src/components/project/project-animated-icon.tsx +0 -73
- package/src/components/shared/icon-packs/index.ts +0 -1
- package/src/components/shared/icon-packs/knowledge.ts +0 -1
- package/src/components/shared/icon-packs/prd.ts +0 -1
- package/src/components/shared/icon-packs/project.ts +0 -1
- package/src/components/shared/icon-picker.tsx +0 -1
- package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
- package/src/components/story/relations/attachments-panel.tsx +0 -179
- package/src/components/story/relations/knowledge-panel.tsx +0 -284
- package/src/components/story/relations/links-panel.tsx +0 -240
- package/src/lib/project-mock.ts +0 -156
|
@@ -328,8 +328,8 @@ export const deleteProjectFn = createServerFn({ method: "POST" })
|
|
|
328
328
|
await prisma.$transaction(async (tx) => {
|
|
329
329
|
for (const id of storyIds) {
|
|
330
330
|
const counter = await tx.sequenceCounter.upsert({
|
|
331
|
-
where: { scope:
|
|
332
|
-
create: { scope:
|
|
331
|
+
where: { scope: independentId },
|
|
332
|
+
create: { scope: independentId, seq: 1 },
|
|
333
333
|
update: { seq: { increment: 1 } },
|
|
334
334
|
});
|
|
335
335
|
await tx.story.update({
|
|
@@ -573,3 +573,46 @@ export const ensureBuiltinProjectFn = createServerFn({ method: "GET" }).handler(
|
|
|
573
573
|
const projectId = await ensureBuiltinIndependentProject(userId);
|
|
574
574
|
return { id: projectId, name: BUILTIN_INDEPENDENT_NAME, displayId: BUILTIN_INDEPENDENT_DISPLAY_ID, icon: BUILTIN_INDEPENDENT_ICON };
|
|
575
575
|
});
|
|
576
|
+
|
|
577
|
+
export const listProjectKbsFn = createServerFn({ method: "GET" })
|
|
578
|
+
.validator(z.object({ projectId: z.string() }))
|
|
579
|
+
.handler(async ({ data }) => {
|
|
580
|
+
const request = getRequest();
|
|
581
|
+
await requireAuth(request);
|
|
582
|
+
const rows = await prisma.projectKnowledgeBase.findMany({
|
|
583
|
+
where: { projectId: data.projectId },
|
|
584
|
+
select: {
|
|
585
|
+
kb: { select: { id: true, name: true, icon: true, visibility: true } },
|
|
586
|
+
},
|
|
587
|
+
orderBy: { createdAt: "asc" },
|
|
588
|
+
});
|
|
589
|
+
return rows.map((r) => r.kb);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
export const setProjectKbsFn = createServerFn({ method: "POST" })
|
|
593
|
+
.validator(z.object({ projectId: z.string(), kbIds: z.array(z.string()) }))
|
|
594
|
+
.handler(async ({ data }) => {
|
|
595
|
+
const request = getRequest();
|
|
596
|
+
await requireAuth(request);
|
|
597
|
+
if (data.kbIds.length > 0) {
|
|
598
|
+
const valid = await prisma.knowledgeBase.findMany({
|
|
599
|
+
where: { id: { in: data.kbIds }, visibility: "team", isDeleted: false },
|
|
600
|
+
select: { id: true },
|
|
601
|
+
});
|
|
602
|
+
if (valid.length !== data.kbIds.length) {
|
|
603
|
+
throw new Error("仅允许团队可见的知识库关联项目");
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
await prisma.$transaction(async (tx) => {
|
|
607
|
+
await tx.projectKnowledgeBase.deleteMany({
|
|
608
|
+
where: { projectId: data.projectId, kbId: { notIn: data.kbIds } },
|
|
609
|
+
});
|
|
610
|
+
if (data.kbIds.length > 0) {
|
|
611
|
+
await tx.projectKnowledgeBase.createMany({
|
|
612
|
+
data: data.kbIds.map((kbId) => ({ projectId: data.projectId, kbId })),
|
|
613
|
+
skipDuplicates: true,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
return { ok: true };
|
|
618
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect } from "vitest";
|
|
19
|
+
import { isIndependentProject, buildKbScopeWhere } from "../kb-scope";
|
|
20
|
+
|
|
21
|
+
describe("kb-scope 可用范围判定", () => {
|
|
22
|
+
it("独立需求项目(displayId=STORY)识别", () => {
|
|
23
|
+
expect(isIndependentProject("STORY")).toBe(true);
|
|
24
|
+
expect(isIndependentProject("story")).toBe(false);
|
|
25
|
+
expect(isIndependentProject(null)).toBe(false);
|
|
26
|
+
expect(isIndependentProject("PROJ")).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("普通项目:general ∪ 项目关联(不含他人 private/team)", () => {
|
|
30
|
+
const where = buildKbScopeWhere({
|
|
31
|
+
projectId: "p1",
|
|
32
|
+
projectDisplayId: "PROJ",
|
|
33
|
+
userId: "u1",
|
|
34
|
+
});
|
|
35
|
+
expect(where).toEqual({
|
|
36
|
+
OR: [
|
|
37
|
+
{ visibility: "general" },
|
|
38
|
+
{ projectLinks: { some: { projectId: "p1" } } },
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("独立需求项目:自己的全部 ∪ 他人 team/general", () => {
|
|
44
|
+
const where = buildKbScopeWhere({
|
|
45
|
+
projectId: "p2",
|
|
46
|
+
projectDisplayId: "STORY",
|
|
47
|
+
userId: "u1",
|
|
48
|
+
});
|
|
49
|
+
expect(where).toEqual({
|
|
50
|
+
OR: [
|
|
51
|
+
{ userId: "u1" },
|
|
52
|
+
{ visibility: { in: ["team", "general"] } },
|
|
53
|
+
],
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, vi, beforeEach } from "vitest"
|
|
19
|
+
|
|
20
|
+
const mocks = vi.hoisted(() => ({
|
|
21
|
+
labelFindMany: vi.fn(),
|
|
22
|
+
storyLabelGroupBy: vi.fn(),
|
|
23
|
+
runChat: vi.fn(),
|
|
24
|
+
hasAiKey: vi.fn(),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
vi.mock("@allbluecn/database", () => ({
|
|
28
|
+
prisma: {
|
|
29
|
+
label: { findMany: mocks.labelFindMany },
|
|
30
|
+
storyLabel: { groupBy: mocks.storyLabelGroupBy },
|
|
31
|
+
},
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
vi.mock("@allbluecn/ai-gateway", async (importOriginal) => {
|
|
35
|
+
const actual = await importOriginal<typeof import("@allbluecn/ai-gateway")>()
|
|
36
|
+
return {
|
|
37
|
+
...actual,
|
|
38
|
+
runChat: mocks.runChat,
|
|
39
|
+
hasAiKey: mocks.hasAiKey,
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
import { suggestLabelsCore } from "../story-crud/label-suggest-core"
|
|
44
|
+
|
|
45
|
+
const ALL_LABELS = [
|
|
46
|
+
{ id: "l1", name: "性能", color: "#ef4444" },
|
|
47
|
+
{ id: "l2", name: "安全", color: "#3b82f6" },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
vi.clearAllMocks()
|
|
52
|
+
mocks.hasAiKey.mockResolvedValue(true)
|
|
53
|
+
mocks.labelFindMany.mockResolvedValue(ALL_LABELS)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe("suggestLabelsCore", () => {
|
|
57
|
+
it("正常链路:查标签→runChat→解析→返回 existing 实体与 newNames", async () => {
|
|
58
|
+
mocks.runChat.mockResolvedValue('{"existing":["性能"],"new":["支付"]}')
|
|
59
|
+
const r = await suggestLabelsCore({ name: "优化登录" }, "u1")
|
|
60
|
+
expect(mocks.runChat).toHaveBeenCalledTimes(1)
|
|
61
|
+
const call = mocks.runChat.mock.calls[0] as any[]
|
|
62
|
+
expect(call[0][0].role).toBe("user")
|
|
63
|
+
expect(call[1].systemPrompts[0]).toContain("性能、安全")
|
|
64
|
+
expect(call[1].modelOptions).toEqual({ temperature: 0.2 })
|
|
65
|
+
expect(r.existingLabels).toEqual([{ id: "l1", name: "性能", color: "#ef4444" }])
|
|
66
|
+
expect(r.newNames).toEqual(["支付"])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("AI 未配置抛错", async () => {
|
|
70
|
+
mocks.hasAiKey.mockResolvedValue(false)
|
|
71
|
+
await expect(suggestLabelsCore({ name: "x" }, "u1")).rejects.toMatchObject({
|
|
72
|
+
message: expect.stringContaining("AI"),
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("标签超 100 个时按使用频次截断", async () => {
|
|
77
|
+
const many = Array.from({ length: 120 }, (_, i) => ({
|
|
78
|
+
id: `l${i}`,
|
|
79
|
+
name: `标签${i}`,
|
|
80
|
+
color: "#6b7280",
|
|
81
|
+
}))
|
|
82
|
+
mocks.labelFindMany.mockResolvedValue(many)
|
|
83
|
+
mocks.storyLabelGroupBy.mockResolvedValue(
|
|
84
|
+
Array.from({ length: 100 }, (_, i) => ({ labelId: `l${i}`, _count: { _all: 100 - i } })),
|
|
85
|
+
)
|
|
86
|
+
mocks.runChat.mockResolvedValue('{"existing":[],"new":[]}')
|
|
87
|
+
await suggestLabelsCore({ name: "x" }, "u1")
|
|
88
|
+
expect(mocks.storyLabelGroupBy).toHaveBeenCalledTimes(1)
|
|
89
|
+
const prompt = (mocks.runChat.mock.calls[0] as any[])[1].systemPrompts[0] as string
|
|
90
|
+
const listed = prompt.split("已有标签:")[1]
|
|
91
|
+
expect(listed.split("、")).toHaveLength(100)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("AI 输出解析失败抛错", async () => {
|
|
95
|
+
mocks.runChat.mockResolvedValue("垃圾输出")
|
|
96
|
+
await expect(suggestLabelsCore({ name: "x" }, "u1")).rejects.toMatchObject({
|
|
97
|
+
message: expect.stringContaining("解析失败"),
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
19
|
+
|
|
20
|
+
const mocks = vi.hoisted(() => ({
|
|
21
|
+
labelUpsert: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("@allbluecn/database", () => ({
|
|
25
|
+
prisma: { label: { upsert: mocks.labelUpsert } },
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import { upsertLabel } from "../story-crud/label-upsert";
|
|
29
|
+
import { pickLabelColor, LABEL_PALETTE } from "../story-crud/label-palette";
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.clearAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("pickLabelColor", () => {
|
|
36
|
+
it("同名稳定同色且落在色板内", () => {
|
|
37
|
+
const a = pickLabelColor("性能");
|
|
38
|
+
expect(pickLabelColor("性能")).toBe(a);
|
|
39
|
+
expect(LABEL_PALETTE).toContain(a);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("不同标签名颜色分布均匀(6 个标签至少 4 色)", () => {
|
|
43
|
+
const colors = new Set(["性能", "安全", "登录", "支付", "导入", "导出"].map(pickLabelColor));
|
|
44
|
+
expect(colors.size).toBeGreaterThanOrEqual(4);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("upsertLabel", () => {
|
|
49
|
+
it("走 upsert 且 create 分支使用色板颜色", async () => {
|
|
50
|
+
mocks.labelUpsert.mockResolvedValue({ id: "l1", name: "性能", color: "#ef4444" });
|
|
51
|
+
const r = await upsertLabel("性能", "u1");
|
|
52
|
+
expect(mocks.labelUpsert).toHaveBeenCalledTimes(1);
|
|
53
|
+
const arg = mocks.labelUpsert.mock.calls[0][0] as any;
|
|
54
|
+
expect(arg.where).toEqual({ name: "性能" });
|
|
55
|
+
expect(arg.update).toEqual({});
|
|
56
|
+
expect(LABEL_PALETTE).toContain(arg.create.color);
|
|
57
|
+
expect(arg.create.createdById).toBe("u1");
|
|
58
|
+
expect(r).toEqual({ id: "l1", name: "性能", color: "#ef4444" });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("传自定义颜色时优先使用自定义颜色", async () => {
|
|
62
|
+
mocks.labelUpsert.mockResolvedValue({ id: "l1", name: "性能", color: "#ff0000" });
|
|
63
|
+
await upsertLabel("性能", "u1", "#ff0000");
|
|
64
|
+
const arg = mocks.labelUpsert.mock.calls[0][0] as any;
|
|
65
|
+
expect(arg.create.color).toBe("#ff0000");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
19
|
+
|
|
20
|
+
vi.mock("@tanstack/react-start", () => ({
|
|
21
|
+
createServerFn: (config: any) => {
|
|
22
|
+
const api: any = {
|
|
23
|
+
_validator: undefined,
|
|
24
|
+
validator(schema: any) {
|
|
25
|
+
this._validator = schema;
|
|
26
|
+
return this;
|
|
27
|
+
},
|
|
28
|
+
handler(fn: Function) {
|
|
29
|
+
const wrapped = async (args: any) => {
|
|
30
|
+
let data = args?.data;
|
|
31
|
+
if (this._validator && data !== undefined) {
|
|
32
|
+
data = this._validator.parse ? this._validator.parse(data) : data;
|
|
33
|
+
}
|
|
34
|
+
return fn({ data });
|
|
35
|
+
};
|
|
36
|
+
return wrapped;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
return api;
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
vi.mock("@tanstack/react-start/server", () => ({
|
|
44
|
+
getRequest: () => ({}),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const mocks = vi.hoisted(() => ({
|
|
48
|
+
storyFindUnique: vi.fn(),
|
|
49
|
+
kbFindMany: vi.fn(),
|
|
50
|
+
kbFindFirst: vi.fn(),
|
|
51
|
+
docFindMany: vi.fn(),
|
|
52
|
+
linkFindMany: vi.fn(),
|
|
53
|
+
linkCreateMany: vi.fn(),
|
|
54
|
+
extLinkFindMany: vi.fn(),
|
|
55
|
+
extLinkCreateMany: vi.fn(),
|
|
56
|
+
extLinkDeleteMany: vi.fn(),
|
|
57
|
+
activityCreate: vi.fn(),
|
|
58
|
+
currentSession: vi.fn(),
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
vi.mock("@allbluecn/database", () => {
|
|
62
|
+
const prisma = {
|
|
63
|
+
story: { findUnique: mocks.storyFindUnique },
|
|
64
|
+
knowledgeBase: { findMany: mocks.kbFindMany, findFirst: mocks.kbFindFirst },
|
|
65
|
+
knowledgeDocument: { findMany: mocks.docFindMany },
|
|
66
|
+
storyDocLink: { findMany: mocks.linkFindMany, createMany: mocks.linkCreateMany },
|
|
67
|
+
storyExternalLink: {
|
|
68
|
+
findMany: mocks.extLinkFindMany,
|
|
69
|
+
createMany: mocks.extLinkCreateMany,
|
|
70
|
+
deleteMany: mocks.extLinkDeleteMany,
|
|
71
|
+
},
|
|
72
|
+
storyActivity: { create: mocks.activityCreate },
|
|
73
|
+
};
|
|
74
|
+
return { prisma, Prisma: {} };
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
vi.mock("@/server/auth-helpers", () => ({
|
|
78
|
+
requireAuth: mocks.currentSession,
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
import {
|
|
82
|
+
listStoryAvailableKbsFn,
|
|
83
|
+
searchKbDocsFn,
|
|
84
|
+
linkDocsFn,
|
|
85
|
+
getLinkedDocsFn,
|
|
86
|
+
} from "../story-assoc";
|
|
87
|
+
|
|
88
|
+
const SESSION = { user: { id: "u1" } };
|
|
89
|
+
const STORY_WITH_PROJECT = {
|
|
90
|
+
project: { id: "p1", displayId: "PROJ" },
|
|
91
|
+
};
|
|
92
|
+
const STORY_INDEPENDENT = {
|
|
93
|
+
project: { id: "p2", displayId: "STORY" },
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
vi.clearAllMocks();
|
|
98
|
+
mocks.currentSession.mockResolvedValue(SESSION);
|
|
99
|
+
mocks.storyFindUnique.mockResolvedValue(STORY_WITH_PROJECT);
|
|
100
|
+
mocks.extLinkFindMany.mockResolvedValue([]);
|
|
101
|
+
mocks.extLinkCreateMany.mockResolvedValue({ count: 0 });
|
|
102
|
+
mocks.extLinkDeleteMany.mockResolvedValue({ count: 0 });
|
|
103
|
+
mocks.activityCreate.mockResolvedValue({
|
|
104
|
+
id: "act1",
|
|
105
|
+
action: "kb_linked",
|
|
106
|
+
field: null,
|
|
107
|
+
oldValue: null,
|
|
108
|
+
newValue: "1",
|
|
109
|
+
isAI: false,
|
|
110
|
+
createdAt: new Date("2026-08-31T00:00:00Z"),
|
|
111
|
+
user: { id: "u1", username: "u1", email: "e", avatarConfig: null, bgShape: null, bgColor: null },
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("listStoryAvailableKbsFn", () => {
|
|
116
|
+
it("普通项目:按 scope 查询返回 KB 列表", async () => {
|
|
117
|
+
mocks.kbFindMany.mockResolvedValue([
|
|
118
|
+
{ id: "kb1", name: "团队库", icon: "notebook", visibility: "team" },
|
|
119
|
+
{ id: "kb2", name: "通用库", icon: "book", visibility: "general" },
|
|
120
|
+
]);
|
|
121
|
+
const result = await listStoryAvailableKbsFn({ data: { storyId: "s1" } });
|
|
122
|
+
expect(result).toHaveLength(2);
|
|
123
|
+
expect(result[0]).toMatchObject({ id: "kb1", name: "团队库" });
|
|
124
|
+
const where = mocks.kbFindMany.mock.calls[0][0].where;
|
|
125
|
+
expect(where.AND).toBeDefined(); // AND: [scope, { isDeleted: false }]
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("需求不存在抛错", async () => {
|
|
129
|
+
mocks.storyFindUnique.mockResolvedValue(null);
|
|
130
|
+
await expect(listStoryAvailableKbsFn({ data: { storyId: "nope" } })).rejects.toMatchObject({
|
|
131
|
+
message: expect.stringContaining("需求不存在"),
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("searchKbDocsFn 两态", () => {
|
|
137
|
+
it("指定 kbId:全量树查询(take 500,含 parentId/type/kb 元数据)", async () => {
|
|
138
|
+
mocks.storyFindUnique.mockResolvedValue(STORY_WITH_PROJECT);
|
|
139
|
+
mocks.kbFindFirst.mockResolvedValue({ id: "kb1" }); // 范围内
|
|
140
|
+
mocks.docFindMany.mockResolvedValue([
|
|
141
|
+
{ id: "d1", title: "页1", parentId: null, kb: { id: "kb1", name: "团队库", icon: "notebook" }, icon: "doc" },
|
|
142
|
+
]);
|
|
143
|
+
const result = await searchKbDocsFn({ data: { storyId: "s1", kbId: "kb1", query: "" } });
|
|
144
|
+
expect(result.items[0]).toMatchObject({ id: "d1", kbName: "团队库" });
|
|
145
|
+
expect(result.nextCursor).toBeNull();
|
|
146
|
+
const args = mocks.docFindMany.mock.calls[0][0];
|
|
147
|
+
expect(args.take).toBe(500);
|
|
148
|
+
expect(args.where.kbId).toBe("kb1");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("指定范围外 kbId:抛越界错误", async () => {
|
|
152
|
+
mocks.storyFindUnique.mockResolvedValue(STORY_WITH_PROJECT);
|
|
153
|
+
mocks.kbFindFirst.mockResolvedValue(null); // 不在 scope 内
|
|
154
|
+
await expect(
|
|
155
|
+
searchKbDocsFn({ data: { storyId: "s1", kbId: "kbX", query: "" } }),
|
|
156
|
+
).rejects.toMatchObject({ message: expect.stringContaining("不在可用范围") });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("全部模式:平铺分页(take 21 判 hasMore)", async () => {
|
|
160
|
+
mocks.storyFindUnique.mockResolvedValue(STORY_WITH_PROJECT);
|
|
161
|
+
mocks.docFindMany.mockResolvedValue([]);
|
|
162
|
+
await searchKbDocsFn({ data: { storyId: "s1", query: "关键词" } });
|
|
163
|
+
const args = mocks.docFindMany.mock.calls[0][0];
|
|
164
|
+
expect(args.take).toBe(21);
|
|
165
|
+
expect(args.where.kb).toBeDefined(); // scope 下推到 kb 关系
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("linkDocsFn 批量关联", () => {
|
|
170
|
+
it("合法项批量建链、已存在跳过、越界归 failed", async () => {
|
|
171
|
+
mocks.storyFindUnique.mockResolvedValue(STORY_WITH_PROJECT);
|
|
172
|
+
mocks.docFindMany.mockResolvedValue([
|
|
173
|
+
{ id: "d1", kbId: "kb1" }, // 合法
|
|
174
|
+
{ id: "d2", kbId: "kb9" }, // 越界(kb9 不在 scope)
|
|
175
|
+
]);
|
|
176
|
+
mocks.kbFindMany.mockResolvedValue([{ id: "kb1" }, { id: "kb2" }]); // scope 内 KB 集合
|
|
177
|
+
mocks.linkFindMany.mockResolvedValue([{ docId: "d1" }]); // d1 已关联
|
|
178
|
+
mocks.linkCreateMany.mockResolvedValue({ count: 0 });
|
|
179
|
+
|
|
180
|
+
const result = await linkDocsFn({ data: { storyId: "s1", docIds: ["d1", "d2"] } });
|
|
181
|
+
expect(result.linked).toEqual([]);
|
|
182
|
+
expect(result.skipped).toEqual(["d1"]);
|
|
183
|
+
expect(result.failed).toEqual([{ docId: "d2", reason: "outOfScope" }]);
|
|
184
|
+
expect(mocks.linkCreateMany).not.toHaveBeenCalled(); // 无新增
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("新增项走 createMany skipDuplicates", async () => {
|
|
188
|
+
mocks.docFindMany.mockResolvedValue([{ id: "d3", kbId: "kb1" }]);
|
|
189
|
+
mocks.kbFindMany.mockResolvedValue([{ id: "kb1" }]);
|
|
190
|
+
mocks.linkFindMany.mockResolvedValue([]);
|
|
191
|
+
mocks.linkCreateMany.mockResolvedValue({ count: 1 });
|
|
192
|
+
const result = await linkDocsFn({ data: { storyId: "s1", docIds: ["d3"] } });
|
|
193
|
+
expect(result.linked).toEqual(["d3"]);
|
|
194
|
+
expect(mocks.linkCreateMany.mock.calls[0][0].data).toEqual([{ storyId: "s1", docId: "d3" }]);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("getLinkedDocsFn 扩展返回 KB 元数据", () => {
|
|
199
|
+
it("返回对齐 DocBrief 的字段", async () => {
|
|
200
|
+
mocks.linkFindMany.mockResolvedValue([
|
|
201
|
+
{
|
|
202
|
+
doc: {
|
|
203
|
+
id: "d1",
|
|
204
|
+
title: "页1",
|
|
205
|
+
kb: { id: "kb1", name: "团队库", icon: "notebook" },
|
|
206
|
+
icon: "doc",
|
|
207
|
+
parentId: null,
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
]);
|
|
211
|
+
const result = await getLinkedDocsFn({ data: { storyId: "s1" } });
|
|
212
|
+
expect(result[0]).toEqual({
|
|
213
|
+
id: "d1",
|
|
214
|
+
title: "页1",
|
|
215
|
+
kbId: "kb1",
|
|
216
|
+
kbName: "团队库",
|
|
217
|
+
kbIcon: "notebook",
|
|
218
|
+
docIcon: "doc",
|
|
219
|
+
parentId: null,
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -47,9 +47,13 @@ vi.mock("@tanstack/react-start/server", () => ({
|
|
|
47
47
|
const mocks = vi.hoisted(() => ({
|
|
48
48
|
depFindMany: vi.fn(),
|
|
49
49
|
depFindFirst: vi.fn(),
|
|
50
|
+
depFindUnique: vi.fn(),
|
|
50
51
|
depCreate: vi.fn(),
|
|
52
|
+
depUpdate: vi.fn(),
|
|
51
53
|
depDelete: vi.fn(),
|
|
52
54
|
storyFindMany: vi.fn(),
|
|
55
|
+
storyFindUnique: vi.fn(),
|
|
56
|
+
activityCreate: vi.fn(),
|
|
53
57
|
currentSession: vi.fn(),
|
|
54
58
|
}));
|
|
55
59
|
|
|
@@ -58,10 +62,13 @@ vi.mock("@allbluecn/database", () => {
|
|
|
58
62
|
storyDependency: {
|
|
59
63
|
findMany: mocks.depFindMany,
|
|
60
64
|
findFirst: mocks.depFindFirst,
|
|
65
|
+
findUnique: mocks.depFindUnique,
|
|
61
66
|
create: mocks.depCreate,
|
|
67
|
+
update: mocks.depUpdate,
|
|
62
68
|
delete: mocks.depDelete,
|
|
63
69
|
},
|
|
64
|
-
story: { findMany: mocks.storyFindMany },
|
|
70
|
+
story: { findMany: mocks.storyFindMany, findUnique: mocks.storyFindUnique },
|
|
71
|
+
storyActivity: { create: mocks.activityCreate },
|
|
65
72
|
};
|
|
66
73
|
return { prisma, Prisma: {} };
|
|
67
74
|
});
|
|
@@ -72,6 +79,7 @@ vi.mock("@/server/auth-helpers", () => ({
|
|
|
72
79
|
|
|
73
80
|
import {
|
|
74
81
|
addDependenciesFn,
|
|
82
|
+
updateDependencyFn,
|
|
75
83
|
removeDependencyFn,
|
|
76
84
|
getDependenciesFn,
|
|
77
85
|
} from "../story-dependencies";
|
|
@@ -104,6 +112,24 @@ beforeEach(() => {
|
|
|
104
112
|
mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
|
|
105
113
|
// 默认 DB 无既有依赖边(handler 开头一次性取全边建邻接表,必须返回数组)
|
|
106
114
|
mocks.depFindMany.mockResolvedValue([]);
|
|
115
|
+
// 活动写入段:端点 label 查询 + create 返回(含 select 字段)
|
|
116
|
+
mocks.storyFindMany.mockResolvedValue([
|
|
117
|
+
{ id: "a", identifier: "A-1", name: "story a" },
|
|
118
|
+
{ id: "b", identifier: "B-1", name: "story b" },
|
|
119
|
+
]);
|
|
120
|
+
mocks.activityCreate.mockImplementation(({ data }: any) =>
|
|
121
|
+
Promise.resolve({
|
|
122
|
+
id: `act-${data.storyId}`,
|
|
123
|
+
action: "dependency_added",
|
|
124
|
+
field: null,
|
|
125
|
+
oldValue: null,
|
|
126
|
+
newValue: data.newValue,
|
|
127
|
+
isAI: false,
|
|
128
|
+
createdAt: new Date("2026-08-31T00:00:00Z"),
|
|
129
|
+
storyId: data.storyId,
|
|
130
|
+
user: { id: "u1", username: "u1", email: "e", avatarConfig: null, bgShape: null, bgColor: null },
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
107
133
|
});
|
|
108
134
|
|
|
109
135
|
describe("addDependenciesFn", () => {
|
|
@@ -187,6 +213,12 @@ describe("addDependenciesFn", () => {
|
|
|
187
213
|
expect(mocks.depFindMany).toHaveBeenCalledWith({
|
|
188
214
|
select: { sourceId: true, targetId: true },
|
|
189
215
|
});
|
|
216
|
+
// 活动契约:依赖两端各记一条「添加依赖关系」,newValue 为对方描述
|
|
217
|
+
expect(res.activities).toHaveLength(2);
|
|
218
|
+
expect(res.activities.map((x) => [x.storyId, x.newValue]).sort()).toEqual([
|
|
219
|
+
["a", "B-1 story b"],
|
|
220
|
+
["b", "A-1 story a"],
|
|
221
|
+
]);
|
|
190
222
|
});
|
|
191
223
|
|
|
192
224
|
it("validator 拒绝旧枚举值:BLOCKED_BY 不再合法", async () => {
|
|
@@ -199,12 +231,110 @@ describe("addDependenciesFn", () => {
|
|
|
199
231
|
});
|
|
200
232
|
});
|
|
201
233
|
|
|
234
|
+
describe("updateDependencyFn", () => {
|
|
235
|
+
const EXISTING = { id: "d1", sourceId: "a", targetId: "b", type: "BLOCKS" };
|
|
236
|
+
const UPDATE_ACTIVITY = {
|
|
237
|
+
id: "act1",
|
|
238
|
+
action: "dependency_updated",
|
|
239
|
+
field: null,
|
|
240
|
+
oldValue: null,
|
|
241
|
+
newValue: "B-1 story b",
|
|
242
|
+
isAI: false,
|
|
243
|
+
createdAt: new Date("2026-08-31T00:00:00Z"),
|
|
244
|
+
storyId: "a",
|
|
245
|
+
user: { id: "u1", username: "u1", email: "e", avatarConfig: null, bgShape: null, bgColor: null },
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
it("同向改类型:仅更新 type,两端各记一条活动", async () => {
|
|
249
|
+
mocks.depFindUnique.mockResolvedValue(EXISTING);
|
|
250
|
+
mocks.depFindMany.mockResolvedValue([]); // 排除自身后无其他边
|
|
251
|
+
mocks.depUpdate.mockResolvedValue({ ...EXISTING, type: "STARTS_BEFORE" });
|
|
252
|
+
mocks.activityCreate.mockResolvedValue(UPDATE_ACTIVITY);
|
|
253
|
+
const res = await updateDependencyFn({
|
|
254
|
+
data: { depId: "d1", sourceId: "a", targetId: "b", type: "STARTS_BEFORE" },
|
|
255
|
+
});
|
|
256
|
+
expect(res.ok).toBe(true);
|
|
257
|
+
expect(mocks.depUpdate).toHaveBeenCalledWith({
|
|
258
|
+
where: { id: "d1" },
|
|
259
|
+
data: { sourceId: "a", targetId: "b", type: "STARTS_BEFORE" },
|
|
260
|
+
});
|
|
261
|
+
expect(res.activities).toHaveLength(2);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("方向反转合法时更新为新的 source/target", async () => {
|
|
265
|
+
mocks.depFindUnique.mockResolvedValue(EXISTING);
|
|
266
|
+
mocks.depFindMany.mockResolvedValue([]);
|
|
267
|
+
mocks.depUpdate.mockResolvedValue({ ...EXISTING, sourceId: "b", targetId: "a" });
|
|
268
|
+
mocks.activityCreate.mockResolvedValue(UPDATE_ACTIVITY);
|
|
269
|
+
const res = await updateDependencyFn({
|
|
270
|
+
data: { depId: "d1", sourceId: "b", targetId: "a", type: "BLOCKS" },
|
|
271
|
+
});
|
|
272
|
+
expect(res.ok).toBe(true);
|
|
273
|
+
expect(mocks.depUpdate).toHaveBeenCalledWith({
|
|
274
|
+
where: { id: "d1" },
|
|
275
|
+
data: { sourceId: "b", targetId: "a", type: "BLOCKS" },
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("方向反转成环时拒绝写入", async () => {
|
|
280
|
+
mocks.depFindUnique.mockResolvedValue(EXISTING);
|
|
281
|
+
// 现边 a -> b;另已存在路径 a -> c -> b。请求反转成 b -> a 后,
|
|
282
|
+
// 从新 target(a) 出发可直达新 source(b) ⇒ 成环,必须拒绝
|
|
283
|
+
mocks.depFindMany.mockResolvedValue([
|
|
284
|
+
{ sourceId: "a", targetId: "c" },
|
|
285
|
+
{ sourceId: "c", targetId: "b" },
|
|
286
|
+
]);
|
|
287
|
+
const res = await updateDependencyFn({
|
|
288
|
+
data: { depId: "d1", sourceId: "b", targetId: "a", type: "BLOCKS" },
|
|
289
|
+
});
|
|
290
|
+
expect(res).toEqual({ ok: false, reason: "CYCLE" });
|
|
291
|
+
expect(mocks.depUpdate).not.toHaveBeenCalled();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("三元组与现状一致时短路返回且不写库", async () => {
|
|
295
|
+
mocks.depFindUnique.mockResolvedValue(EXISTING);
|
|
296
|
+
const res = await updateDependencyFn({
|
|
297
|
+
data: { depId: "d1", sourceId: "a", targetId: "b", type: "BLOCKS" },
|
|
298
|
+
});
|
|
299
|
+
expect(res).toEqual({ ok: true, activities: [] });
|
|
300
|
+
expect(mocks.depUpdate).not.toHaveBeenCalled();
|
|
301
|
+
expect(mocks.depFindMany).not.toHaveBeenCalled();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("两端不属于原边时抛错", async () => {
|
|
305
|
+
mocks.depFindUnique.mockResolvedValue(EXISTING);
|
|
306
|
+
await expect(
|
|
307
|
+
updateDependencyFn({
|
|
308
|
+
data: { depId: "d1", sourceId: "a", targetId: "c", type: "BLOCKS" },
|
|
309
|
+
}),
|
|
310
|
+
).rejects.toMatchObject({ message: expect.stringContaining("依赖关系两端不匹配") });
|
|
311
|
+
expect(mocks.depUpdate).not.toHaveBeenCalled();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("depId 不存在时抛错", async () => {
|
|
315
|
+
mocks.depFindUnique.mockResolvedValue(null);
|
|
316
|
+
await expect(
|
|
317
|
+
updateDependencyFn({
|
|
318
|
+
data: { depId: "missing", sourceId: "a", targetId: "b", type: "BLOCKS" },
|
|
319
|
+
}),
|
|
320
|
+
).rejects.toMatchObject({ message: expect.stringContaining("依赖关系不存在") });
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
202
324
|
describe("removeDependencyFn", () => {
|
|
203
325
|
it("按 depId 删除", async () => {
|
|
326
|
+
mocks.depFindUnique.mockResolvedValue({ id: "d1", sourceId: "s1", targetId: "t1" });
|
|
327
|
+
mocks.storyFindUnique.mockImplementation(({ where }: any) => {
|
|
328
|
+
if (where.id === "s1") return Promise.resolve({ identifier: "A-1", name: "story a" });
|
|
329
|
+
if (where.id === "t1") return Promise.resolve({ identifier: "B-1", name: "story b" });
|
|
330
|
+
return Promise.resolve(null);
|
|
331
|
+
});
|
|
204
332
|
mocks.depDelete.mockResolvedValue({ id: "d1" });
|
|
333
|
+
mocks.activityCreate.mockResolvedValue({ id: "act1", action: "dependency_removed", field: null, oldValue: null, newValue: "B-1", isAI: false, createdAt: new Date(), user: { id: "u1", username: "u1", email: "e", avatarConfig: null, bgShape: null, bgColor: null } });
|
|
205
334
|
const res = await removeDependencyFn({ data: { depId: "d1" } });
|
|
206
|
-
expect(res.id).toBe("d1");
|
|
335
|
+
expect(res?.dep.id).toBe("d1");
|
|
207
336
|
expect(mocks.depDelete).toHaveBeenCalledWith({ where: { id: "d1" } });
|
|
337
|
+
expect(mocks.activityCreate).toHaveBeenCalledTimes(2);
|
|
208
338
|
});
|
|
209
339
|
});
|
|
210
340
|
|