@allbluecn/web-app 0.5.1 → 0.6.1

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.
Files changed (125) hide show
  1. package/package.json +230 -30
  2. package/src/components/iteration/auto-schedule-dialog.tsx +150 -0
  3. package/src/components/iteration/burndown-chart.tsx +63 -0
  4. package/src/components/iteration/constants.ts +28 -0
  5. package/src/components/iteration/create-iteration-dialog.tsx +140 -0
  6. package/src/components/iteration/iteration-card.tsx +60 -0
  7. package/src/components/iteration/iteration-editor.tsx +116 -0
  8. package/src/components/iteration/iteration-progress-bar.tsx +44 -0
  9. package/src/components/iteration/iteration-status-badge.tsx +33 -0
  10. package/src/components/knowledge/dialog/knowledge-form-dialog.tsx +21 -30
  11. package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +2 -2
  12. package/src/components/layout/sidebar-03/nav-workspace.tsx +2 -2
  13. package/src/components/project/create-project-dialog.tsx +44 -57
  14. package/src/components/shared/__tests__/lucide-curated-icons.test.ts +83 -0
  15. package/src/components/shared/__tests__/lucide-icon-data.test.ts +131 -0
  16. package/src/components/shared/__tests__/lucide-icon-picker.test.tsx +197 -0
  17. package/src/components/shared/lucide-icon-picker/curated-icons.ts +478 -0
  18. package/src/components/shared/lucide-icon-picker/icon-data.ts +109 -0
  19. package/src/components/shared/lucide-icon-picker/index.tsx +82 -0
  20. package/src/components/shared/lucide-icon-picker/lucide-icon-by-name.tsx +78 -0
  21. package/src/components/shared/lucide-icon-picker/panel.tsx +163 -0
  22. package/src/components/story/__tests__/constants.test.ts +255 -0
  23. package/src/components/story/ai/story-ai-panel.tsx +23 -15
  24. package/src/components/story/ai/story-ai-preview-list.tsx +19 -0
  25. package/src/components/story/constants.ts +33 -1
  26. package/src/components/story/create-story-dialog.tsx +30 -10
  27. package/src/components/story/detail/__tests__/categories.test.tsx +62 -0
  28. package/src/components/story/detail/__tests__/dependencies-button.test.tsx +49 -0
  29. package/src/components/story/detail/activity-timeline.tsx +11 -4
  30. package/src/components/story/detail/story-detail-body.tsx +89 -38
  31. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +118 -9
  32. package/src/components/story/detail/story-detail-toolbar/children.tsx +7 -4
  33. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +67 -7
  34. package/src/components/story/detail/story-detail-toolbar.tsx +6 -1
  35. package/src/components/story/detail/story-properties.tsx +21 -4
  36. package/src/components/story/display/__tests__/group-fields.test.ts +107 -0
  37. package/src/components/story/display/group-resolver.tsx +2 -2
  38. package/src/components/story/inline-editors/__tests__/dependency-type-select.test.tsx +155 -0
  39. package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +155 -0
  40. package/src/components/story/inline-editors/__tests__/priority-editor.test.tsx +173 -0
  41. package/src/components/story/inline-editors/__tests__/state-editor.test.tsx +174 -0
  42. package/src/components/story/inline-editors/dependency-type-select.tsx +180 -0
  43. package/src/components/story/inline-editors/index.ts +2 -1
  44. package/src/components/story/inline-editors/labels-editor.tsx +159 -3
  45. package/src/components/story/inline-editors/story-select-dialog.tsx +22 -59
  46. package/src/components/story/peek-panel.tsx +121 -21
  47. package/src/components/story/relations/link-manage-dialog.tsx +2 -1
  48. package/src/components/story/relations/story-ref-row.tsx +17 -27
  49. package/src/components/story/story-panel-header.tsx +25 -19
  50. package/src/components/story/story-toolbar.tsx +3 -3
  51. package/src/components/story/transfer-story-popover.tsx +2 -2
  52. package/src/components/story/types.ts +1 -0
  53. package/src/components/story/views/create-view-dialog.tsx +3 -3
  54. package/src/components/story/views/story-list/__tests__/group-header.test.tsx +219 -0
  55. package/src/components/story/views/story-list/group.tsx +1 -1
  56. package/src/components/story/views/story-list/inline-story-creator.tsx +2 -2
  57. package/src/components/story/views/story-list/row.tsx +1 -1
  58. package/src/components/workspace/create-workspace-dialog.tsx +41 -49
  59. package/src/lib/__tests__/project-display-id.test.ts +8 -5
  60. package/src/lib/changelog/sdk-versions.ts +20 -20
  61. package/src/lib/iteration/__tests__/status.test.ts +41 -0
  62. package/src/lib/iteration/status.ts +28 -0
  63. package/src/lib/project-display-id.ts +14 -5
  64. package/src/lib/project-module-meta.ts +77 -0
  65. package/src/lib/utils.d.ts +1 -1
  66. package/src/providers/index.tsx +6 -0
  67. package/src/routeTree.gen.ts +54 -0
  68. package/src/routes/__root.tsx +0 -8
  69. package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
  70. package/src/routes/_nav/projects/$projectId/$storyId/route.lazy.tsx +52 -25
  71. package/src/routes/_nav/projects/$projectId/$storyId/route.tsx +6 -3
  72. package/src/routes/_nav/projects/$projectId/index.lazy.tsx +32 -15
  73. package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.lazy.tsx +289 -0
  74. package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.tsx +27 -0
  75. package/src/routes/_nav/projects/$projectId/iterations/index.lazy.tsx +111 -0
  76. package/src/routes/_nav/projects/$projectId/iterations/index.tsx +24 -0
  77. package/src/routes/_nav/projects/$projectId/route.tsx +4 -2
  78. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +4 -4
  79. package/src/routes/_nav/stories/$storyId/route.lazy.tsx +5 -1
  80. package/src/routes/_nav/stories/$storyId/route.tsx +4 -0
  81. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +3 -3
  82. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +4 -4
  83. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  84. package/src/server/serverFns/iteration/__tests__/auto-schedule-core.test.ts +62 -0
  85. package/src/server/serverFns/iteration/__tests__/burndown-core.test.ts +90 -0
  86. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +211 -0
  87. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +155 -0
  88. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +136 -0
  89. package/src/server/serverFns/iteration/auto-schedule-core.ts +54 -0
  90. package/src/server/serverFns/iteration/auto-schedule-internal.ts +88 -0
  91. package/src/server/serverFns/iteration/burndown-core.ts +60 -0
  92. package/src/server/serverFns/iteration/index.ts +22 -0
  93. package/src/server/serverFns/iteration/iteration-auto-schedule.ts +66 -0
  94. package/src/server/serverFns/iteration/iteration-burndown.ts +70 -0
  95. package/src/server/serverFns/iteration/iteration-crud.ts +208 -0
  96. package/src/server/serverFns/iteration/iteration-stories.ts +111 -0
  97. package/src/server/serverFns/iteration/iteration-transfer.ts +42 -0
  98. package/src/server/serverFns/iteration/schemas.ts +58 -0
  99. package/src/server/serverFns/iteration/transfer-internal.ts +62 -0
  100. package/src/server/serverFns/project/module-overview.ts +109 -0
  101. package/src/server/serverFns/project.ts +2 -2
  102. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +100 -0
  103. package/src/server/serverFns/story/__tests__/labels.test.ts +67 -0
  104. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +106 -2
  105. package/src/server/serverFns/story/index.ts +3 -0
  106. package/src/server/serverFns/story/story-assoc.ts +36 -4
  107. package/src/server/serverFns/story/story-attachments.ts +20 -2
  108. package/src/{components/project/project-icon.tsx → server/serverFns/story/story-crud/label-palette.ts} +16 -19
  109. package/src/server/serverFns/story/story-crud/label-suggest-core.ts +88 -0
  110. package/src/server/serverFns/story/story-crud/label-suggest.ts +38 -0
  111. package/src/{components/knowledge/knowledge-icon.tsx → server/serverFns/story/story-crud/label-upsert.ts} +13 -19
  112. package/src/server/serverFns/story/story-crud/labels.ts +3 -5
  113. package/src/server/serverFns/story/story-crud.ts +32 -2
  114. package/src/server/serverFns/story/story-dependencies.ts +123 -2
  115. package/src/server/serverFns/story/story-links.ts +30 -6
  116. package/src/server/serverFns/story/story-transfer.ts +5 -4
  117. package/src/server/serverFns/story/types.ts +1 -0
  118. package/src/types/project.ts +5 -5
  119. package/src/components/project/project-animated-icon.tsx +0 -73
  120. package/src/components/shared/icon-packs/index.ts +0 -1
  121. package/src/components/shared/icon-packs/knowledge.ts +0 -1
  122. package/src/components/shared/icon-packs/prd.ts +0 -1
  123. package/src/components/shared/icon-packs/project.ts +0 -1
  124. package/src/components/shared/icon-picker.tsx +0 -1
  125. package/src/lib/project-mock.ts +0 -156
@@ -0,0 +1,62 @@
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 { planMissingIterations } from "../auto-schedule-core";
20
+
21
+ const cfg = { title: "Sprint", durationDays: 14, cooldownDays: 0, startsOn: "2026-09-01", futureCount: 2, lastSeq: 0 };
22
+
23
+ describe("planMissingIterations(规格 §3.6)", () => {
24
+ it("无现有迭代 → 从 max(startsOn, today) 起补 2 个,命名带递增序号", () => {
25
+ const { planned, nextSeq } = planMissingIterations(cfg, [], "2026-09-01");
26
+ expect(planned).toHaveLength(2);
27
+ expect(planned[0]).toMatchObject({ name: "Sprint 1", startDate: "2026-09-01", endDate: "2026-09-14" });
28
+ expect(planned[1]).toMatchObject({ name: "Sprint 2", startDate: "2026-09-15", endDate: "2026-09-28" });
29
+ expect(nextSeq).toBe(2);
30
+ });
31
+ it("upcoming 已够数 → 不补", () => {
32
+ const { planned } = planMissingIterations(cfg, [
33
+ { startDate: "2026-09-15", endDate: "2026-09-28", archived: false },
34
+ { startDate: "2026-09-29", endDate: "2026-10-12", archived: false },
35
+ ], "2026-09-01");
36
+ expect(planned).toHaveLength(0);
37
+ });
38
+ it("anchor 接最晚迭代结束日,cooldown 生效", () => {
39
+ const { planned } = planMissingIterations(
40
+ { ...cfg, cooldownDays: 2, lastSeq: 3 },
41
+ [{ startDate: "2026-09-01", endDate: "2026-09-14", archived: false }],
42
+ "2026-09-01",
43
+ );
44
+ expect(planned[0]).toMatchObject({ name: "Sprint 4", startDate: "2026-09-16" });
45
+ });
46
+ it("与现有迭代重叠时顺延至其后", () => {
47
+ const { planned } = planMissingIterations(cfg, [
48
+ { startDate: "2026-08-19", endDate: "2026-09-05", archived: false },
49
+ ], "2026-09-01");
50
+ expect(planned[0].startDate).toBe("2026-09-06");
51
+ });
52
+ it("归档迭代不占用 anchor/不阻断", () => {
53
+ const { planned } = planMissingIterations(cfg, [
54
+ { startDate: "2026-09-10", endDate: "2026-09-23", archived: true },
55
+ ], "2026-09-01");
56
+ expect(planned[0].startDate).toBe("2026-09-01");
57
+ });
58
+ it("startsOn 在过去时从今天起", () => {
59
+ const { planned } = planMissingIterations({ ...cfg, startsOn: "2026-08-01" }, [], "2026-09-01");
60
+ expect(planned[0].startDate).toBe("2026-09-01");
61
+ });
62
+ });
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { computeBurndown } from "../burndown-core";
3
+
4
+ // 输入构造器:迭代 09-01~09-05(5 天),时区 Asia/Shanghai
5
+ const base = {
6
+ startDate: "2026-09-01",
7
+ endDate: "2026-09-05",
8
+ timezone: "Asia/Shanghai",
9
+ };
10
+
11
+ describe("computeBurndown(规格 §3.4)", () => {
12
+ it("无需求无事件 → 各日 scope=0 pending=0", () => {
13
+ const r = computeBurndown({
14
+ ...base,
15
+ stories: [],
16
+ stateEvents: [],
17
+ now: new Date("2026-09-03T04:00:00Z"),
18
+ });
19
+ expect(r.points).toHaveLength(3); // 只画到今天
20
+ expect(r.points[0]).toMatchObject({ date: "2026-09-01", scope: 0, pending: 0 });
21
+ });
22
+
23
+ it("三个需求,第二天完成一个 → day1 pending=3, day2 pending=2", () => {
24
+ const r = computeBurndown({
25
+ ...base,
26
+ stories: [
27
+ { id: "s1", addedAt: "2026-09-01T02:00:00Z" },
28
+ { id: "s2", addedAt: "2026-09-01T02:00:00Z" },
29
+ { id: "s3", addedAt: "2026-09-01T02:00:00Z" },
30
+ ],
31
+ stateEvents: [{ storyId: "s1", at: "2026-09-02T06:00:00Z", to: "done" }],
32
+ now: new Date("2026-09-05T20:00:00Z"),
33
+ });
34
+ expect(r.points[0]).toMatchObject({ scope: 3, pending: 3 });
35
+ expect(r.points[1]).toMatchObject({ scope: 3, pending: 2 });
36
+ });
37
+
38
+ it("中途加入(day3)抬高 scope", () => {
39
+ const r = computeBurndown({
40
+ ...base,
41
+ stories: [
42
+ { id: "s1", addedAt: "2026-09-01T02:00:00Z" },
43
+ { id: "s2", addedAt: "2026-09-03T02:00:00Z" },
44
+ ],
45
+ stateEvents: [],
46
+ now: new Date("2026-09-05T20:00:00Z"),
47
+ });
48
+ expect(r.points[1]).toMatchObject({ scope: 1 });
49
+ expect(r.points[2]).toMatchObject({ scope: 2 });
50
+ });
51
+
52
+ it("状态反复变更取最后态(done→in_progress→done)", () => {
53
+ const r = computeBurndown({
54
+ ...base,
55
+ stories: [{ id: "s1", addedAt: "2026-09-01T02:00:00Z" }],
56
+ stateEvents: [
57
+ { storyId: "s1", at: "2026-09-02T02:00:00Z", to: "done" },
58
+ { storyId: "s1", at: "2026-09-03T02:00:00Z", to: "in_progress" },
59
+ { storyId: "s1", at: "2026-09-04T02:00:00Z", to: "done" },
60
+ ],
61
+ now: new Date("2026-09-05T20:00:00Z"),
62
+ });
63
+ expect(r.points[1].pending).toBe(0); // day2 done
64
+ expect(r.points[2].pending).toBe(1); // day3 打回
65
+ expect(r.points[3].pending).toBe(0); // day4 再 done
66
+ });
67
+
68
+ it("cancelled 计入退出(done 口径)", () => {
69
+ const r = computeBurndown({
70
+ ...base,
71
+ stories: [
72
+ { id: "s1", addedAt: "2026-09-01T02:00:00Z" },
73
+ { id: "s2", addedAt: "2026-09-01T02:00:00Z" },
74
+ ],
75
+ stateEvents: [{ storyId: "s2", at: "2026-09-02T02:00:00Z", to: "cancelled" }],
76
+ now: new Date("2026-09-05T20:00:00Z"),
77
+ });
78
+ expect(r.points[1].pending).toBe(1);
79
+ });
80
+
81
+ it("领先/滞后判定:today pending < ideal → leading", () => {
82
+ const r = computeBurndown({
83
+ ...base,
84
+ stories: Array.from({ length: 4 }, (_, i) => ({ id: `s${i}`, addedAt: "2026-09-01T02:00:00Z" })),
85
+ stateEvents: Array.from({ length: 3 }, (_, i) => ({ storyId: `s${i}`, at: "2026-09-01T06:00:00Z", to: "done" })),
86
+ now: new Date("2026-09-03T02:00:00Z"), // 上海时区 09-03 10:00(20:00Z 会被分桶到 09-04)
87
+ });
88
+ expect(r.status).toBe("leading"); // day3: pending=1 < ideal=round(4*3/5)=2
89
+ });
90
+ });
@@ -0,0 +1,211 @@
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
+ findMany: vi.fn(),
49
+ findUnique: vi.fn(),
50
+ create: vi.fn(),
51
+ update: vi.fn(),
52
+ delete: vi.fn(),
53
+ count: vi.fn(),
54
+ groupBy: vi.fn(),
55
+ currentSession: vi.fn(),
56
+ }));
57
+
58
+ vi.mock("@allbluecn/database", () => ({
59
+ prisma: {
60
+ iteration: {
61
+ findMany: mocks.findMany,
62
+ findUnique: mocks.findUnique,
63
+ create: mocks.create,
64
+ update: mocks.update,
65
+ delete: mocks.delete,
66
+ },
67
+ story: { groupBy: mocks.groupBy },
68
+ iterationAutoSchedule: { findMany: mocks.findMany },
69
+ },
70
+ Prisma: {},
71
+ }));
72
+
73
+ vi.mock("@/server/auth-helpers", () => ({
74
+ requireAuth: mocks.currentSession,
75
+ }));
76
+
77
+ import {
78
+ listIterationsFn,
79
+ getIterationFn,
80
+ createIterationsFn,
81
+ updateIterationsFn,
82
+ archiveIterationsFn,
83
+ deleteIterationsFn,
84
+ } from "../iteration-crud";
85
+
86
+ describe("createIterationsFn", () => {
87
+ beforeEach(() => {
88
+ vi.clearAllMocks();
89
+ mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
90
+ });
91
+
92
+ it("日期与现有迭代重叠时拒绝", async () => {
93
+ mocks.findMany.mockResolvedValue([
94
+ { startDate: new Date("2026-09-01"), endDate: new Date("2026-09-14"), archived: false },
95
+ ]);
96
+ await expect(
97
+ createIterationsFn({ data: { projectId: "p1", name: "S2", startDate: "2026-09-10", endDate: "2026-09-23" } }),
98
+ ).rejects.toMatchObject({ message: expect.stringContaining("重叠") });
99
+ });
100
+
101
+ it("合法日期调用 create 且日期按 UTC 0 点入库", async () => {
102
+ mocks.findMany.mockResolvedValue([]);
103
+ mocks.create.mockResolvedValue({ id: "i1" });
104
+ const result = await createIterationsFn({
105
+ data: { projectId: "p1", name: "S1", startDate: "2026-09-01", endDate: "2026-09-14" },
106
+ });
107
+ expect(result).toEqual({ id: "i1" });
108
+ expect(mocks.create).toHaveBeenCalledWith(
109
+ expect.objectContaining({
110
+ data: expect.objectContaining({
111
+ startDate: new Date("2026-09-01T00:00:00.000Z"),
112
+ endDate: new Date("2026-09-14T00:00:00.000Z"),
113
+ }),
114
+ }),
115
+ );
116
+ });
117
+ });
118
+
119
+ describe("updateIterationsFn", () => {
120
+ beforeEach(() => {
121
+ vi.clearAllMocks();
122
+ mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
123
+ });
124
+
125
+ it("completed 迭代锁定编辑(startDate 已过)", async () => {
126
+ mocks.findUnique.mockResolvedValue({
127
+ id: "i1",
128
+ startDate: new Date("2026-08-01"),
129
+ endDate: new Date("2026-08-14"),
130
+ archived: false,
131
+ projectId: "p1",
132
+ });
133
+ await expect(updateIterationsFn({ data: { id: "i1", name: "改名" } })).rejects.toMatchObject({
134
+ message: expect.stringContaining("不可编辑"),
135
+ });
136
+ });
137
+
138
+ it("改期时校验与其他迭代不重叠", async () => {
139
+ mocks.findUnique.mockResolvedValue({
140
+ id: "i1",
141
+ startDate: new Date("2026-09-01"),
142
+ endDate: new Date("2026-09-14"),
143
+ archived: false,
144
+ projectId: "p1",
145
+ });
146
+ mocks.findMany.mockResolvedValue([
147
+ { startDate: new Date("2026-09-10"), endDate: new Date("2026-09-20") },
148
+ ]);
149
+ await expect(
150
+ updateIterationsFn({ data: { id: "i1", startDate: "2026-09-18", endDate: "2026-09-25" } }),
151
+ ).rejects.toMatchObject({ message: expect.stringContaining("重叠") });
152
+ });
153
+ });
154
+
155
+ describe("listIterationsFn", () => {
156
+ beforeEach(() => {
157
+ vi.clearAllMocks();
158
+ mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
159
+ });
160
+
161
+ it("返回带统计与 status 字段", async () => {
162
+ mocks.findMany.mockResolvedValue([
163
+ { id: "i1", name: "Sprint 1", description: null, startDate: new Date("2026-09-01"), endDate: new Date("2026-09-14"), archived: false },
164
+ { id: "i2", name: "Sprint 0", description: null, startDate: new Date("2026-08-19"), endDate: new Date("2026-09-01"), archived: false },
165
+ ]);
166
+ mocks.groupBy.mockResolvedValue([
167
+ { iterationId: "i1", state: "done", _count: { _all: 3 } },
168
+ { iterationId: "i1", state: "in_progress", _count: { _all: 1 } },
169
+ { iterationId: "i2", state: "done", _count: { _all: 5 } },
170
+ ]);
171
+ const result = await listIterationsFn({ data: { projectId: "p1", includeArchived: false } });
172
+ expect(result.items).toHaveLength(2);
173
+ expect(result.items[0].id).toBe("i1");
174
+ expect(result.items[0].stateCounts).toEqual({ done: 3, in_progress: 1 });
175
+ expect(result.items[0].storyCount).toBe(4);
176
+ expect(result.items[0].startDate).toBe("2026-09-01");
177
+ expect(result.items[0].endDate).toBe("2026-09-14");
178
+ expect(result.items[1].storyCount).toBe(5);
179
+ });
180
+ });
181
+
182
+ describe("archiveIterationsFn", () => {
183
+ beforeEach(() => {
184
+ vi.clearAllMocks();
185
+ mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
186
+ });
187
+
188
+ it("归档/取消归档", async () => {
189
+ mocks.update.mockResolvedValue({ id: "i1" });
190
+ const r1 = await archiveIterationsFn({ data: { id: "i1", archived: true } });
191
+ expect(r1.ok).toBe(true);
192
+ expect(mocks.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ archived: true }) }));
193
+ const r2 = await archiveIterationsFn({ data: { id: "i1", archived: false } });
194
+ expect(r2.ok).toBe(true);
195
+ expect(mocks.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ archived: false }) }));
196
+ });
197
+ });
198
+
199
+ describe("deleteIterationsFn", () => {
200
+ beforeEach(() => {
201
+ vi.clearAllMocks();
202
+ mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
203
+ });
204
+
205
+ it("删除迭代", async () => {
206
+ mocks.delete.mockResolvedValue({ id: "i1" });
207
+ const r = await deleteIterationsFn({ data: { id: "i1" } });
208
+ expect(r.ok).toBe(true);
209
+ expect(mocks.delete).toHaveBeenCalledWith({ where: { id: "i1" } });
210
+ });
211
+ });
@@ -0,0 +1,155 @@
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
+ findUnique: vi.fn(),
49
+ update: vi.fn(),
50
+ updateMany: vi.fn(),
51
+ create: vi.fn(),
52
+ createMany: vi.fn(),
53
+ currentSession: vi.fn(),
54
+ }));
55
+
56
+ vi.mock("@allbluecn/database", () => ({
57
+ prisma: {
58
+ iteration: { findUnique: mocks.findUnique },
59
+ story: { findUnique: mocks.findUnique, update: mocks.update, updateMany: mocks.updateMany },
60
+ storyActivity: { create: mocks.create, createMany: mocks.createMany },
61
+ },
62
+ Prisma: {},
63
+ }));
64
+
65
+ vi.mock("@/server/auth-helpers", () => ({
66
+ requireAuth: mocks.currentSession,
67
+ }));
68
+
69
+ import { setStoryIterationFn, addStoriesToIterationFn, removeStoryFromIterationFn } from "../iteration-stories";
70
+
71
+ describe("setStoryIterationFn", () => {
72
+ beforeEach(() => {
73
+ vi.clearAllMocks();
74
+ mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
75
+ });
76
+
77
+ it("目标是 completed 迭代时拒绝", async () => {
78
+ mocks.findUnique.mockResolvedValue({
79
+ startDate: new Date("2026-08-01"),
80
+ endDate: new Date("2026-08-14"),
81
+ archived: false,
82
+ name: "已完成",
83
+ });
84
+ await expect(
85
+ setStoryIterationFn({ data: { storyId: "s1", iterationId: "i1" } }),
86
+ ).rejects.toMatchObject({ message: expect.stringContaining("已完成") });
87
+ });
88
+
89
+ it("合法目标更新 story 并记录活动", async () => {
90
+ mocks.findUnique.mockImplementation(async (args: any) => {
91
+ if (args.where?.id === "i1") {
92
+ return { startDate: new Date("2026-09-01"), endDate: new Date("2026-09-14"), archived: false, name: "Sprint 1" };
93
+ }
94
+ if (args.where?.id === "s1") {
95
+ return { iterationId: null, iteration: null };
96
+ }
97
+ return null;
98
+ });
99
+ mocks.update.mockResolvedValue({ id: "s1" });
100
+ mocks.create.mockResolvedValue({ id: "a1" });
101
+ const r = await setStoryIterationFn({ data: { storyId: "s1", iterationId: "i1" } });
102
+ expect(r.ok).toBe(true);
103
+ expect(mocks.update).toHaveBeenCalledWith(expect.objectContaining({ data: { iterationId: "i1" } }));
104
+ const activity = mocks.create.mock.calls[0][0].data;
105
+ expect(activity.action).toBe("iteration_changed");
106
+ expect(activity.field).toBe("iterationId");
107
+ expect(activity.newValue).toBe("Sprint 1");
108
+ });
109
+ });
110
+
111
+ describe("addStoriesToIterationFn", () => {
112
+ beforeEach(() => {
113
+ vi.clearAllMocks();
114
+ mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
115
+ });
116
+
117
+ it("批量更新并记录活动", async () => {
118
+ mocks.findUnique.mockResolvedValue({
119
+ startDate: new Date("2026-09-01"), endDate: new Date("2026-09-14"), archived: false, name: "Sprint 1",
120
+ });
121
+ mocks.updateMany.mockResolvedValue({ count: 2 });
122
+ mocks.createMany.mockResolvedValue(undefined);
123
+ const r = await addStoriesToIterationFn({ data: { iterationId: "i1", storyIds: ["s1", "s2"] } });
124
+ expect(r.count).toBe(2);
125
+ expect(mocks.createMany).toHaveBeenCalledWith(expect.objectContaining({
126
+ data: expect.arrayContaining([
127
+ expect.objectContaining({ storyId: "s1", action: "iteration_changed" }),
128
+ expect.objectContaining({ storyId: "s2", action: "iteration_changed" }),
129
+ ]),
130
+ }));
131
+ });
132
+ });
133
+
134
+ describe("removeStoryFromIterationFn", () => {
135
+ beforeEach(() => {
136
+ vi.clearAllMocks();
137
+ mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
138
+ });
139
+
140
+ it("移出迭代并记录活动", async () => {
141
+ mocks.findUnique.mockResolvedValue({
142
+ iterationId: "i1",
143
+ iteration: { name: "Sprint 1" },
144
+ });
145
+ mocks.updateMany.mockResolvedValue({ count: 1 });
146
+ mocks.create.mockResolvedValue({ id: "a1" });
147
+ const r = await removeStoryFromIterationFn({ data: { storyId: "s1" } });
148
+ expect(r.ok).toBe(true);
149
+ const activity = mocks.create.mock.calls[0][0].data;
150
+ expect(activity.action).toBe("iteration_changed");
151
+ expect(activity.field).toBe("iterationId");
152
+ expect(activity.oldValue).toBe("Sprint 1");
153
+ expect(activity.newValue).toBeNull();
154
+ });
155
+ });
@@ -0,0 +1,136 @@
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
+ findMany: vi.fn(),
49
+ findUnique: vi.fn(),
50
+ updateMany: vi.fn(),
51
+ createMany: vi.fn(),
52
+ projectFindUnique: vi.fn(),
53
+ currentSession: vi.fn(),
54
+ }));
55
+
56
+ vi.mock("@allbluecn/database", () => ({
57
+ prisma: {
58
+ iteration: { findUnique: mocks.findUnique },
59
+ story: { findMany: mocks.findMany, updateMany: mocks.updateMany },
60
+ storyActivity: { createMany: mocks.createMany },
61
+ project: { findUnique: mocks.projectFindUnique },
62
+ },
63
+ Prisma: {},
64
+ }));
65
+
66
+ vi.mock("@/server/auth-helpers", () => ({
67
+ requireAuth: mocks.currentSession,
68
+ }));
69
+
70
+ import { transferUnfinishedFn } from "../iteration-transfer";
71
+
72
+ describe("transferUnfinishedFn", () => {
73
+ beforeEach(() => {
74
+ vi.clearAllMocks();
75
+ mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
76
+ });
77
+
78
+ it("目标是 completed 迭代时拒绝", async () => {
79
+ mocks.findUnique.mockResolvedValue({
80
+ startDate: new Date("2026-08-01"),
81
+ endDate: new Date("2026-08-14"),
82
+ archived: false,
83
+ name: "已完成迭代",
84
+ projectId: "p1",
85
+ });
86
+ await expect(
87
+ transferUnfinishedFn({ data: { iterationId: "src1", targetIterationId: "tgt1" } }),
88
+ ).rejects.toMatchObject({ message: expect.stringContaining("已完成") });
89
+ });
90
+
91
+ it("where 断言 state notIn 且 iterationId = 源迭代", async () => {
92
+ mocks.findUnique.mockResolvedValue({
93
+ startDate: new Date("2026-09-01"),
94
+ endDate: new Date("2026-09-14"),
95
+ archived: false,
96
+ name: "目标迭代",
97
+ projectId: "p1",
98
+ });
99
+ mocks.findMany.mockResolvedValue([{ id: "s1" }, { id: "s2" }]);
100
+ mocks.updateMany.mockResolvedValue({ count: 2 });
101
+ mocks.createMany.mockResolvedValue(undefined);
102
+ mocks.projectFindUnique.mockResolvedValue({ ownerId: "u1" });
103
+ const result = await transferUnfinishedFn({ data: { iterationId: "src1", targetIterationId: "tgt1" } });
104
+ expect(result).toEqual({ count: 2 });
105
+ const whereArg = mocks.findMany.mock.calls[0][0].where;
106
+ expect(whereArg.iterationId).toBe("src1");
107
+ expect(whereArg.state.notIn).toContain("done");
108
+ expect(whereArg.state.notIn).toContain("cancelled");
109
+ });
110
+
111
+ it("归档目标拒绝", async () => {
112
+ mocks.findUnique.mockResolvedValue({
113
+ startDate: new Date("2026-09-01"),
114
+ endDate: new Date("2026-09-14"),
115
+ archived: true,
116
+ name: "归档迭代",
117
+ projectId: "p1",
118
+ });
119
+ await expect(
120
+ transferUnfinishedFn({ data: { iterationId: "src1", targetIterationId: "tgt1" } }),
121
+ ).rejects.toMatchObject({ message: expect.stringContaining("迭代不存在") });
122
+ });
123
+
124
+ it("二次调用(无未完成)返回 { count: 0 },不抛错", async () => {
125
+ mocks.findUnique.mockResolvedValue({
126
+ startDate: new Date("2026-09-01"),
127
+ endDate: new Date("2026-09-14"),
128
+ archived: false,
129
+ name: "目标迭代",
130
+ projectId: "p1",
131
+ });
132
+ mocks.findMany.mockResolvedValue([]);
133
+ const result = await transferUnfinishedFn({ data: { iterationId: "src1", targetIterationId: "tgt1" } });
134
+ expect(result).toEqual({ count: 0 });
135
+ });
136
+ });