@allbluecn/web-app 0.11.4 → 0.12.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 +36 -26
- package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +83 -5
- package/src/components/shared-kit/icons/__tests__/lucide-lab-icons.test.ts +179 -0
- package/src/components/shared-kit/icons/lucide-icon-picker/icon-set.ts +47 -0
- package/src/components/shared-kit/icons/lucide-icon-picker/lab-icons.ts +463 -0
- package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +30 -4
- package/src/components/shared-kit/icons/lucide-icon-picker/panel.tsx +169 -43
- package/src/lib/changelog/sdk-versions.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allbluecn/web-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"license": "AGPL-3.0-or-later",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -33,6 +33,14 @@
|
|
|
33
33
|
"types": "./src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx",
|
|
34
34
|
"default": "./src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx"
|
|
35
35
|
},
|
|
36
|
+
"./lucide-icon-picker/lab-icons": {
|
|
37
|
+
"types": "./src/components/shared-kit/icons/lucide-icon-picker/lab-icons.ts",
|
|
38
|
+
"default": "./src/components/shared-kit/icons/lucide-icon-picker/lab-icons.ts"
|
|
39
|
+
},
|
|
40
|
+
"./lucide-icon-picker/icon-set": {
|
|
41
|
+
"types": "./src/components/shared-kit/icons/lucide-icon-picker/icon-set.ts",
|
|
42
|
+
"default": "./src/components/shared-kit/icons/lucide-icon-picker/icon-set.ts"
|
|
43
|
+
},
|
|
36
44
|
"./prototype": {
|
|
37
45
|
"types": "./src/components/prototype/index.ts",
|
|
38
46
|
"default": "./src/components/prototype/index.ts"
|
|
@@ -57,7 +65,33 @@
|
|
|
57
65
|
"publishConfig": {
|
|
58
66
|
"access": "public"
|
|
59
67
|
},
|
|
68
|
+
"packageManager": "pnpm@12.4.1",
|
|
69
|
+
"scripts": {
|
|
70
|
+
"dev": "vite --port 3000",
|
|
71
|
+
"build": "vite build",
|
|
72
|
+
"start": "vite preview",
|
|
73
|
+
"lint": "eslint",
|
|
74
|
+
"test": "vitest",
|
|
75
|
+
"test:ui": "vitest --ui",
|
|
76
|
+
"test:run": "vitest run --project unit --project integration",
|
|
77
|
+
"pretest:coverage": "pnpm assemble",
|
|
78
|
+
"test:coverage": "vitest run --project unit --project integration --coverage.enabled",
|
|
79
|
+
"setup": "pnpm --filter @allbluecn/database generate && pnpm --filter @allbluecn/database migrate:dev",
|
|
80
|
+
"check:features": "tsx scripts/check-feature-keys.ts",
|
|
81
|
+
"assemble": "TSX_TSCONFIG_PATH=apps/web/tsconfig.json pnpm -w exec tsx packages/kernel/src/bin.ts --profile oss --assemble --app-dir apps/web",
|
|
82
|
+
"predev": "pnpm assemble && node scripts/check-sdk-versions.mjs",
|
|
83
|
+
"prebuild": "pnpm assemble && node scripts/check-sdk-versions.mjs",
|
|
84
|
+
"pretest": "pnpm assemble",
|
|
85
|
+
"pretest:run": "pnpm assemble",
|
|
86
|
+
"prepublishOnly": "node scripts/check-sdk-versions.mjs",
|
|
87
|
+
"typecheck": "node scripts/i18n/compile.mjs && tsc --noEmit",
|
|
88
|
+
"test:unit": "vitest run --project unit",
|
|
89
|
+
"test:visual": "vitest run --project vrt",
|
|
90
|
+
"test:bench": "vitest bench --project \"integration (bench)\""
|
|
91
|
+
},
|
|
60
92
|
"dependencies": {
|
|
93
|
+
"@allbluecn/database": "workspace:*",
|
|
94
|
+
"@allbluecn/kernel": "workspace:*",
|
|
61
95
|
"@ariakit/react": "^0.4.28",
|
|
62
96
|
"@auth/core": "^0.41.2",
|
|
63
97
|
"@auth/prisma-adapter": "^2.11.2",
|
|
@@ -187,9 +221,7 @@
|
|
|
187
221
|
"tailwind-merge": "^3.5.0",
|
|
188
222
|
"three": "^0.180.0",
|
|
189
223
|
"tw-animate-css": "^1.4.0",
|
|
190
|
-
"zod": "^4.4.3"
|
|
191
|
-
"@allbluecn/database": "0.8.0",
|
|
192
|
-
"@allbluecn/kernel": "0.8.0"
|
|
224
|
+
"zod": "^4.4.3"
|
|
193
225
|
},
|
|
194
226
|
"devDependencies": {
|
|
195
227
|
"@babel/parser": "^8.0.4",
|
|
@@ -237,27 +269,5 @@
|
|
|
237
269
|
},
|
|
238
270
|
"optionalDependencies": {
|
|
239
271
|
"@tauri-apps/api": "^2.11.1"
|
|
240
|
-
},
|
|
241
|
-
"scripts": {
|
|
242
|
-
"dev": "vite --port 3000",
|
|
243
|
-
"build": "vite build",
|
|
244
|
-
"start": "vite preview",
|
|
245
|
-
"lint": "eslint",
|
|
246
|
-
"test": "vitest",
|
|
247
|
-
"test:ui": "vitest --ui",
|
|
248
|
-
"test:run": "vitest run --project unit --project integration",
|
|
249
|
-
"pretest:coverage": "pnpm assemble",
|
|
250
|
-
"test:coverage": "vitest run --project unit --project integration --coverage.enabled",
|
|
251
|
-
"setup": "pnpm --filter @allbluecn/database generate && pnpm --filter @allbluecn/database migrate:dev",
|
|
252
|
-
"check:features": "tsx scripts/check-feature-keys.ts",
|
|
253
|
-
"assemble": "TSX_TSCONFIG_PATH=apps/web/tsconfig.json pnpm -w exec tsx packages/kernel/src/bin.ts --profile oss --assemble --app-dir apps/web",
|
|
254
|
-
"predev": "pnpm assemble && node scripts/check-sdk-versions.mjs",
|
|
255
|
-
"prebuild": "pnpm assemble && node scripts/check-sdk-versions.mjs",
|
|
256
|
-
"pretest": "pnpm assemble",
|
|
257
|
-
"pretest:run": "pnpm assemble",
|
|
258
|
-
"typecheck": "node scripts/i18n/compile.mjs && tsc --noEmit",
|
|
259
|
-
"test:unit": "vitest run --project unit",
|
|
260
|
-
"test:visual": "vitest run --project vrt",
|
|
261
|
-
"test:bench": "vitest bench --project \"integration (bench)\""
|
|
262
272
|
}
|
|
263
273
|
}
|
|
@@ -22,7 +22,7 @@ import { Star } from "lucide-react";
|
|
|
22
22
|
import { LucideIconByName } from "@/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name";
|
|
23
23
|
import { IconPickerPanel } from "@/components/shared-kit/icons/lucide-icon-picker/panel";
|
|
24
24
|
import { LucideIconPicker } from "@/components/shared-kit/icons/lucide-icon-picker";
|
|
25
|
-
import {
|
|
25
|
+
import { PICKER_ICONS } from "@/components/shared-kit/icons/lucide-icon-picker/icon-set";
|
|
26
26
|
import { pushRecentIcon, getRecentIcons } from "@/components/shared-kit/icons/lucide-icon-picker/icon-data";
|
|
27
27
|
|
|
28
28
|
describe("LucideIconByName", () => {
|
|
@@ -62,6 +62,16 @@ describe("LucideIconByName", () => {
|
|
|
62
62
|
"text-primary"
|
|
63
63
|
);
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
it("Lab 图标名按需加载后渲染对应图标", async () => {
|
|
67
|
+
const { container } = render(<LucideIconByName name="avocado" size={20} />);
|
|
68
|
+
await waitFor(() => {
|
|
69
|
+
expect(
|
|
70
|
+
container.querySelector("svg")?.getAttribute("class")
|
|
71
|
+
).toContain("lucide-avocado");
|
|
72
|
+
});
|
|
73
|
+
expect(container.querySelector("svg")?.getAttribute("width")).toBe("20");
|
|
74
|
+
});
|
|
65
75
|
});
|
|
66
76
|
|
|
67
77
|
describe("IconPickerPanel", () => {
|
|
@@ -79,9 +89,9 @@ describe("IconPickerPanel", () => {
|
|
|
79
89
|
expect(screen.queryByRole("button", { name: /全部图标/ })).toBeNull();
|
|
80
90
|
// 无最近记录:不显示"最近使用"小节标题,网格 = 精选总数
|
|
81
91
|
expect(screen.queryByText(/最近使用/)).toBeNull();
|
|
82
|
-
expect(screen.getByText(
|
|
92
|
+
expect(screen.getByText(/常规图标/)).toBeTruthy();
|
|
83
93
|
const cells = await screen.findAllByRole("button", { name: /^icon-cell-/ });
|
|
84
|
-
expect(cells.length).toBe(
|
|
94
|
+
expect(cells.length).toBe(PICKER_ICONS.length);
|
|
85
95
|
});
|
|
86
96
|
|
|
87
97
|
it("英文搜索过滤 + Enter 选中第一个结果", async () => {
|
|
@@ -120,9 +130,32 @@ describe("IconPickerPanel", () => {
|
|
|
120
130
|
pushRecentIcon("clock");
|
|
121
131
|
render(<IconPickerPanel onPick={vi.fn()} />);
|
|
122
132
|
expect(screen.getByText(/最近使用/)).toBeTruthy();
|
|
123
|
-
expect(screen.getByText(
|
|
133
|
+
expect(screen.getByText(/常规图标/)).toBeTruthy();
|
|
134
|
+
const cells = await screen.findAllByRole("button", { name: /^icon-cell-/ });
|
|
135
|
+
expect(cells.length).toBe(PICKER_ICONS.length + 2);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("Lab 分区靠前且常规分区在末尾", async () => {
|
|
139
|
+
render(<IconPickerPanel onPick={vi.fn()} />);
|
|
140
|
+
const headings = screen.getAllByRole("heading").map((h) => h.textContent);
|
|
141
|
+
for (const title of [
|
|
142
|
+
"食物与饮品",
|
|
143
|
+
"游戏",
|
|
144
|
+
"家居",
|
|
145
|
+
"动物",
|
|
146
|
+
"自然",
|
|
147
|
+
"人物",
|
|
148
|
+
"季节",
|
|
149
|
+
"更多实验图标",
|
|
150
|
+
]) {
|
|
151
|
+
expect(headings).toContain(title);
|
|
152
|
+
}
|
|
153
|
+
expect(headings[headings.length - 1]).toBe("常规图标");
|
|
124
154
|
const cells = await screen.findAllByRole("button", { name: /^icon-cell-/ });
|
|
125
|
-
expect(cells.
|
|
155
|
+
expect(cells[0].getAttribute("aria-label")).toBe(
|
|
156
|
+
`icon-cell-${PICKER_ICONS[0].name}`
|
|
157
|
+
);
|
|
158
|
+
expect(PICKER_ICONS[0].source).toBe("lab");
|
|
126
159
|
});
|
|
127
160
|
|
|
128
161
|
it("value 对应 cell 有选中态标识", async () => {
|
|
@@ -130,6 +163,51 @@ describe("IconPickerPanel", () => {
|
|
|
130
163
|
const cell = screen.getByRole("button", { name: "icon-cell-star" });
|
|
131
164
|
expect(cell.getAttribute("data-selected")).toBe("true");
|
|
132
165
|
});
|
|
166
|
+
|
|
167
|
+
it("Lab 图标 value 对应 cell 有选中态标识", async () => {
|
|
168
|
+
render(<IconPickerPanel value="avocado" onPick={vi.fn()} />);
|
|
169
|
+
const cell = screen.getByRole("button", { name: "icon-cell-avocado" });
|
|
170
|
+
expect(cell.getAttribute("data-selected")).toBe("true");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("视口高度可用时走虚拟化:只渲染首屏区块,尾部区块不进 DOM", () => {
|
|
174
|
+
const viewport = 420;
|
|
175
|
+
// jsdom 默认 clientHeight/offsetHeight 为 0(降级整表渲染),这里给滚动容器一个真实视口高度
|
|
176
|
+
try {
|
|
177
|
+
vi.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(
|
|
178
|
+
viewport
|
|
179
|
+
);
|
|
180
|
+
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(
|
|
181
|
+
viewport
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const { container } = render(<IconPickerPanel onPick={vi.fn()} />);
|
|
185
|
+
const scrollArea = container.querySelector(
|
|
186
|
+
".min-h-0.overflow-y-auto"
|
|
187
|
+
) as HTMLElement;
|
|
188
|
+
expect(scrollArea).toBeTruthy();
|
|
189
|
+
const spacer = scrollArea.firstElementChild as HTMLElement;
|
|
190
|
+
expect(spacer.style.position).toBe("relative");
|
|
191
|
+
const totalSize = Number(spacer.style.height.replace("px", ""));
|
|
192
|
+
expect(totalSize).toBeGreaterThan(viewport);
|
|
193
|
+
expect(
|
|
194
|
+
screen.getByRole("button", {
|
|
195
|
+
name: `icon-cell-${PICKER_ICONS[0].name}`,
|
|
196
|
+
})
|
|
197
|
+
).toBeTruthy();
|
|
198
|
+
expect(screen.getByText(/食物与饮品/)).toBeTruthy();
|
|
199
|
+
const tail = PICKER_ICONS[PICKER_ICONS.length - 1];
|
|
200
|
+
expect(
|
|
201
|
+
screen.queryByRole("button", { name: `icon-cell-${tail.name}` })
|
|
202
|
+
).toBeNull();
|
|
203
|
+
expect(screen.queryByText(/常规图标/)).toBeNull();
|
|
204
|
+
const cells = screen.queryAllByRole("button", { name: /^icon-cell-/ });
|
|
205
|
+
expect(cells.length).toBeGreaterThan(0);
|
|
206
|
+
expect(cells.length).toBeLessThan(PICKER_ICONS.length);
|
|
207
|
+
} finally {
|
|
208
|
+
vi.restoreAllMocks();
|
|
209
|
+
}
|
|
210
|
+
});
|
|
133
211
|
});
|
|
134
212
|
|
|
135
213
|
describe("LucideIconPicker(Popover 包装)", () => {
|
|
@@ -0,0 +1,179 @@
|
|
|
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, expect, it } from "vitest";
|
|
19
|
+
import * as lab from "@lucide/lab";
|
|
20
|
+
import {
|
|
21
|
+
LAB_ICONS,
|
|
22
|
+
LAB_ICON_MAP,
|
|
23
|
+
LAB_ICONS_BY_CATEGORY,
|
|
24
|
+
LAB_PRIORITY_CATEGORIES,
|
|
25
|
+
type LabCategory,
|
|
26
|
+
} from "@/components/shared-kit/icons/lucide-icon-picker/lab-icons";
|
|
27
|
+
import {
|
|
28
|
+
CURATED_SEARCH_INDEX,
|
|
29
|
+
PICKER_ICONS,
|
|
30
|
+
PICKER_ICON_MAP,
|
|
31
|
+
PICKER_SEARCH_INDEX,
|
|
32
|
+
} from "@/components/shared-kit/icons/lucide-icon-picker/icon-set";
|
|
33
|
+
import { CURATED_ICONS } from "@/components/shared-kit/icons/lucide-icon-picker/curated-icons";
|
|
34
|
+
import { pascalToKebab } from "@/components/shared-kit/icons/lucide-icon-picker/icon-data";
|
|
35
|
+
|
|
36
|
+
const ALL_CATEGORIES: LabCategory[] = [...LAB_PRIORITY_CATEGORIES, "other"];
|
|
37
|
+
|
|
38
|
+
/** kebab 名 → @lucide/lab 导出名(首段保持小写,其余段首字母大写) */
|
|
39
|
+
function kebabToExportName(kebab: string): string {
|
|
40
|
+
const parts = kebab.split("-");
|
|
41
|
+
return (
|
|
42
|
+
parts[0] +
|
|
43
|
+
parts
|
|
44
|
+
.slice(1)
|
|
45
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
46
|
+
.join("")
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @lucide/lab 真实图标导出(default/module.exports 聚合对象按值类型排除) */
|
|
51
|
+
const LAB_EXPORTS = Object.keys(lab).filter(
|
|
52
|
+
(key) => Array.isArray((lab as Record<string, unknown>)[key])
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
describe("LAB_ICONS 实验性图标名单", () => {
|
|
56
|
+
it("覆盖 @lucide/lab 全量导出(*-off 关闭变体除外)", () => {
|
|
57
|
+
const covered = new Set(LAB_ICONS.map((icon) => kebabToExportName(icon.name)));
|
|
58
|
+
const missing = LAB_EXPORTS.filter(
|
|
59
|
+
(name) => !covered.has(name) && !pascalToKebab(name).endsWith("-off")
|
|
60
|
+
);
|
|
61
|
+
expect(missing).toEqual([]);
|
|
62
|
+
expect(covered.size).toBe(LAB_ICONS.length);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("每项 name 对应的 @lucide/lab 导出存在", () => {
|
|
66
|
+
const exportSet = new Set(LAB_EXPORTS);
|
|
67
|
+
const missing = LAB_ICONS.filter(
|
|
68
|
+
(icon) => !exportSet.has(kebabToExportName(icon.name))
|
|
69
|
+
);
|
|
70
|
+
expect(missing.map((icon) => icon.name)).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("数量在目标区间 320-420", () => {
|
|
74
|
+
expect(LAB_ICONS.length).toBeGreaterThanOrEqual(320);
|
|
75
|
+
expect(LAB_ICONS.length).toBeLessThanOrEqual(420);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("name 无重复", () => {
|
|
79
|
+
const names = LAB_ICONS.map((icon) => icon.name);
|
|
80
|
+
expect(new Set(names).size).toBe(names.length);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("不含 -off 关闭变体", () => {
|
|
84
|
+
const banned = LAB_ICONS.filter((icon) => icon.name.endsWith("-off"));
|
|
85
|
+
expect(banned.map((icon) => icon.name)).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("每项 zh 关键词非空且无空字符串", () => {
|
|
89
|
+
const bad = LAB_ICONS.filter(
|
|
90
|
+
(icon) => icon.zh.length === 0 || icon.zh.some((k) => !k.trim())
|
|
91
|
+
);
|
|
92
|
+
expect(bad.map((icon) => icon.name)).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("LAB_ICON_MAP 与 LAB_ICONS 一一对应", () => {
|
|
96
|
+
const names = Object.keys(LAB_ICON_MAP);
|
|
97
|
+
expect(new Set(names).size).toBe(names.length);
|
|
98
|
+
expect(names.length).toBe(LAB_ICONS.length);
|
|
99
|
+
for (const icon of LAB_ICONS) {
|
|
100
|
+
expect(LAB_ICON_MAP[icon.name]?.name).toBe(icon.name);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("分类互斥且完整(分区渲染无重漏)", () => {
|
|
105
|
+
const flattened = ALL_CATEGORIES.flatMap((category) =>
|
|
106
|
+
LAB_ICONS_BY_CATEGORY[category].map((icon) => icon.name)
|
|
107
|
+
);
|
|
108
|
+
expect(flattened.length).toBe(LAB_ICONS.length);
|
|
109
|
+
expect(new Set(flattened).size).toBe(LAB_ICONS.length);
|
|
110
|
+
for (const category of ALL_CATEGORIES) {
|
|
111
|
+
for (const icon of LAB_ICONS_BY_CATEGORY[category]) {
|
|
112
|
+
expect(icon.category).toBe(category);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("优先级分类非空(靠前显示的前提)", () => {
|
|
118
|
+
for (const category of LAB_PRIORITY_CATEGORIES) {
|
|
119
|
+
expect(LAB_ICONS_BY_CATEGORY[category].length).toBeGreaterThan(0);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("展示顺序 = 优先级分类顺序拼接 + other 收尾", () => {
|
|
124
|
+
const expected = [
|
|
125
|
+
...LAB_PRIORITY_CATEGORIES.flatMap((category) =>
|
|
126
|
+
LAB_ICONS_BY_CATEGORY[category].map((icon) => icon.name)
|
|
127
|
+
),
|
|
128
|
+
...LAB_ICONS_BY_CATEGORY.other.map((icon) => icon.name),
|
|
129
|
+
];
|
|
130
|
+
expect(LAB_ICONS.map((icon) => icon.name)).toEqual(expected);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("PICKER_ICONS 展示顺序(Lab 整体靠前)", () => {
|
|
135
|
+
it("总量 = Lab + 精选常规", () => {
|
|
136
|
+
expect(PICKER_ICONS.length).toBe(LAB_ICONS.length + CURATED_ICONS.length);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("Lab 图标整体排在精选常规图标之前(无交叉)", () => {
|
|
140
|
+
const lastLab = PICKER_ICONS.map((icon) => icon.source).lastIndexOf("lab");
|
|
141
|
+
expect(lastLab).toBe(LAB_ICONS.length - 1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("优先级分类图标位于最前(食物/游戏/家居/动物/自然/人物/季节)", () => {
|
|
145
|
+
const priorityCount = LAB_PRIORITY_CATEGORIES.reduce(
|
|
146
|
+
(sum, category) => sum + LAB_ICONS_BY_CATEGORY[category].length,
|
|
147
|
+
0
|
|
148
|
+
);
|
|
149
|
+
expect(LAB_ICON_MAP[PICKER_ICONS[0].name]?.category).toBe("food-beverage");
|
|
150
|
+
expect(
|
|
151
|
+
LAB_ICON_MAP[PICKER_ICONS[priorityCount - 1].name]?.category
|
|
152
|
+
).toBe("seasons");
|
|
153
|
+
expect(LAB_ICON_MAP[PICKER_ICONS[priorityCount].name]?.category).toBe(
|
|
154
|
+
"other"
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("PICKER_ICON_MAP 覆盖 Lab 与精选图标", () => {
|
|
159
|
+
expect(Object.keys(PICKER_ICON_MAP).length).toBe(PICKER_ICONS.length);
|
|
160
|
+
expect(PICKER_ICON_MAP[LAB_ICONS[0].name]?.source).toBe("lab");
|
|
161
|
+
expect(PICKER_ICON_MAP[CURATED_ICONS[0].name]?.source).toBe("curated");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("搜索索引与展示顺序一致", () => {
|
|
165
|
+
expect(PICKER_SEARCH_INDEX.map((entry) => entry.name)).toEqual(
|
|
166
|
+
PICKER_ICONS.map((icon) => icon.name)
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("CURATED_SEARCH_INDEX 仅含精选常规图标且顺序不变", () => {
|
|
171
|
+
expect(CURATED_SEARCH_INDEX.map((entry) => entry.name)).toEqual(
|
|
172
|
+
CURATED_ICONS.map((icon) => icon.name)
|
|
173
|
+
);
|
|
174
|
+
const labNames = new Set(LAB_ICONS.map((icon) => icon.name));
|
|
175
|
+
expect(
|
|
176
|
+
CURATED_SEARCH_INDEX.filter((entry) => labNames.has(entry.name))
|
|
177
|
+
).toEqual([]);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
// 选择器图标单一事实源:Lab(实验性)图标整体靠前,随后是精选常规图标。
|
|
19
|
+
// Lab 图标分类靠前规则见 lab-icons.ts(食物/游戏/家居/动物/自然/人物/季节)。
|
|
20
|
+
|
|
21
|
+
import { CURATED_ICONS } from "./curated-icons";
|
|
22
|
+
import { LAB_ICONS, LAB_ICON_MAP } from "./lab-icons";
|
|
23
|
+
import { buildSearchIndex, type CuratedIcon, type IconSearchEntry } from "./icon-data";
|
|
24
|
+
|
|
25
|
+
export interface PickerIcon extends CuratedIcon {
|
|
26
|
+
/** 来源:lab(实验性,靠前)| curated(精选常规) */
|
|
27
|
+
source: "lab" | "curated";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 展示顺序:Lab 图标整体在精选常规图标之前 */
|
|
31
|
+
export const PICKER_ICONS: PickerIcon[] = [
|
|
32
|
+
...LAB_ICONS.map((icon) => ({ ...icon, source: "lab" as const })),
|
|
33
|
+
...CURATED_ICONS.map((icon) => ({ ...icon, source: "curated" as const })),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** name → 条目 查找表(全量,含 Lab) */
|
|
37
|
+
export const PICKER_ICON_MAP: Record<string, PickerIcon> = Object.fromEntries(
|
|
38
|
+
PICKER_ICONS.map((icon) => [icon.name, icon])
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
/** 搜索索引(与展示顺序一致) */
|
|
42
|
+
export const PICKER_SEARCH_INDEX: IconSearchEntry[] = buildSearchIndex(PICKER_ICONS);
|
|
43
|
+
|
|
44
|
+
/** 精选常规图标索引(分区渲染用) */
|
|
45
|
+
export const CURATED_SEARCH_INDEX: IconSearchEntry[] = PICKER_SEARCH_INDEX.filter(
|
|
46
|
+
(entry) => !(entry.name in LAB_ICON_MAP)
|
|
47
|
+
);
|