diff --git a/ts/web/nextjs/polaris/public/icons/ali-cloud.svg b/ts/web/nextjs/polaris/public/icons/ali-cloud.svg new file mode 100644 index 0000000..63020cd --- /dev/null +++ b/ts/web/nextjs/polaris/public/icons/ali-cloud.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ts/web/nextjs/polaris/src/app/categories/[categoryId]/page.test.tsx b/ts/web/nextjs/polaris/src/app/categories/[categoryId]/page.test.tsx index c9c435c..f87a74f 100644 --- a/ts/web/nextjs/polaris/src/app/categories/[categoryId]/page.test.tsx +++ b/ts/web/nextjs/polaris/src/app/categories/[categoryId]/page.test.tsx @@ -6,7 +6,7 @@ import { getLinksByCategory } from "@/services/links" import CategoryPage, { dynamicParams, generateStaticParams } from "./page" describe("CategoryPage", () => { - it("为全部 7 个分类生成静态参数并关闭运行时动态参数", () => { + it("为全部 8 个分类生成静态参数并关闭运行时动态参数", () => { expect(generateStaticParams()).toEqual([ { categoryId: "ai" }, { categoryId: "dev" }, @@ -14,11 +14,35 @@ describe("CategoryPage", () => { { categoryId: "learning" }, { categoryId: "efficiency" }, { categoryId: "media" }, + { categoryId: "game-resources" }, { categoryId: "life" }, ]) expect(dynamicParams).toBe(false) }) + it("归类边界:游戏资料页收录宝可梦维基,娱乐媒体页不再包含", async () => { + const gamePage = await CategoryPage({ + params: Promise.resolve({ categoryId: "game-resources" }), + }) + render(gamePage) + + expect( + screen.getByRole("heading", { level: 1, name: "游戏资料" }), + ).toBeInTheDocument() + expect( + screen.getByRole("link", { name: /神奇宝贝百科/ }), + ).toBeInTheDocument() + expect( + screen.getByRole("link", { name: /星露谷物语中文维基/ }), + ).toBeInTheDocument() + expect( + screen.queryByRole("link", { name: /萌娘百科/ }), + ).not.toBeInTheDocument() + expect(getLinksByCategory("media").map(({ id }) => id)).not.toContain( + "wiki-52poke", + ) + }) + it("通过 service 组合开发工具分类页", async () => { const links = getLinksByCategory("dev") const page = await CategoryPage({ diff --git a/ts/web/nextjs/polaris/src/data/categories.json b/ts/web/nextjs/polaris/src/data/categories.json index 317d95e..dad0b6f 100644 --- a/ts/web/nextjs/polaris/src/data/categories.json +++ b/ts/web/nextjs/polaris/src/data/categories.json @@ -41,6 +41,13 @@ "icon": "circle-play", "sort": 60 }, + { + "id": "game-resources", + "title": "游戏资料", + "description": "游戏攻略维基、资料速查与实用规划工具。", + "icon": "book-open", + "sort": 65 + }, { "id": "life", "title": "生活服务", diff --git a/ts/web/nextjs/polaris/src/data/data.test.ts b/ts/web/nextjs/polaris/src/data/data.test.ts index 7550243..e80cd6a 100644 --- a/ts/web/nextjs/polaris/src/data/data.test.ts +++ b/ts/web/nextjs/polaris/src/data/data.test.ts @@ -9,9 +9,12 @@ const hasUniqueValues = (values: string[]) => new Set(values).size === values.length describe("Polaris 静态数据", () => { - it("包含 7 个唯一且结构完整的分类", () => { - expect(categories).toHaveLength(7) + it("包含 8 个唯一且结构完整的分类", () => { + expect(categories).toHaveLength(8) expect(hasUniqueValues(categories.map(({ id }) => id))).toBe(true) + expect(hasUniqueValues(categories.map(({ sort }) => String(sort)))).toBe( + true, + ) for (const category of categories) { expect(category).toEqual({ @@ -27,9 +30,9 @@ describe("Polaris 静态数据", () => { } }) - it("包含 45 条合法链接且分类引用均存在", () => { + it("包含 51 条合法链接且分类引用均存在", () => { const categoryIds = new Set(categories.map(({ id }) => id)) - expect(links).toHaveLength(45) + expect(links).toHaveLength(51) expect(hasUniqueValues(links.map(({ id }) => id))).toBe(true) for (const link of links) { @@ -115,4 +118,134 @@ describe("Polaris 静态数据", () => { } }) }) + + describe("归类边界回归", () => { + const linkIdsByCategory = (categoryId: string) => + links + .filter((link) => link.categoryId === categoryId) + .map(({ id }) => id) + .sort() + + it("游戏维基与攻略工具归入 game-resources", () => { + expect(linkIdsByCategory("game-resources")).toEqual( + expect.arrayContaining([ + "wiki-52poke", + "wiki-blhx", + "wiki-fgo", + "wiki-stardew", + "wiki-terraria", + ]), + ) + }) + + it("娱乐媒体保留影音社区与 ACG 百科,不混入游戏资料", () => { + const mediaIds = linkIdsByCategory("media") + + expect(mediaIds).toEqual( + expect.arrayContaining([ + "youtube", + "bilibili", + "spotify", + "nexusmods", + "pokemon-showdown", + "expreview", + "chairo", + "wiki-moegirl", + ]), + ) + for (const gameId of [ + "wiki-52poke", + "wiki-blhx", + "wiki-fgo", + "wiki-stardew", + "wiki-terraria", + ]) { + expect(mediaIds).not.toContain(gameId) + } + }) + + it("Nexus Mods 与 Pokemon Showdown 等实测工具仍留在娱乐媒体", () => { + const mediaIds = linkIdsByCategory("media") + + expect(mediaIds).toContain("nexusmods") + expect(mediaIds).toContain("pokemon-showdown") + expect(linkIdsByCategory("game-resources")).not.toContain("nexusmods") + expect(linkIdsByCategory("game-resources")).not.toContain( + "pokemon-showdown", + ) + }) + }) + + describe("新增九条链接回归", () => { + const expectedNewLinks = [ + { + id: "kzo", + url: "https://kzo.moe", + categoryId: "media", + }, + { + id: "manhuagui", + url: "https://www.manhuagui.com", + categoryId: "media", + }, + { + id: "linovelib", + url: "https://www.linovelib.com", + categoryId: "media", + }, + { + id: "steam", + url: "https://store.steampowered.com", + categoryId: "media", + }, + { + id: "tieba", + url: "https://tieba.baidu.com", + categoryId: "media", + }, + { + id: "manga-bilibili", + url: "https://manga.bilibili.com", + categoryId: "media", + }, + { + id: "zhihu", + url: "https://www.zhihu.com", + categoryId: "media", + }, + { + id: "pixiv", + url: "https://www.pixiv.net", + categoryId: "design", + }, + { + id: "aliyun-unified-console", + url: "https://home.console.aliyun.com", + categoryId: "efficiency", + }, + ] as const + + it("九条新链接全部存在且总数为 51", () => { + expect(links).toHaveLength(51) + expect( + expectedNewLinks.every(({ id }) => + links.some((link) => link.id === id), + ), + ).toBe(true) + }) + + it.each(expectedNewLinks)( + "$id 归入 $categoryId 且 URL 正确", + (expected) => { + const link = links.find(({ id }) => id === expected.id) + + expect(link).toBeDefined() + expect(link).toMatchObject({ + url: expected.url, + categoryId: expected.categoryId, + pinned: false, + }) + }, + ) + }) }) diff --git a/ts/web/nextjs/polaris/src/data/links.json b/ts/web/nextjs/polaris/src/data/links.json index 1c0885d..7af3375 100644 --- a/ts/web/nextjs/polaris/src/data/links.json +++ b/ts/web/nextjs/polaris/src/data/links.json @@ -266,47 +266,27 @@ "title": "神奇宝贝百科", "description": "宝可梦中文资料维基", "url": "https://wiki.52poke.com/wiki/%E4%B8%BB%E9%A1%B5", - "categoryId": "media", + "categoryId": "game-resources", "pinned": false, "keywords": ["宝可梦", "口袋妖怪", "攻略", "资料"], "sort": 80 }, - { - "id": "wiki-52poke-typechart", - "title": "宝可梦属性相克表", - "description": "宝可梦全属性克制关系速查", - "url": "https://wiki.52poke.com/wiki/%E5%B1%9E%E6%80%A7%E7%9B%B8%E5%85%8B%E8%A1%A8", - "categoryId": "media", - "pinned": false, - "keywords": ["宝可梦", "属性", "相克", "速查"], - "sort": 90 - }, { "id": "wiki-blhx", "title": "碧蓝航线WIKI", "description": "碧蓝航线官方中文百科", "url": "https://wiki.biligame.com/blhx/%E9%A6%96%E9%A1%B5", - "categoryId": "media", + "categoryId": "game-resources", "pinned": false, "keywords": ["碧蓝航线", "手游", "攻略", "wiki"], "sort": 100 }, - { - "id": "wiki-blhx-boss", - "title": "碧蓝航线月度BOSS解析", - "description": "大型作战月度 BOSS 攻略解析", - "url": "https://wiki.biligame.com/blhx/%E5%A4%A7%E5%9E%8B%E4%BD%9C%E6%88%98%E6%9C%88%E5%BA%A6BOSS%E8%A7%A3%E6%9E%90", - "categoryId": "media", - "pinned": false, - "keywords": ["碧蓝航线", "boss", "攻略", "大型作战"], - "sort": 110 - }, { "id": "wiki-fgo", "title": "Mooncell", "description": "FGO 中文维基与攻略资料站", "url": "https://fgo.wiki/w/%E9%A6%96%E9%A1%B5", - "categoryId": "media", + "categoryId": "game-resources", "pinned": false, "keywords": ["fgo", "mooncell", "从者", "攻略"], "sort": 120 @@ -316,7 +296,7 @@ "title": "星露谷物语中文维基", "description": "星露谷物语官方中文资料维基", "url": "https://zh.stardewvalleywiki.com", - "categoryId": "media", + "categoryId": "game-resources", "pinned": false, "keywords": ["星露谷物语", "攻略", "作物", "wiki"], "sort": 130 @@ -326,7 +306,7 @@ "title": "泰拉瑞亚中文维基", "description": "泰拉瑞亚官方中文百科", "url": "https://terraria.wiki.gg/zh", - "categoryId": "media", + "categoryId": "game-resources", "pinned": false, "keywords": ["泰拉瑞亚", "攻略", "物品", "wiki"], "sort": 140 @@ -341,16 +321,6 @@ "keywords": ["动漫", "acg", "百科", "二次元"], "sort": 150 }, - { - "id": "farmgamehub", - "title": "星露谷洒水器规划器", - "description": "星露谷物语洒水器布局可视化工具", - "url": "https://www.farmgamehub.com/zh/tools/stardew-sprinklers", - "categoryId": "media", - "pinned": false, - "keywords": ["星露谷物语", "洒水器", "布局", "工具"], - "sort": 160 - }, { "id": "iodraw", "title": "iodraw", @@ -406,7 +376,8 @@ "id": "aliyun-console", "title": "阿里云轻量控制台", "description": "阿里云轻量应用服务器控制台", - "url": "https://swas.console.aliyun.com", + "url": "https://console.aliyun.com", + "icon": "local:ali-cloud", "categoryId": "efficiency", "pinned": false, "keywords": ["阿里云", "控制台", "轻量服务器", "云服务"], @@ -472,5 +443,96 @@ "pinned": false, "keywords": ["地图", "导航", "出行", "路线"], "sort": 10 + }, + { + "id": "kzo", + "title": "Kmoe 漫画", + "description": "Kindle/EPUB 漫画下载与推送", + "url": "https://kzo.moe", + "categoryId": "media", + "pinned": false, + "keywords": ["漫画", "kindle", "epub", "推送"], + "sort": 160 + }, + { + "id": "manhuagui", + "title": "漫画柜", + "description": "在线漫画阅读站点", + "url": "https://www.manhuagui.com", + "categoryId": "media", + "pinned": false, + "keywords": ["漫画", "在线阅读", "动漫"], + "sort": 170 + }, + { + "id": "linovelib", + "title": "轻小说文库", + "description": "在线轻小说阅读站点", + "url": "https://www.linovelib.com", + "categoryId": "media", + "pinned": false, + "keywords": ["轻小说", "在线阅读", "文库"], + "sort": 180 + }, + { + "id": "steam", + "title": "Steam", + "description": "PC 游戏数字发行商店", + "url": "https://store.steampowered.com", + "categoryId": "media", + "pinned": false, + "keywords": ["游戏", "商店", "平台"], + "sort": 190 + }, + { + "id": "tieba", + "title": "百度贴吧", + "description": "百度旗下主题兴趣社区", + "url": "https://tieba.baidu.com", + "categoryId": "media", + "pinned": false, + "keywords": ["贴吧", "论坛", "社区", "百度"], + "sort": 200 + }, + { + "id": "manga-bilibili", + "title": "哔哩哔哩漫画", + "description": "哔哩哔哩正版漫画平台", + "url": "https://manga.bilibili.com", + "categoryId": "media", + "pinned": false, + "keywords": ["漫画", "哔哩哔哩", "正版", "阅读"], + "sort": 210 + }, + { + "id": "zhihu", + "title": "知乎", + "description": "中文问答社区", + "url": "https://www.zhihu.com", + "categoryId": "media", + "pinned": false, + "keywords": ["问答", "社区", "知识"], + "sort": 220 + }, + { + "id": "pixiv", + "title": "pixiv", + "description": "插画作品分享社区", + "url": "https://www.pixiv.net", + "categoryId": "design", + "pinned": false, + "keywords": ["插画", "绘画", "灵感", "二次元"], + "sort": 40 + }, + { + "id": "aliyun-unified-console", + "title": "阿里云统一控制台", + "description": "阿里云产品统一管理控制台", + "url": "https://home.console.aliyun.com", + "icon": "local:ali-cloud", + "categoryId": "efficiency", + "pinned": false, + "keywords": ["阿里云", "控制台", "云服务", "管理"], + "sort": 100 } ] diff --git a/ts/web/nextjs/polaris/src/features/category/components/category-content.test.tsx b/ts/web/nextjs/polaris/src/features/category/components/category-content.test.tsx index 3b8a798..d2868e7 100644 --- a/ts/web/nextjs/polaris/src/features/category/components/category-content.test.tsx +++ b/ts/web/nextjs/polaris/src/features/category/components/category-content.test.tsx @@ -72,6 +72,7 @@ describe("CategoryContent", () => { "learning", "efficiency", "media", + "game-resources", "life", ] as const)("%s 分类展示服务提供的数量", (categoryId) => { const category = getCategoryById(categoryId) @@ -82,4 +83,24 @@ describe("CategoryContent", () => { expect(screen.getByText(`共 ${links.length} 个`)).toBeInTheDocument() }) + + it("归类边界:游戏资料面板只渲染 5 个游戏维基卡片", () => { + const category = getCategoryById("game-resources") + if (!category) throw new Error("测试数据缺少 game-resources 分类") + const links = getLinksByCategory(category.id) + + render() + + expect(links).toHaveLength(5) + const toolLinks = screen + .getByRole("list", { name: "工具列表" }) + .querySelectorAll("a") + expect(toolLinks).toHaveLength(5) + expect( + screen.queryByRole("link", { name: /萌娘百科/ }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole("link", { name: /Nexus Mods/ }), + ).not.toBeInTheDocument() + }) }) diff --git a/ts/web/nextjs/polaris/src/features/navigation/components/app-shell.test.tsx b/ts/web/nextjs/polaris/src/features/navigation/components/app-shell.test.tsx index e04a909..fc1bad0 100644 --- a/ts/web/nextjs/polaris/src/features/navigation/components/app-shell.test.tsx +++ b/ts/web/nextjs/polaris/src/features/navigation/components/app-shell.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import type { Category } from "@/types/navigation" @@ -9,9 +10,18 @@ const categories: Category[] = [ { id: "dev", title: "开发工具", description: "开发", icon: "code", sort: 10 }, ] +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} + describe("AppShell", () => { beforeEach(() => window.localStorage.clear()) - afterEach(() => vi.restoreAllMocks()) + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) it("切换侧栏并持久化状态", () => { render( @@ -198,4 +208,67 @@ describe("AppShell", () => { "page", ) }) + + it("展开时分类文字可见,悬停或聚焦不显示提示", async () => { + const user = userEvent.setup() + render( + +
首页内容
+
, + ) + + const link = screen.getByRole("link", { name: "开发工具" }) + expect(link.querySelector(".sidebar__label")).not.toHaveClass("sr-only") + await user.hover(link) + expect(link).not.toHaveAttribute("data-state") + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument() + + link.focus() + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument() + }) + + it("收起时悬停分类图标显示分类名提示", async () => { + vi.stubGlobal("ResizeObserver", ResizeObserverStub) + const user = userEvent.setup() + render( + +
首页内容
+
, + ) + + const link = screen.getByRole("link", { name: "开发工具" }) + expect(link.querySelector(".sidebar__label")).toHaveClass("sr-only") + await user.hover(link) + expect(await screen.findByRole("tooltip")).toHaveTextContent("开发工具") + }) + + it("收起时键盘聚焦分类图标显示分类名提示", async () => { + vi.stubGlobal("ResizeObserver", ResizeObserverStub) + const user = userEvent.setup() + render( + +
首页内容
+
, + ) + + await user.tab() + await user.tab() + await user.tab() + await user.tab() + const link = screen.getByRole("link", { name: "开发工具" }) + expect(link).toHaveFocus() + expect(await screen.findByRole("tooltip")).toHaveTextContent("开发工具") + }) }) diff --git a/ts/web/nextjs/polaris/src/features/navigation/components/sidebar.tsx b/ts/web/nextjs/polaris/src/features/navigation/components/sidebar.tsx index ad9d21c..7cf7676 100644 --- a/ts/web/nextjs/polaris/src/features/navigation/components/sidebar.tsx +++ b/ts/web/nextjs/polaris/src/features/navigation/components/sidebar.tsx @@ -81,28 +81,30 @@ export function Sidebar({
{categories.map((category) => { const isActive = category.id === activeCategoryId + const categoryLink = ( + + + + {category.title} + + + ) - return ( + return collapsed ? ( - - - - - {category.title} - - - + {categoryLink} {category.title} + ) : ( + categoryLink ) })}
diff --git a/ts/web/nextjs/polaris/src/services/services.test.ts b/ts/web/nextjs/polaris/src/services/services.test.ts index 2be4b00..5ae266e 100644 --- a/ts/web/nextjs/polaris/src/services/services.test.ts +++ b/ts/web/nextjs/polaris/src/services/services.test.ts @@ -16,6 +16,7 @@ describe("静态数据 service", () => { "learning", "efficiency", "media", + "game-resources", "life", ]) expect(first).not.toBe(second) @@ -55,6 +56,7 @@ describe("静态数据 service", () => { "iconfont", "haowallpaper", "iodraw", + "pixiv", ]) expect(getLinksByCategory("life").map(({ id }) => id)).toEqual(["amap"]) })