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", 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", 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", 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", 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: true, 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: true, 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: true, 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) }) }) })