From be329865b33c97f90989c25f7790d906f3ba6f91 Mon Sep 17 00:00:00 2001 From: zetaloop Date: Sun, 3 May 2026 05:46:24 +0800 Subject: [PATCH] refactor: drop unused local search catalog --- lib/search/search-catalog.ts | 189 ---------------- tests/search-catalog.test.ts | 417 ----------------------------------- 2 files changed, 606 deletions(-) delete mode 100644 lib/search/search-catalog.ts delete mode 100644 tests/search-catalog.test.ts diff --git a/lib/search/search-catalog.ts b/lib/search/search-catalog.ts deleted file mode 100644 index ca378b1..0000000 --- a/lib/search/search-catalog.ts +++ /dev/null @@ -1,189 +0,0 @@ -import type { SearchResponse, SearchResultItem, SearchSort } from "@/lib/search/types" -import type { Player, PlayerService, Shop } from "@/lib/types" - -export interface SearchCatalogParams { - q?: string - selectedGames?: string[] - min?: string - max?: string - onlyOnline?: boolean - minRating?: string - sort?: SearchSort - limit?: number - offset?: number -} - -export interface SearchCatalogData { - players: Player[] - shops: Shop[] - services: PlayerService[] -} - -type InternalResultItem = SearchResultItem & { __index: number } - -function minPriceFromServices(services: PlayerService[]) { - return services.length === 0 ? 0 : Math.min(...services.map((s) => s.price)) -} - -function unitFromCheapestService(services: PlayerService[]): PlayerService["unit"] { - if (services.length === 0) return "局" - return services.reduce((prev, curr) => (prev.price < curr.price ? prev : curr)).unit -} - -export function searchCatalog( - params: SearchCatalogParams, - data: SearchCatalogData, -): SearchResponse { - const q = params.q ?? "" - const query = q ? q.toLowerCase() : "" - const selectedGames = params.selectedGames ?? [] - - const min = params.min ?? "" - const max = params.max ?? "" - const minP = min ? Number(min) : 0 - const maxP = max ? Number(max) : Infinity - - const onlyOnline = params.onlyOnline ?? false - const minRating = Number(params.minRating ?? "0") - const sort = params.sort ?? "composite" - - const offset = Math.max(0, params.offset ?? 0) - const limit = Math.max(0, params.limit ?? 12) - - const shopDerivedById = new Map< - string, - { - minPrice: number - unit: PlayerService["unit"] - games: string[] - hasAvailable: boolean - } - >() - - for (const shop of data.shops) { - const shopPlayers = data.players.filter((player) => player.shopId === shop.id) - const playerIds = new Set(shopPlayers.map((player) => player.id)) - const shopServices = data.services.filter((service) => playerIds.has(service.playerId)) - const shopMinPrice = shopServices.length > 0 ? Math.min(...shopServices.map((s) => s.price)) : 0 - const shopUnit = shopServices.length > 0 ? unitFromCheapestService(shopServices) : "局" - const shopGames = [...new Set(shopServices.map((service) => service.gameName))] - const hasAvailable = shopPlayers.some((player) => player.status === "available") - - shopDerivedById.set(shop.id, { - minPrice: shopMinPrice, - unit: shopUnit, - games: shopGames, - hasAvailable, - }) - } - - let nextIndex = 0 - const results: InternalResultItem[] = [] - - for (const player of data.players) { - results.push({ - __index: nextIndex++, - type: "player", - player, - rating: player.rating, - orders: player.totalOrders, - minPrice: minPriceFromServices(player.services), - unit: unitFromCheapestService(player.services), - }) - } - - for (const shop of data.shops) { - const derived = shopDerivedById.get(shop.id) - if (!derived) continue - - results.push({ - __index: nextIndex++, - type: "shop", - shop, - rating: Number(shop.rating), - orders: shop.totalOrders, - minPrice: derived.minPrice, - unit: derived.unit, - games: derived.games, - hasAvailable: derived.hasAvailable, - }) - } - - const filtered = results.filter((item) => { - if (query) { - if (item.type === "player") { - const matchName = item.player.user.nickname.toLowerCase().includes(query) - const matchTags = item.player.tags.some((tag) => tag.toLowerCase().includes(query)) - const matchGames = item.player.games.some((game) => game.toLowerCase().includes(query)) - if (!matchName && !matchTags && !matchGames) return false - } else { - const matchName = item.shop.name.toLowerCase().includes(query) - const matchDescription = item.shop.description.toLowerCase().includes(query) - const matchGames = item.games.some((game) => game.toLowerCase().includes(query)) - if (!matchName && !matchDescription && !matchGames) return false - } - } - - if (selectedGames.length > 0) { - if (item.type === "player") { - const hasGame = item.player.games.some((game) => selectedGames.includes(game)) - if (!hasGame) return false - } else { - const hasGame = item.games.some((game) => selectedGames.includes(game)) - if (!hasGame) return false - } - } - - if (item.minPrice < minP) return false - if (max && item.minPrice > maxP) return false - - if (onlyOnline) { - if (item.type === "player") { - if (item.player.status !== "available") return false - } else { - if (!item.hasAvailable) return false - } - } - - if (item.type === "player") { - if (item.player.rating < minRating) return false - } else { - if (Number(item.shop.rating) < minRating) return false - } - - return true - }) - - filtered.sort((a, b) => { - let diff = 0 - switch (sort) { - case "rating": - diff = b.rating - a.rating - break - case "price_asc": - diff = a.minPrice - b.minPrice - break - case "price_desc": - diff = b.minPrice - a.minPrice - break - case "orders": - diff = b.orders - a.orders - break - case "composite": { - const scoreA = a.rating * Math.log10(a.orders + 1) - const scoreB = b.rating * Math.log10(b.orders + 1) - diff = scoreB - scoreA - break - } - default: - diff = 0 - } - - if (diff !== 0) return diff - return a.__index - b.__index - }) - - const total = filtered.length - const items = filtered.slice(offset, offset + limit).map(({ __index, ...item }) => item) - return { items, meta: { total, offset, limit } } -} diff --git a/tests/search-catalog.test.ts b/tests/search-catalog.test.ts deleted file mode 100644 index 647c03a..0000000 --- a/tests/search-catalog.test.ts +++ /dev/null @@ -1,417 +0,0 @@ -import type { SearchCatalogData } from "@/lib/search/search-catalog" -import { searchCatalog } from "@/lib/search/search-catalog" -import { describe, expect, it } from "vitest" - -const createdAt = "2025-01-01T00:00:00.000Z" - -const players: SearchCatalogData["players"] = [ - { - id: "1006", - user: { - id: "2006", - username: "u1006", - nickname: "Winter", - avatar: "/avatars/u1006.png", - role: "player", - createdAt, - }, - rating: 4.8, - totalOrders: 120, - completionRate: 0.98, - status: "available", - games: ["王者荣耀"], - services: [ - { - id: "s-1006-wzry", - playerId: "1006", - gameId: "g-wzry", - gameName: "王者荣耀", - title: "王者荣耀陪练", - description: "", - price: 30, - unit: "局", - availability: ["weekends"], - }, - ], - shopId: "3002", - gender: true, - tags: ["moba"], - }, - { - id: "1007", - user: { - id: "2007", - username: "u1007", - nickname: "u7", - avatar: "/avatars/u1007.png", - role: "player", - createdAt, - }, - rating: 4.0, - totalOrders: 20, - completionRate: 0.9, - status: "busy", - games: ["英雄联盟"], - services: [ - { - id: "s-1007-lol", - playerId: "1007", - gameId: "g-lol", - gameName: "英雄联盟", - title: "英雄联盟陪练", - description: "", - price: 18, - unit: "局", - availability: ["weekdays"], - }, - ], - shopId: "3003", - gender: true, - tags: [], - }, - { - id: "1008", - user: { - id: "2008", - username: "u1008", - nickname: "Ace", - avatar: "/avatars/u1008.png", - role: "player", - createdAt, - }, - rating: 4.7, - totalOrders: 80, - completionRate: 0.97, - status: "available", - games: ["CS2"], - services: [ - { - id: "s-1008-cs2", - playerId: "1008", - gameId: "g-cs2", - gameName: "CS2", - title: "CS2代练", - description: "", - price: 10, - unit: "局", - availability: ["weekends"], - }, - ], - shopId: "3001", - gender: false, - tags: ["fps"], - }, - { - id: "1009", - user: { - id: "2009", - username: "u1009", - nickname: "u9", - avatar: "/avatars/u1009.png", - role: "player", - createdAt, - }, - rating: 3.5, - totalOrders: 5, - completionRate: 0.85, - status: "offline", - games: ["英雄联盟"], - services: [ - { - id: "s-1009-lol", - playerId: "1009", - gameId: "g-lol", - gameName: "英雄联盟", - title: "英雄联盟陪练", - description: "", - price: 12, - unit: "局", - availability: ["weekends"], - }, - ], - shopId: "3003", - gender: true, - tags: [], - }, -] - -const shops: SearchCatalogData["shops"] = [ - { - id: "3001", - owner: { - id: "4001", - username: "owner3001", - nickname: "Owner 3001", - avatar: "/avatars/owner3001.png", - role: "owner", - createdAt, - }, - name: "CS2 Hub", - description: "", - rating: "4.6", - totalOrders: 300, - playerCount: 1, - commissionType: "fixed", - commissionValue: "0", - allowMultiShop: false, - allowIndependentOrders: false, - dispatchMode: "manual", - announcements: [], - templateConfig: { sections: [] }, - }, - { - id: "3002", - owner: { - id: "4002", - username: "owner3002", - nickname: "Owner 3002", - avatar: "/avatars/owner3002.png", - role: "owner", - createdAt, - }, - name: "Yuki Studio", - description: "", - rating: "4.2", - totalOrders: 50, - playerCount: 1, - commissionType: "fixed", - commissionValue: "0", - allowMultiShop: false, - allowIndependentOrders: false, - dispatchMode: "manual", - announcements: [], - templateConfig: { sections: [] }, - }, - { - id: "3003", - owner: { - id: "4003", - username: "owner3003", - nickname: "Owner 3003", - avatar: "/avatars/owner3003.png", - role: "owner", - createdAt, - }, - name: "Quiet Shop", - description: "", - rating: "3.8", - totalOrders: 10, - playerCount: 2, - commissionType: "fixed", - commissionValue: "0", - allowMultiShop: false, - allowIndependentOrders: false, - dispatchMode: "manual", - announcements: [], - templateConfig: { sections: [] }, - }, -] - -const services: SearchCatalogData["services"] = players.flatMap((p) => p.services) - -const data: SearchCatalogData = { - players, - shops, - services, -} - -describe("searchCatalog", () => { - describe("q matching (case-insensitive)", () => { - it("matches player nickname", () => { - const res = searchCatalog({ q: "winter", limit: 50 }, data) - const playerItems = res.items.filter((i) => i.type === "player") - expect(playerItems.some((i) => i.type === "player" && i.player.id === "1006")).toBe(true) - }) - - it("matches player nickname case-insensitively", () => { - const res = searchCatalog({ q: "WINTER", limit: 50 }, data) - const playerItems = res.items.filter((i) => i.type === "player") - expect(playerItems.some((i) => i.type === "player" && i.player.id === "1006")).toBe(true) - }) - - it("matches shop name", () => { - const res = searchCatalog({ q: "yuki", limit: 50 }, data) - const shopItems = res.items.filter((i) => i.type === "shop") - expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "3002")).toBe(true) - }) - - it("matches player game name", () => { - const res = searchCatalog({ q: "cs2", limit: 50 }, data) - const playerItems = res.items.filter((i) => i.type === "player") - // 1008 has CS2 in games - expect(playerItems.some((i) => i.type === "player" && i.player.id === "1008")).toBe(true) - }) - - it("matches shop derived games", () => { - const res = searchCatalog({ q: "cs2", limit: 50 }, data) - const shopItems = res.items.filter((i) => i.type === "shop") - expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "3001")).toBe(true) - }) - - it("returns all items when q is empty", () => { - const res = searchCatalog({ limit: 50 }, data) - expect(res.meta.total).toBe(players.length + shops.length) - }) - }) - - describe("selectedGames ANY-match", () => { - it("filters players by game", () => { - const res = searchCatalog({ selectedGames: ["CS2"], limit: 50 }, data) - const playerItems = res.items.filter((i) => i.type === "player") - expect(playerItems.every((i) => i.type === "player" && i.player.games.includes("CS2"))).toBe( - true, - ) - expect(playerItems.length).toBeGreaterThan(0) - }) - - it("filters shops by derived games from services", () => { - const res = searchCatalog({ selectedGames: ["CS2"], limit: 50 }, data) - const shopItems = res.items.filter((i) => i.type === "shop") - expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "3001")).toBe(true) - }) - - it("uses ANY-match (OR) for multiple games", () => { - const res = searchCatalog({ selectedGames: ["CS2", "王者荣耀"], limit: 50 }, data) - // Should include players with either game - expect(res.meta.total).toBeGreaterThan(0) - const playerItems = res.items.filter((i) => i.type === "player") - for (const item of playerItems) { - if (item.type === "player") { - expect(item.player.games.includes("CS2") || item.player.games.includes("王者荣耀")).toBe( - true, - ) - } - } - }) - }) - - describe("onlyOnline semantics", () => { - it("filters players by status === available", () => { - const res = searchCatalog({ onlyOnline: true, limit: 50 }, data) - const playerItems = res.items.filter((i) => i.type === "player") - for (const item of playerItems) { - if (item.type === "player") { - expect(item.player.status).toBe("available") - } - } - // u7 is busy, u9 is offline — they should be excluded - expect(playerItems.some((i) => i.type === "player" && i.player.id === "1007")).toBe(false) - expect(playerItems.some((i) => i.type === "player" && i.player.id === "1009")).toBe(false) - }) - - it("filters shops by hasAvailable (any player available)", () => { - const res = searchCatalog({ onlyOnline: true, limit: 50 }, data) - const shopItems = res.items.filter((i) => i.type === "shop") - for (const item of shopItems) { - if (item.type === "shop") { - expect(item.hasAvailable).toBe(true) - } - } - expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "3003")).toBe(false) - expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "3001")).toBe(true) - }) - }) - - describe("price min/max parsing", () => { - it("filters by min price", () => { - const res = searchCatalog({ min: "25", limit: 50 }, data) - for (const item of res.items) { - expect(item.minPrice).toBeGreaterThanOrEqual(25) - } - }) - - it("filters by max price", () => { - const res = searchCatalog({ max: "20", limit: 50 }, data) - for (const item of res.items) { - expect(item.minPrice).toBeLessThanOrEqual(20) - } - }) - - it("NaN min does not filter (min='abc' → minP=NaN)", () => { - const resAll = searchCatalog({ limit: 50 }, data) - const resNaN = searchCatalog({ min: "abc", limit: 50 }, data) - // NaN comparison: item.minPrice < NaN is always false, so nothing is excluded by min - // But items with minPrice < 0 would pass too — effectively no min filter - expect(resNaN.meta.total).toBe(resAll.meta.total) - }) - - it("NaN max does not filter (max='abc' → maxP=NaN)", () => { - const resAll = searchCatalog({ limit: 50 }, data) - const resNaN = searchCatalog({ max: "abc", limit: 50 }, data) - // max is truthy ("abc") so the max filter runs, but item.minPrice > NaN is always false - expect(resNaN.meta.total).toBe(resAll.meta.total) - }) - }) - - describe("sort options", () => { - it("sorts by rating descending", () => { - const res = searchCatalog({ sort: "rating", limit: 50 }, data) - for (let i = 1; i < res.items.length; i++) { - expect(res.items[i - 1].rating).toBeGreaterThanOrEqual(res.items[i].rating) - } - }) - - it("sorts by orders descending", () => { - const res = searchCatalog({ sort: "orders", limit: 50 }, data) - for (let i = 1; i < res.items.length; i++) { - expect(res.items[i - 1].orders).toBeGreaterThanOrEqual(res.items[i].orders) - } - }) - - it("sorts by price ascending", () => { - const res = searchCatalog({ sort: "price_asc", limit: 50 }, data) - for (let i = 1; i < res.items.length; i++) { - expect(res.items[i - 1].minPrice).toBeLessThanOrEqual(res.items[i].minPrice) - } - }) - - it("sorts by price descending", () => { - const res = searchCatalog({ sort: "price_desc", limit: 50 }, data) - for (let i = 1; i < res.items.length; i++) { - expect(res.items[i - 1].minPrice).toBeGreaterThanOrEqual(res.items[i].minPrice) - } - }) - - it("sorts by composite formula descending", () => { - const res = searchCatalog({ sort: "composite", limit: 50 }, data) - const scores = res.items.map((i) => i.rating * Math.log10(i.orders + 1)) - for (let i = 1; i < scores.length; i++) { - expect(scores[i - 1]).toBeGreaterThanOrEqual(scores[i]) - } - }) - - it("uses stable tie-breaker by insertion order", () => { - // Create data with identical ratings to test tie-breaking - const res = searchCatalog({ sort: "rating", limit: 50 }, data) - // Items with same rating should preserve insertion order (players before shops) - const sameRating = res.items.filter((i) => i.rating === res.items[0].rating) - if (sameRating.length > 1) { - // They should be in original insertion order - expect(sameRating.length).toBeGreaterThan(0) - } - }) - }) - - describe("pagination offset/limit", () => { - it("returns correct meta", () => { - const res = searchCatalog({ limit: 2, offset: 0 }, data) - expect(res.meta.limit).toBe(2) - expect(res.meta.offset).toBe(0) - expect(res.items.length).toBe(2) - expect(res.meta.total).toBe(players.length + shops.length) - }) - - it("offset skips items", () => { - const all = searchCatalog({ limit: 50 }, data) - const page2 = searchCatalog({ limit: 2, offset: 2 }, data) - expect(page2.items[0]).toEqual(all.items[2]) - expect(page2.items[1]).toEqual(all.items[3]) - }) - - it("offset beyond total returns empty items", () => { - const res = searchCatalog({ limit: 10, offset: 100 }, data) - expect(res.items.length).toBe(0) - expect(res.meta.total).toBeGreaterThan(0) - }) - }) -})