@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.
- package/package.json +230 -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/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 +33 -1
- package/src/components/story/create-story-dialog.tsx +30 -10
- 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 +11 -4
- package/src/components/story/detail/story-detail-body.tsx +89 -38
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +118 -9
- package/src/components/story/detail/story-detail-toolbar/children.tsx +7 -4
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +67 -7
- package/src/components/story/detail/story-detail-toolbar.tsx +6 -1
- 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 +22 -59
- package/src/components/story/peek-panel.tsx +121 -21
- package/src/components/story/relations/link-manage-dialog.tsx +2 -1
- 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 +1 -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/utils.d.ts +1 -1
- package/src/providers/index.tsx +6 -0
- package/src/routeTree.gen.ts +54 -0
- package/src/routes/__root.tsx +0 -8
- package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
- 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 +4 -4
- 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/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/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/project/module-overview.ts +109 -0
- package/src/server/serverFns/project.ts +2 -2
- 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-dependencies.test.ts +106 -2
- package/src/server/serverFns/story/index.ts +3 -0
- package/src/server/serverFns/story/story-assoc.ts +36 -4
- package/src/server/serverFns/story/story-attachments.ts +20 -2
- 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 +32 -2
- package/src/server/serverFns/story/story-dependencies.ts +123 -2
- package/src/server/serverFns/story/story-links.ts +30 -6
- package/src/server/serverFns/story/story-transfer.ts +5 -4
- package/src/server/serverFns/story/types.ts +1 -0
- package/src/types/project.ts +5 -5
- 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/lib/project-mock.ts +0 -156
|
@@ -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
|
+
const MockResizeObserver = class ResizeObserver {
|
|
19
|
+
observe() {}
|
|
20
|
+
unobserve() {}
|
|
21
|
+
disconnect() {}
|
|
22
|
+
};
|
|
23
|
+
if (typeof globalThis.ResizeObserver === "undefined") {
|
|
24
|
+
(globalThis as any).ResizeObserver = MockResizeObserver;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
28
|
+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
29
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
30
|
+
import { DependencyTypeSelect } from "../dependency-type-select";
|
|
31
|
+
|
|
32
|
+
vi.mock("react-i18next", () => ({
|
|
33
|
+
useTranslation: () => ({
|
|
34
|
+
t: (key: string, vars?: Record<string, string>) =>
|
|
35
|
+
vars ? `${key}(${Object.values(vars).join(",")})` : key,
|
|
36
|
+
}),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
vi.mock("@/components/ui/tooltip", () => ({
|
|
40
|
+
TooltipProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
41
|
+
Tooltip: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
42
|
+
TooltipContent: ({ children, ...props }: { children: React.ReactNode; side?: string; className?: string }) => (
|
|
43
|
+
<div data-tooltip="content" {...props}>
|
|
44
|
+
{children}
|
|
45
|
+
</div>
|
|
46
|
+
),
|
|
47
|
+
TooltipTrigger: ({ asChild, children }: { asChild?: boolean; children: React.ReactNode }) =>
|
|
48
|
+
asChild ? children : <div>{children}</div>,
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
vi.mock("@/lib/utils", () => ({
|
|
52
|
+
cn: (...args: string[]) => args.filter(Boolean).join(" "),
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
function renderSelect(props: Partial<React.ComponentProps<typeof DependencyTypeSelect>>) {
|
|
56
|
+
return render(
|
|
57
|
+
<TooltipProvider>
|
|
58
|
+
<DependencyTypeSelect onChange={vi.fn()} {...props} />
|
|
59
|
+
</TooltipProvider>,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
vi.clearAllMocks();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("DependencyTypeSelect", () => {
|
|
68
|
+
describe("trigger variant", () => {
|
|
69
|
+
it("无 value 时显示 Waypoints 图标占位 + placeholder 文案", () => {
|
|
70
|
+
renderSelect({ variant: "trigger" });
|
|
71
|
+
expect(screen.getByRole("button", { name: "dependency.typePlaceholder" })).toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("有 value 时显示对应 option 的 label", () => {
|
|
75
|
+
renderSelect({ variant: "trigger", value: "blocking" });
|
|
76
|
+
expect(screen.getByRole("button", { name: "dependency.options.blocking" })).toBeInTheDocument();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("受控 open:外部控制 Popover 开合", () => {
|
|
80
|
+
const onOpenChange = vi.fn();
|
|
81
|
+
renderSelect({ variant: "trigger", open: true, onOpenChange });
|
|
82
|
+
expect(screen.getByText("dependency.options.blocked_by")).toBeInTheDocument();
|
|
83
|
+
|
|
84
|
+
fireEvent.click(screen.getByText("dependency.options.blocking"));
|
|
85
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("非受控 open:点击 trigger 打开 Popover", async () => {
|
|
89
|
+
const onChange = vi.fn();
|
|
90
|
+
renderSelect({ variant: "trigger", onChange });
|
|
91
|
+
expect(screen.queryByText("dependency.options.blocked_by")).not.toBeInTheDocument();
|
|
92
|
+
fireEvent.click(screen.getByRole("button", { name: "dependency.typePlaceholder" }));
|
|
93
|
+
await waitFor(() => {
|
|
94
|
+
expect(screen.getByText("dependency.options.blocked_by")).toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("badge variant", () => {
|
|
100
|
+
it("有 value 时显示 badgeLabel + identifier 插值", () => {
|
|
101
|
+
renderSelect({ variant: "badge", value: "blocked_by", identifier: "AB-123" });
|
|
102
|
+
expect(screen.getByText("dependency.labels.blocked_by(AB-123)")).toBeInTheDocument();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("有 currentIdentifier 时渲染含 tooltipLabel 的 TooltipContent", () => {
|
|
106
|
+
renderSelect({
|
|
107
|
+
variant: "badge",
|
|
108
|
+
value: "blocking",
|
|
109
|
+
identifier: "AB-456",
|
|
110
|
+
currentIdentifier: "AB-789",
|
|
111
|
+
});
|
|
112
|
+
expect(screen.getByText("dependency.tooltips.blocking(AB-456,AB-789)")).toBeInTheDocument();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("无 currentIdentifier 时不渲染 TooltipContent", () => {
|
|
116
|
+
renderSelect({ variant: "badge", value: "finishes_before", identifier: "AB-001" });
|
|
117
|
+
expect(screen.getByText("dependency.labels.finishes_before(AB-001)")).toBeInTheDocument();
|
|
118
|
+
expect(screen.queryByText("dependency.tooltips.finishes_before")).not.toBeInTheDocument();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("无 value 时显示 placeholder", () => {
|
|
122
|
+
renderSelect({ variant: "badge" });
|
|
123
|
+
expect(screen.getByText("dependency.typePlaceholder")).toBeInTheDocument();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("选项点击", () => {
|
|
128
|
+
it("点击 PopoverContent 内选项触发 onChange 并关闭 Popover", () => {
|
|
129
|
+
const onChange = vi.fn();
|
|
130
|
+
const onOpenChange = vi.fn();
|
|
131
|
+
renderSelect({ variant: "trigger", value: "blocked_by", onChange, onOpenChange, open: true });
|
|
132
|
+
const contentBtns = [...document.querySelectorAll("[role=dialog] button")];
|
|
133
|
+
expect(contentBtns.length).toBeGreaterThan(0);
|
|
134
|
+
fireEvent.click(contentBtns.find((b) => b.textContent?.includes("dependency.options.blocking"))!);
|
|
135
|
+
expect(onChange).toHaveBeenCalledWith("blocking");
|
|
136
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("已选中项显示 font-medium 标记", () => {
|
|
140
|
+
renderSelect({ variant: "trigger", value: "starts_after", open: true });
|
|
141
|
+
const checkedBtn = [...document.querySelectorAll("[role=dialog] button")].find(
|
|
142
|
+
(b) => b.textContent?.includes("dependency.options.starts_after") && b.className.includes("font-medium"),
|
|
143
|
+
);
|
|
144
|
+
expect(checkedBtn).toBeTruthy();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("分组", () => {
|
|
149
|
+
it("Popover 内含两个 optionGroups 标题", () => {
|
|
150
|
+
renderSelect({ variant: "trigger", open: true });
|
|
151
|
+
expect(screen.getByText("dependency.optionGroups.dependsOn")).toBeInTheDocument();
|
|
152
|
+
expect(screen.getByText("dependency.optionGroups.dependents")).toBeInTheDocument();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -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
|
+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
20
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
21
|
+
import { LabelsEditor } from "../labels-editor";
|
|
22
|
+
|
|
23
|
+
vi.mock("react-i18next", () => ({
|
|
24
|
+
useTranslation: () => ({ t: (key: string) => key }),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock("@/lib/utils", () => ({
|
|
28
|
+
cn: (...args: string[]) => args.filter(Boolean).join(" "),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock("@/lib/auth/auth-client", () => ({
|
|
32
|
+
useSession: () => ({ data: { user: { hasAiKey: true } } }),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock("sonner", () => ({
|
|
36
|
+
toast: { error: vi.fn(), success: vi.fn() },
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
const mocks = vi.hoisted(() => ({
|
|
40
|
+
suggestStoryLabelsFn: vi.fn(),
|
|
41
|
+
createLabelFn: vi.fn(),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
vi.mock("@/server/serverFns/story", () => ({
|
|
45
|
+
suggestStoryLabelsFn: mocks.suggestStoryLabelsFn,
|
|
46
|
+
createLabelFn: mocks.createLabelFn,
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
const LABELS = [
|
|
50
|
+
{ id: "l1", name: "性能", color: "#ef4444" },
|
|
51
|
+
{ id: "l2", name: "安全", color: "#3b82f6" },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const DEFAULT_PROPS: React.ComponentProps<typeof LabelsEditor> = {
|
|
55
|
+
labelIds: [],
|
|
56
|
+
labels: LABELS,
|
|
57
|
+
onChange: vi.fn(),
|
|
58
|
+
variant: "button",
|
|
59
|
+
aiContext: { name: () => "优化登录速度" },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function renderEditor(overrides: Partial<React.ComponentProps<typeof LabelsEditor>> = {}) {
|
|
63
|
+
return render(
|
|
64
|
+
<TooltipProvider>
|
|
65
|
+
<LabelsEditor {...DEFAULT_PROPS} onChange={overrides.onChange ?? vi.fn()} {...overrides} />
|
|
66
|
+
</TooltipProvider>,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function openPopover(): HTMLElement {
|
|
71
|
+
const trigger = document.querySelector("button[data-slot=tooltip-trigger]")!;
|
|
72
|
+
fireEvent.click(trigger);
|
|
73
|
+
return document.querySelector("[data-slot=popover-content]")! as HTMLElement;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function withText(root: HTMLElement, text: string): HTMLElement {
|
|
77
|
+
const buttons = root.querySelectorAll("button");
|
|
78
|
+
for (const b of buttons) {
|
|
79
|
+
if (b.textContent?.includes(text)) {
|
|
80
|
+
return b as HTMLElement;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
throw new Error(`button with text ${text} not found`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
vi.clearAllMocks();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("LabelsEditor AI 入口", () => {
|
|
91
|
+
it("无 aiContext 时不渲染 AI 按钮", () => {
|
|
92
|
+
renderEditor({ aiContext: undefined });
|
|
93
|
+
const dialog = openPopover();
|
|
94
|
+
expect(dialog.textContent).not.toContain("aiGenerate");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("有 aiContext 时 popover 内出现 AI 生成按钮", () => {
|
|
98
|
+
renderEditor();
|
|
99
|
+
const dialog = openPopover();
|
|
100
|
+
expect(dialog.textContent).toContain("aiGenerate");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("生成后展示候选(已有 + 新),默认全选,应用时新标签懒创建并合并 onChange", async () => {
|
|
104
|
+
mocks.suggestStoryLabelsFn.mockResolvedValue({
|
|
105
|
+
existingLabels: [{ id: "l1", name: "性能", color: "#ef4444" }],
|
|
106
|
+
newNames: ["登录"],
|
|
107
|
+
});
|
|
108
|
+
mocks.createLabelFn.mockResolvedValue({ id: "l-new", name: "登录", color: "#10b981" });
|
|
109
|
+
const onChange = vi.fn();
|
|
110
|
+
renderEditor({ onChange });
|
|
111
|
+
|
|
112
|
+
const dialog = openPopover();
|
|
113
|
+
fireEvent.click(withText(dialog, "aiGenerate"));
|
|
114
|
+
|
|
115
|
+
await waitFor(() => {
|
|
116
|
+
expect(mocks.suggestStoryLabelsFn).toHaveBeenCalledTimes(1);
|
|
117
|
+
});
|
|
118
|
+
const call = mocks.suggestStoryLabelsFn.mock.calls[0][0] as any;
|
|
119
|
+
expect(call.data.name).toBe("优化登录速度");
|
|
120
|
+
|
|
121
|
+
await waitFor(() => {
|
|
122
|
+
expect(screen.getByText("登录")).toBeTruthy();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
fireEvent.click(withText(document.querySelector("[data-slot=popover-content]")!, "aiApply"));
|
|
126
|
+
await waitFor(() => {
|
|
127
|
+
expect(mocks.createLabelFn).toHaveBeenCalledWith({ data: { name: "登录" } });
|
|
128
|
+
expect(onChange).toHaveBeenCalledWith(["l1", "l-new"]);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("生成失败展示错误态且可重试", async () => {
|
|
133
|
+
mocks.suggestStoryLabelsFn.mockRejectedValueOnce(new Error("AI 标签输出解析失败"));
|
|
134
|
+
renderEditor();
|
|
135
|
+
|
|
136
|
+
const dialog = openPopover();
|
|
137
|
+
fireEvent.click(withText(dialog, "aiGenerate"));
|
|
138
|
+
await waitFor(() => {
|
|
139
|
+
expect(document.querySelector("[data-slot=popover-content]")!.textContent).toContain("aiFailed");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
mocks.suggestStoryLabelsFn.mockResolvedValue({ existingLabels: [], newNames: [] });
|
|
143
|
+
fireEvent.click(withText(document.querySelector("[data-slot=popover-content]")!, "aiRegenerate"));
|
|
144
|
+
await waitFor(() => {
|
|
145
|
+
expect(mocks.suggestStoryLabelsFn).toHaveBeenCalledTimes(2);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("name 为空时不发请求", () => {
|
|
150
|
+
renderEditor({ aiContext: { name: () => " " } });
|
|
151
|
+
const dialog = openPopover();
|
|
152
|
+
fireEvent.click(withText(dialog, "aiGenerate"));
|
|
153
|
+
expect(mocks.suggestStoryLabelsFn).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
const MockResizeObserver = class ResizeObserver {
|
|
19
|
+
observe() {}
|
|
20
|
+
unobserve() {}
|
|
21
|
+
disconnect() {}
|
|
22
|
+
};
|
|
23
|
+
if (typeof globalThis.ResizeObserver === "undefined") {
|
|
24
|
+
(globalThis as any).ResizeObserver = MockResizeObserver;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
28
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
29
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
30
|
+
import { PriorityEditor } from "../priority-editor";
|
|
31
|
+
|
|
32
|
+
vi.mock("react-i18next", () => ({
|
|
33
|
+
useTranslation: () => ({
|
|
34
|
+
t: (key: string) => key,
|
|
35
|
+
}),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock("@/lib/utils", () => ({
|
|
39
|
+
cn: (...args: string[]) => args.filter(Boolean).join(" "),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
function renderEditor(props: Partial<React.ComponentProps<typeof PriorityEditor>>) {
|
|
43
|
+
return render(
|
|
44
|
+
<TooltipProvider>
|
|
45
|
+
<PriorityEditor value="medium" onChange={vi.fn()} {...props} />
|
|
46
|
+
</TooltipProvider>,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getPopoverDialog() {
|
|
51
|
+
return document.querySelector("[role=dialog]") as HTMLElement | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getOptionButtons() {
|
|
55
|
+
const dialog = getPopoverDialog();
|
|
56
|
+
if (!dialog) return [];
|
|
57
|
+
return [...dialog.querySelectorAll("button")];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
vi.clearAllMocks();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("PriorityEditor", () => {
|
|
65
|
+
it("chip variant 渲染当前 priority 的 label", () => {
|
|
66
|
+
renderEditor({ value: "high" });
|
|
67
|
+
expect(screen.getByText("priority.high")).toBeInTheDocument();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("urgent 状态渲染对应 chip", () => {
|
|
71
|
+
renderEditor({ value: "urgent" });
|
|
72
|
+
expect(screen.getByText("priority.urgent")).toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("none 状态渲染对应 chip", () => {
|
|
76
|
+
renderEditor({ value: "none" });
|
|
77
|
+
expect(screen.getByText("priority.none")).toBeInTheDocument();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("点击 chip 打开 Popover 显示所有可选 priority", () => {
|
|
81
|
+
render(
|
|
82
|
+
<TooltipProvider>
|
|
83
|
+
<PriorityEditor value="medium" onChange={vi.fn()} />
|
|
84
|
+
</TooltipProvider>,
|
|
85
|
+
);
|
|
86
|
+
fireEvent.click(screen.getByText("priority.medium"));
|
|
87
|
+
expect(getPopoverDialog()).not.toBeNull();
|
|
88
|
+
const buttons = getOptionButtons();
|
|
89
|
+
expect(buttons.map((b) => b.textContent)).toEqual(
|
|
90
|
+
expect.arrayContaining([
|
|
91
|
+
expect.stringContaining("priority.urgent"),
|
|
92
|
+
expect.stringContaining("priority.high"),
|
|
93
|
+
expect.stringContaining("priority.medium"),
|
|
94
|
+
expect.stringContaining("priority.low"),
|
|
95
|
+
expect.stringContaining("priority.none"),
|
|
96
|
+
]),
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("点击不同 priority 触发 onChange 并关闭 Popover", () => {
|
|
101
|
+
const onChange = vi.fn();
|
|
102
|
+
render(
|
|
103
|
+
<TooltipProvider>
|
|
104
|
+
<PriorityEditor value="medium" onChange={onChange} />
|
|
105
|
+
</TooltipProvider>,
|
|
106
|
+
);
|
|
107
|
+
fireEvent.click(screen.getByText("priority.medium"));
|
|
108
|
+
expect(getPopoverDialog()).not.toBeNull();
|
|
109
|
+
const buttons = getOptionButtons();
|
|
110
|
+
const urgentBtn = buttons.find((b) => b.textContent?.includes("priority.urgent"));
|
|
111
|
+
fireEvent.click(urgentBtn!);
|
|
112
|
+
expect(onChange).toHaveBeenCalledWith("urgent");
|
|
113
|
+
expect(getPopoverDialog()).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("已选中的 priority 在 Popover 中带 Check 图标(active 类)", () => {
|
|
117
|
+
render(
|
|
118
|
+
<TooltipProvider>
|
|
119
|
+
<PriorityEditor value="low" onChange={vi.fn()} />
|
|
120
|
+
</TooltipProvider>,
|
|
121
|
+
);
|
|
122
|
+
fireEvent.click(screen.getByText("priority.low"));
|
|
123
|
+
expect(getPopoverDialog()).not.toBeNull();
|
|
124
|
+
const checkedBtn = getOptionButtons().find((b) => {
|
|
125
|
+
return b.textContent?.includes("priority.low") && b.className.includes("font-medium");
|
|
126
|
+
});
|
|
127
|
+
expect(checkedBtn).toBeTruthy();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("multi 模式", () => {
|
|
131
|
+
it("selected 为空时显示当前 priority 的 chip", () => {
|
|
132
|
+
renderEditor({ mode: "multi", selected: [] });
|
|
133
|
+
expect(screen.getByText("priority.medium")).toBeInTheDocument();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("selected 有值时渲染对应 priority 的多个 chip", () => {
|
|
137
|
+
renderEditor({ mode: "multi", selected: ["high", "urgent"], onMultiChange: vi.fn() });
|
|
138
|
+
expect(screen.getByText("priority.high")).toBeInTheDocument();
|
|
139
|
+
expect(screen.getByText("priority.urgent")).toBeInTheDocument();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("点击 multi 选项触发 onMultiChange 而非 onChange", () => {
|
|
143
|
+
const onChange = vi.fn();
|
|
144
|
+
const onMultiChange = vi.fn();
|
|
145
|
+
render(
|
|
146
|
+
<TooltipProvider>
|
|
147
|
+
<PriorityEditor
|
|
148
|
+
value="medium"
|
|
149
|
+
mode="multi"
|
|
150
|
+
selected={["medium"]}
|
|
151
|
+
onChange={onChange}
|
|
152
|
+
onMultiChange={onMultiChange}
|
|
153
|
+
/>
|
|
154
|
+
</TooltipProvider>,
|
|
155
|
+
);
|
|
156
|
+
fireEvent.click(screen.getByText("priority.medium"));
|
|
157
|
+
expect(getPopoverDialog()).not.toBeNull();
|
|
158
|
+
const buttons = getOptionButtons();
|
|
159
|
+
const targetBtn = buttons.find((b) => b.textContent?.includes("priority.high"));
|
|
160
|
+
fireEvent.click(targetBtn!);
|
|
161
|
+
expect(onMultiChange).toHaveBeenCalledWith("high");
|
|
162
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
163
|
+
expect(getPopoverDialog()).not.toBeNull();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("variant=button 时渲染 InlineTrigger(无 chip 边框)", () => {
|
|
168
|
+
renderEditor({ variant: "button", value: "urgent" });
|
|
169
|
+
expect(screen.getByText("priority.urgent")).toBeInTheDocument();
|
|
170
|
+
const chip = screen.getByText("priority.urgent").closest("span.border-border");
|
|
171
|
+
expect(chip).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
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
|
+
const MockResizeObserver = class ResizeObserver {
|
|
19
|
+
observe() {}
|
|
20
|
+
unobserve() {}
|
|
21
|
+
disconnect() {}
|
|
22
|
+
};
|
|
23
|
+
if (typeof globalThis.ResizeObserver === "undefined") {
|
|
24
|
+
(globalThis as any).ResizeObserver = MockResizeObserver;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
28
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
29
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
30
|
+
import { StateEditor } from "../state-editor";
|
|
31
|
+
|
|
32
|
+
vi.mock("react-i18next", () => ({
|
|
33
|
+
useTranslation: () => ({
|
|
34
|
+
t: (key: string) => key,
|
|
35
|
+
}),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock("@/lib/utils", () => ({
|
|
39
|
+
cn: (...args: string[]) => args.filter(Boolean).join(" "),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
function renderEditor(props: Partial<React.ComponentProps<typeof StateEditor>>) {
|
|
43
|
+
return render(
|
|
44
|
+
<TooltipProvider>
|
|
45
|
+
<StateEditor value="todo" onChange={vi.fn()} {...props} />
|
|
46
|
+
</TooltipProvider>,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getPopoverDialog() {
|
|
51
|
+
return document.querySelector("[role=dialog]") as HTMLElement | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getOptionButtons() {
|
|
55
|
+
const dialog = getPopoverDialog();
|
|
56
|
+
if (!dialog) return [];
|
|
57
|
+
return [...dialog.querySelectorAll("button")];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
vi.clearAllMocks();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("StateEditor", () => {
|
|
65
|
+
it("chip variant 渲染当前 state 的 label 作为 chip", () => {
|
|
66
|
+
renderEditor({ value: "in_progress" });
|
|
67
|
+
expect(screen.getByText("state.in_progress")).toBeInTheDocument();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("todo 状态渲染对应 chip", () => {
|
|
71
|
+
renderEditor({ value: "todo" });
|
|
72
|
+
expect(screen.getByText("state.todo")).toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("backlog 状态渲染对应 chip", () => {
|
|
76
|
+
renderEditor({ value: "backlog" });
|
|
77
|
+
expect(screen.getByText("state.backlog")).toBeInTheDocument();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("点击 chip 打开 Popover 显示所有可选 state", async () => {
|
|
81
|
+
render(
|
|
82
|
+
<TooltipProvider>
|
|
83
|
+
<StateEditor value="todo" onChange={vi.fn()} />
|
|
84
|
+
</TooltipProvider>,
|
|
85
|
+
);
|
|
86
|
+
fireEvent.click(screen.getByText("state.todo"));
|
|
87
|
+
expect(getPopoverDialog()).not.toBeNull();
|
|
88
|
+
const buttons = getOptionButtons();
|
|
89
|
+
expect(buttons.map((b) => b.textContent)).toEqual(
|
|
90
|
+
expect.arrayContaining([
|
|
91
|
+
expect.stringContaining("state.backlog"),
|
|
92
|
+
expect.stringContaining("state.todo"),
|
|
93
|
+
expect.stringContaining("state.in_progress"),
|
|
94
|
+
expect.stringContaining("state.done"),
|
|
95
|
+
expect.stringContaining("state.cancelled"),
|
|
96
|
+
]),
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("点击不同 state 触发 onChange 并关闭 Popover", async () => {
|
|
101
|
+
const onChange = vi.fn();
|
|
102
|
+
render(
|
|
103
|
+
<TooltipProvider>
|
|
104
|
+
<StateEditor value="todo" onChange={onChange} />
|
|
105
|
+
</TooltipProvider>,
|
|
106
|
+
);
|
|
107
|
+
fireEvent.click(screen.getByText("state.todo"));
|
|
108
|
+
expect(getPopoverDialog()).not.toBeNull();
|
|
109
|
+
const buttons = getOptionButtons();
|
|
110
|
+
const doneBtn = buttons.find((b) => b.textContent?.includes("state.done"));
|
|
111
|
+
fireEvent.click(doneBtn!);
|
|
112
|
+
expect(onChange).toHaveBeenCalledWith("done");
|
|
113
|
+
expect(getPopoverDialog()).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("已选中的 state 在 Popover 中带 Check 图标(active 类)", () => {
|
|
117
|
+
render(
|
|
118
|
+
<TooltipProvider>
|
|
119
|
+
<StateEditor value="done" onChange={vi.fn()} />
|
|
120
|
+
</TooltipProvider>,
|
|
121
|
+
);
|
|
122
|
+
fireEvent.click(screen.getByText("state.done"));
|
|
123
|
+
expect(getPopoverDialog()).not.toBeNull();
|
|
124
|
+
const checkedBtn = getOptionButtons().find((b) => {
|
|
125
|
+
return b.textContent?.includes("state.done") && b.className.includes("font-medium");
|
|
126
|
+
});
|
|
127
|
+
expect(checkedBtn).toBeTruthy();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("multi 模式", () => {
|
|
131
|
+
it("selected 为空时显示当前 state 的 chip", () => {
|
|
132
|
+
renderEditor({ mode: "multi", selected: [] });
|
|
133
|
+
expect(screen.getByText("state.todo")).toBeInTheDocument();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("selected 有值时渲染对应 state 的多个 chip", () => {
|
|
137
|
+
renderEditor({ mode: "multi", selected: ["todo", "in_progress"], onMultiChange: vi.fn() });
|
|
138
|
+
expect(screen.getByText("state.todo")).toBeInTheDocument();
|
|
139
|
+
expect(screen.getByText("state.in_progress")).toBeInTheDocument();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("点击 multi 选项触发 onMultiChange 而非 onChange", () => {
|
|
143
|
+
const onChange = vi.fn();
|
|
144
|
+
const onMultiChange = vi.fn();
|
|
145
|
+
render(
|
|
146
|
+
<TooltipProvider>
|
|
147
|
+
<StateEditor
|
|
148
|
+
value="todo"
|
|
149
|
+
mode="multi"
|
|
150
|
+
selected={["todo"]}
|
|
151
|
+
onChange={onChange}
|
|
152
|
+
onMultiChange={onMultiChange}
|
|
153
|
+
/>
|
|
154
|
+
</TooltipProvider>,
|
|
155
|
+
);
|
|
156
|
+
fireEvent.click(screen.getByText("state.todo"));
|
|
157
|
+
expect(getPopoverDialog()).not.toBeNull();
|
|
158
|
+
const buttons = getOptionButtons();
|
|
159
|
+
const targetBtn = buttons.find((b) => b.textContent?.includes("state.in_progress"));
|
|
160
|
+
fireEvent.click(targetBtn!);
|
|
161
|
+
expect(onMultiChange).toHaveBeenCalledWith("in_progress");
|
|
162
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
163
|
+
expect(getPopoverDialog()).not.toBeNull();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("variant=button 时渲染 InlineTrigger(无 chip 边框)", () => {
|
|
168
|
+
renderEditor({ variant: "button", value: "todo" });
|
|
169
|
+
expect(screen.getByText("state.todo")).toBeInTheDocument();
|
|
170
|
+
// InlineTrigger 走 Button,外层无 chip 边框(span.border-border 不存在)
|
|
171
|
+
const chip = screen.getByText("state.todo").closest("span.border-border");
|
|
172
|
+
expect(chip).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
});
|