- 数据模型: vote/review/submission/claim/use_case/audience/collection/event (+tool 冗余统计列) - 社区: 投票(限流+原子toggle+API) / 评论(评分+优缺点+Helpful+聚合回写) / 提交(重复检测+审批发布) / 认领(Owner白名单编辑) - Ranking Engine: trending/popular/most-voted/most-saved/highest-rated/new/editors-choice (纯函数可测) - 发现体系: Use Case + Audience + Collection 三维发现 / /ai-tools-for / /best SEO 页 - Programmatic SEO: 页面生成器 / sitemap 270条 / AggregateRating/FAQPage/Breadcrumb/ItemList schema / alternatives + vs 对比页 - Analytics: event 事件流 + 外链跳转埋点 + /admin/analytics CTR 与搜索词分析 - Admin: submissions/claims/reviews/discovery/vote-audit/analytics 六个新后台 + 仪表盘扩展 - 主题系统: themes registry + 五套站点 (classic/product/editorial/niche/minimal) 构建期注入 - 认证: GitHub OAuth (env门控) + 登录回跳 redirect - i18n: 新增 7 个命名空间中英文案 (546 键一致) - 测试: bun test 32 用例 (ranking/slug/去重/SEO生成器) + Playwright e2e 脚本 - 种子: 14分类/16用例/9受众/40工具/6用户/投票/评论/6合集/7718事件
167 lines
4.7 KiB
TypeScript
167 lines
4.7 KiB
TypeScript
import { describe, test, expect } from "bun:test";
|
||
import {
|
||
trendingScore,
|
||
bayesianRating,
|
||
timeDecayFactor,
|
||
popularScore,
|
||
parseToolSlugRoute,
|
||
normalizeDomain,
|
||
slugify,
|
||
type ToolSignals,
|
||
} from "../src/lib/ranking/score";
|
||
|
||
// ========== Trending 评分 ==========
|
||
|
||
describe("trendingScore", () => {
|
||
const baseSignals: ToolSignals = {
|
||
votesInWindow: 10,
|
||
favoritesInWindow: 5,
|
||
reviewsInWindow: 2,
|
||
viewsInWindow: 100,
|
||
ageHours: 24,
|
||
};
|
||
|
||
test("互动越多分数越高", () => {
|
||
const more: ToolSignals = { ...baseSignals, votesInWindow: 50 };
|
||
expect(trendingScore(more)).toBeGreaterThan(trendingScore(baseSignals));
|
||
});
|
||
|
||
test("无互动分数为 0(避免空榜填充)", () => {
|
||
const empty: ToolSignals = {
|
||
votesInWindow: 0,
|
||
favoritesInWindow: 0,
|
||
reviewsInWindow: 0,
|
||
viewsInWindow: 0,
|
||
ageHours: 100,
|
||
};
|
||
expect(trendingScore(empty)).toBe(0);
|
||
});
|
||
|
||
test("新工具衰减慢:相同互动下,新工具 > 老工具(防止老工具永久霸榜)", () => {
|
||
const fresh = trendingScore({ ...baseSignals, ageHours: 24 });
|
||
const old = trendingScore({ ...baseSignals, ageHours: 24 * 365 });
|
||
expect(fresh).toBeGreaterThan(old);
|
||
});
|
||
|
||
test("评论权重高于投票", () => {
|
||
const reviewHeavy = trendingScore({ ...baseSignals, reviewsInWindow: 3, votesInWindow: 0 });
|
||
const voteHeavy = trendingScore({ ...baseSignals, reviewsInWindow: 0, votesInWindow: 3 });
|
||
expect(reviewHeavy).toBeGreaterThan(voteHeavy);
|
||
});
|
||
});
|
||
|
||
// ========== 贝叶斯评分 ==========
|
||
|
||
describe("bayesianRating", () => {
|
||
test("评论极少时被先验拉低", () => {
|
||
const oneFiveStar = bayesianRating(5, 1, 4.0);
|
||
expect(oneFiveStar).toBeLessThan(5);
|
||
});
|
||
|
||
test("评论很多时接近观测均值", () => {
|
||
const manyReviews = bayesianRating(4.8, 200, 4.0);
|
||
expect(manyReviews).toBeGreaterThan(4.7);
|
||
expect(manyReviews).toBeLessThanOrEqual(4.8);
|
||
});
|
||
|
||
test("样本相同的两工具,均值高者胜", () => {
|
||
expect(bayesianRating(4.8, 10, 4.0)).toBeGreaterThan(bayesianRating(4.2, 10, 4.0));
|
||
});
|
||
|
||
test("无评论返回 0", () => {
|
||
expect(bayesianRating(5, 0, 4)).toBe(0);
|
||
});
|
||
});
|
||
|
||
// ========== 时间衰减 / Popular ==========
|
||
|
||
describe("timeDecayFactor", () => {
|
||
test("未衰减为 1,随时间单调递减", () => {
|
||
expect(timeDecayFactor(0)).toBe(1);
|
||
expect(timeDecayFactor(100)).toBeLessThan(1);
|
||
expect(timeDecayFactor(200)).toBeLessThan(timeDecayFactor(100));
|
||
});
|
||
|
||
test("半衰期处恰好为 0.5", () => {
|
||
expect(timeDecayFactor(168, 168)).toBeCloseTo(0.5);
|
||
});
|
||
});
|
||
|
||
describe("popularScore", () => {
|
||
test("全量互动加权求和", () => {
|
||
expect(popularScore(10, 10, 10, 1000)).toBe(10 * 2 + 10 * 1.5 + 10 * 3 + 1000 * 0.1);
|
||
});
|
||
});
|
||
|
||
// ========== 复合路由解析(Alternatives / Comparison SEO) ==========
|
||
|
||
describe("parseToolSlugRoute", () => {
|
||
test("普通工具", () => {
|
||
expect(parseToolSlugRoute("chatgpt")).toEqual({ kind: "tool", slug: "chatgpt" });
|
||
});
|
||
|
||
test("alternatives 页", () => {
|
||
expect(parseToolSlugRoute("chatgpt-alternatives")).toEqual({
|
||
kind: "alternatives",
|
||
base: "chatgpt",
|
||
});
|
||
});
|
||
|
||
test("vs 对比页", () => {
|
||
expect(parseToolSlugRoute("cursor-vs-windsurf")).toEqual({
|
||
kind: "comparison",
|
||
a: "cursor",
|
||
b: "windsurf",
|
||
});
|
||
});
|
||
|
||
test("名称中带连字符的对比页", () => {
|
||
expect(parseToolSlugRoute("github-copilot-vs-tabnine")).toEqual({
|
||
kind: "comparison",
|
||
a: "github-copilot",
|
||
b: "tabnine",
|
||
});
|
||
});
|
||
});
|
||
|
||
// ========== 域名归一化(重复工具检测) ==========
|
||
|
||
describe("normalizeDomain", () => {
|
||
test("https URL 提取主机", () => {
|
||
expect(normalizeDomain("https://chatgpt.com/")).toBe("chatgpt.com");
|
||
});
|
||
|
||
test("去 www", () => {
|
||
expect(normalizeDomain("www.anthropic.com")).toBe("anthropic.com");
|
||
});
|
||
|
||
test("带路径与查询参数", () => {
|
||
expect(normalizeDomain("https://openai.com/chatgpt?ref=x")).toBe("openai.com");
|
||
});
|
||
|
||
test("子域保留(重复检测按主机比较)", () => {
|
||
expect(normalizeDomain("http://chat.openai.com")).toBe("chat.openai.com");
|
||
});
|
||
|
||
test("非法输入返回 null", () => {
|
||
expect(normalizeDomain("")).toBeNull();
|
||
expect(normalizeDomain("not a url")).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ========== slug 生成 ==========
|
||
|
||
describe("slugify", () => {
|
||
test("空格转连字符 + 小写", () => {
|
||
expect(slugify("Best AI Writing Tools")).toBe("best-ai-writing-tools");
|
||
});
|
||
|
||
test("去除特殊字符", () => {
|
||
expect(slugify("DALL·E 3!")).toBe("dalle-3");
|
||
});
|
||
|
||
test("合并连续连字符", () => {
|
||
expect(slugify("a -- b")).toBe("a-b");
|
||
});
|
||
});
|