Files
Genarrative/server-rs/crates/module-game-distribution/src/theme.rs
T
suzmii e401963d3a
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
fix(游戏共创): 非法游标回归稳定码 THEME_INVALID_CURSOR(契约码此前在 HTTP 面不可达)
端到端实测(54 项里唯一红的那条):`GET /api/game-distribution/themes?cursor=abc` 回 400,但
`error.code = "BAD_REQUEST"`——技术方案 §3.4 与主题 e2e 脚本承诺的稳定码 `THEME_INVALID_CURSOR`
在 HTTP 面上拿不到,客户端只能去匹配中文文案。

根因:`map_spacetime_error` 的 `THEME_` 分支前置条件是「消息含 `THEME_`」,而模块侧游标解析只回
中文串「主题列表游标格式无效」⇒ 落兜底分支成 400 + 通用 `BAD_REQUEST`;契约常量与那条映射分支
因此是死代码。且 api-server 原有单测**明确要求**文案不含 `THEME_`(口径留了两套)。

口径二选一,选**让码可达**(模块产出前缀),删掉「要求不含 `THEME_`」的断言:

- `module-game-distribution/src/errors.rs`:新增 6 个主题码常量
  `GAME_DISTRIBUTION_THEME_{NOT_FOUND,BAD_REQUEST,INVALID_CURSOR,IDEMPOTENCY_CONFLICT,MEMBER_NOT_ROOT,MEMBER_GAME_NOT_FOUND}_CODE`,
  并把 `Display` 从字面量改为**复用同一批常量**(消灭「常量改了、`Display` 没改」这类只有真栈才发现的漂移)。
- `theme.rs`:`parse_game_distribution_theme_cursor` 产出 `THEME_INVALID_CURSOR: 主题列表游标格式无效`;
  文档注释更新为「只保留 `FORK_` 这个排在 `THEME_` 分支之前的子串禁忌」(其余子串已抢不到)。
- `lib.rs`:导出新增的 6 个码常量。
- 测试:
  · `domain.rs::theme_errors_are_prefixed_with_their_machine_code` 改为**枚举全部 6 个主题码**(含唯一
    不走领域变体的游标路径——正是当初漏掉它的原因),并加「任何新增码都必须进这张表」的覆盖完整性断言;
  · `theme.rs::invalid_cursor_message_stays_in_the_bad_request_bucket` 增加「必须以稳定码开头」;
  · api-server 新增 `theme_error_codes_are_reachable_from_module_messages`:用**模块真实产出的消息**
    逐码断言 `(状态码, 稳定码)` 可达,并比对模块常量与 `shared-contracts` 常量是同一组字符串;
  · api-server `theme_list_invalid_cursor_maps_to_bad_request` 改为断言映射出 `THEME_INVALID_CURSOR`,
    只把 `FORK_` 留在禁忌列表里(并写明理由)。

门禁:`cargo test -p module-game-distribution` **100 passed**;`cargo test -p api-server game_distribution`
**86 passed**;`cargo check --all-targets` 0;DTO parity 58 组 / 10 构建器;`check:encoding` 0;
`cargo fmt --all -- --check` 0;`git diff --check` 0。
2026-10-06 03:50:05 +08:00

791 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 共创主题的纯函数规则。
//!
//! 表与事务在 `spacetime-module::game_distribution`;这里只放能被不起 SpacetimeDB 的单测钉住的
//! 规则:确定性成员主键、「主题 / 成员是否进入公开投影」的可见性判定、「成员只允许根」的约束,
//! 以及公开主题列表与主题内成员的排序、游标编解码与切页。
use crate::collection::game_distribution_collection_visible;
/// 主题状态:`draft`(未发布)。
pub const GAME_DISTRIBUTION_THEME_STATUS_DRAFT: &str = "draft";
/// 主题状态:`published`(公开可见)。
pub const GAME_DISTRIBUTION_THEME_STATUS_PUBLISHED: &str = "published";
/// 主题状态:`archived`(已归档下架,行保留)。
pub const GAME_DISTRIBUTION_THEME_STATUS_ARCHIVED: &str = "archived";
/// 主题状态白名单:`draft` / `published` / `archived`。
///
/// 可见性判定、后台列表的 `status` 过滤白名单与 DTO 校验**共用这一份**(另加后台过滤里的
/// `all`),因此新增一个状态只需要改这里一处;漏改会被「状态白名单」单测与 DTO parity 抓住。
pub const GAME_DISTRIBUTION_THEME_STATUSES: [&str; 3] = [
GAME_DISTRIBUTION_THEME_STATUS_DRAFT,
GAME_DISTRIBUTION_THEME_STATUS_PUBLISHED,
GAME_DISTRIBUTION_THEME_STATUS_ARCHIVED,
];
/// 状态是否是白名单里的合法值。
///
/// 非法值由 api-server 映射 400(`THEME_BAD_REQUEST`),不落到 axum 默认的 422 纯文本。
pub fn game_distribution_theme_status_valid(status: &str) -> bool {
GAME_DISTRIBUTION_THEME_STATUSES.contains(&status)
}
/// 后台列表 `status` 过滤里代表「全量」的取值。
///
/// 与 `GAME_DISTRIBUTION_THEME_STATUSES` 并列而不是塞进白名单:`all` 是**过滤视图**的取值,
/// 不是主题行上的合法状态(没有任何一行的 `status` 会是 `all`);混进去会让
/// [`game_distribution_theme_status_valid`] 反过来接受一个落不进表的字符串。
pub const GAME_DISTRIBUTION_THEME_ADMIN_STATUS_ALL: &str = "all";
/// 后台列表 `status` 过滤值是否合法:`all` + 三个真实状态。
///
/// 单一实现,避免「api-server 放行、事务里当全量」这类两侧各写一遍白名单的漂移;非法值由
/// api-server 映射 400(`THEME_BAD_REQUEST`)。
pub fn game_distribution_theme_admin_status_filter_valid(status: &str) -> bool {
status == GAME_DISTRIBUTION_THEME_ADMIN_STATUS_ALL
|| game_distribution_theme_status_valid(status)
}
/// 主题名长度上限(字符数,不是字节数)。
///
/// 设计文档(§3.10.8 / 里程碑 E)只写了「`name` 必填非空」,没有给上限。这里取 40:主题名与
/// 作品标题(`title` 40)处在同一层级——都是卡片上一行标题——沿用同一量级可以让「一屏能放下
/// 什么」在两条列表里一致;再长会被前端截断,运营写了也白写。
pub const GAME_DISTRIBUTION_THEME_NAME_MAX_CHARS: usize = 40;
/// 主题简介长度上限(字符数)。
///
/// 设计文档没写上限。取 200:主题简介是主题页头部的一段引导文案,比作品简介(120)略长
/// (主题是运营叙事,需要多说一句「这一组作品为什么放在一起」),又远小于作品详细介绍
/// (2000)——它不是正文,多出的字数只会把成员清单挤出首屏。
pub const GAME_DISTRIBUTION_THEME_SUMMARY_MAX_CHARS: usize = 200;
/// 主题角标长度上限(字符数)。
///
/// 设计文档没写上限。取 16:角标是卡片角上的短标签(「精选」「编辑推荐」「新春」),16 个字符
/// 足够表达一个标签,同时短到不可能被当成第二段正文——真要写长文案的位置是 `summary`。
pub const GAME_DISTRIBUTION_THEME_BADGE_MAX_CHARS: usize = 16;
/// 后台主题文本字段的校验:合法返回 `None`,否则返回可直接拼进 `THEME_BAD_REQUEST:` 的原因。
///
/// 规则本身(长度上限、空名)只写在这里一处:api-server 与事务都调它,因此「HTTP 挡住的」
/// 与「落库挡住的」永远是同一套判据。三个字段都先 `trim` 再判定:一串空格与空名同罪,调用方
/// 忘掉 trim 也不会把「只有空白的主题名」写进库(长度也按 trim 后的字符数算,尾部空格不占额度)。
pub fn game_distribution_theme_text_violation(
name: &str,
summary: &str,
badge: &str,
) -> Option<&'static str> {
let name = name.trim();
let summary = summary.trim();
let badge = badge.trim();
let name_chars = name.chars().count();
if name_chars == 0 {
return Some("主题名不能为空");
}
if name_chars > GAME_DISTRIBUTION_THEME_NAME_MAX_CHARS {
return Some("主题名不能超过 40 个字符");
}
if summary.chars().count() > GAME_DISTRIBUTION_THEME_SUMMARY_MAX_CHARS {
return Some("主题简介不能超过 200 个字符");
}
if badge.chars().count() > GAME_DISTRIBUTION_THEME_BADGE_MAX_CHARS {
return Some("主题角标不能超过 16 个字符");
}
None
}
/// 主题是否应出现在公开投影(公开列表 / 公开详情 / 作品详情的 `themes`)里。
///
/// 等价于 `status == "published"`:`draft` 与 `archived` 一律不可见(后台仍可见全量)。与
/// [`game_distribution_theme_status_valid`] 分开是刻意的——「值合法」与「对外可见」是两条不同
/// 的规则,把两者混成一个函数会让「归档」这种合法但不可见的状态无从表达。
pub fn game_distribution_theme_public_visible(status: &str) -> bool {
status == GAME_DISTRIBUTION_THEME_STATUS_PUBLISHED
}
/// 主题成员主键:`{theme_id}:{root_game_id}`。
///
/// 与 `game_distribution_collection_id`(收藏)同一写法:平台签发的两个标识用冒号拼成确定性 ID。
/// `theme_id` 由本领域生成(`theme-*`)、`root_game_id` 也是(`game-*`),两者都不含 `:`,
/// 因此组合是单射。
///
/// 用确定性主键而不是随机 ID,是为了让**去重由结构保证,而不是由流程保证**:同一 (主题, 根)
/// 只可能有一行,这一点由主键约束本身成立。也正因为主键已经是那条约束,这张表**不需要**再建
/// `(theme_id, root_game_id)` 唯一索引——两条具名索引只服务两个读取方向(按主题取成员、
/// 按根反查所属主题)。
pub fn game_distribution_theme_member_id(theme_id: &str, root_game_id: &str) -> String {
format!("{theme_id}:{root_game_id}")
}
/// 成员(根)是否应出现在公开投影里。
///
/// 只委托 [`game_distribution_collection_visible`](收藏)的同一条口径:未软删除、可见性为
/// `published`、且存在当前公开版本(`has_public_version` 由调用方按 `public_game_distribution_version`
/// 命中给出)。**不新写第二个同义判定**:公开目录、收藏、主题成员三条路径共用一条规则,就不可能
/// 出现「收藏列表看得见、主题页看不见」这类漂移。
///
/// 判定只在**投影时**发生,成员行本身不因此被删除:作品下架后该行只是不再出现在响应里,
/// 作品重新公开后同一行会再次出现。
pub fn game_distribution_theme_member_visible(
is_deleted: bool,
is_published: bool,
has_public_version: bool,
) -> bool {
game_distribution_collection_visible(is_deleted, is_published, has_public_version)
}
/// 目标作品能否作为主题成员:**只有根作品**(没有血缘行,即第 0 代)。
///
/// 事实(该 `game_id` 是否存在血缘行)由调用方用血缘点查给出——`game_distribution_lineage()
/// .game_id().find(&game_id)` 命中即有行 ⇒ 非根,一律拒绝(`409 THEME_MEMBER_NOT_ROOT`)。
/// 纯函数只表达规则本身,不持有 `ReducerContext`。
///
/// 为什么不许任意代作品入主题:主题页要呈现的是**作品树**,而 `/games/{id}/lineage` 是**按根
/// 聚合**的。若允许子作品也入主题,同一棵树会在主题页里出现多次(子作品自己一条、它的根又一条),
/// 去重与「高亮当前节点」都会打架。要挂就挂它所属的根。
pub fn game_distribution_theme_root_acceptable(has_lineage_row: bool) -> bool {
!has_lineage_row
}
/// 公开主题列表一页的默认条数。
///
/// 与 `/my-collections`(收录列表)取同一个数值:列表页是卡片网格,一屏放得下 20 张。
pub const GAME_DISTRIBUTION_THEME_PAGE_LIMIT_DEFAULT: u32 = 20;
/// 公开主题列表单次响应回传的条数上限。
///
/// 与 `/my-collections` 同一数值与同一处理方式:超界一律**截断**而不是报错,客户端拿到的是一个
/// 完整页,而不是一个需要重试的错误。同时它也是主题详情成员清单的响应体积约束。
pub const GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX: u32 = 50;
/// 请求里的 `limit` → 实际页大小:`0` 取默认,超界截断到上限。
///
/// 抽成独立函数是为了让 api-server 记日志的「生效条数」与模块真正使用的页大小**同源**:
/// 两处各自写一遍 `if == 0 ... min(...)` 迟早会漂移。
pub fn game_distribution_theme_page_limit(limit: u32) -> usize {
let resolved = if limit == 0 {
GAME_DISTRIBUTION_THEME_PAGE_LIMIT_DEFAULT
} else {
limit.min(GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX)
};
resolved as usize
}
/// 主题公开列表里的一条:游标 / 排序所需的两个键 + 调用方自己的负载。
///
/// 泛型 `payload` 让「排序键」与「这一页要回传的东西」绑在同一个值上,纯函数切页时整条值一起
/// 移动,因此不可能出现「按 A 排序、按 B 切页」的错位;模块侧把负载填成主题行,测试里填成
/// 任意轻量值。
#[derive(Clone, Debug, PartialEq)]
pub struct GameDistributionThemePageItem<T> {
/// 主题主键 `theme-*`;主题表内唯一,因此可作全序兜底键。
pub theme_id: String,
/// 主题创建时间(Unix 微秒),主排序键。
pub created_at_micros: i64,
pub payload: T,
}
/// 主题列表游标编码:`{created_at_micros}:{theme_id}`。
///
/// 与后台列表、收藏列表同一套 `"{micros}:{id}"` 惯例。`theme_id` 由本领域签发(`theme-*`)、
/// **不含冒号**,因此这里比收藏列表更简单;解析仍只切**第一个**冒号,格式对将来更长的 ID
/// 也不会退化。
pub fn encode_game_distribution_theme_cursor(created_at_micros: i64, theme_id: &str) -> String {
format!("{created_at_micros}:{theme_id}")
}
/// 主题列表游标解析;格式非法时返回带**稳定码**的 `Err`:`THEME_INVALID_CURSOR: …`。
///
/// 文案必须以码开头(`"{CODE}: {中文说明}"`):api-server 的 `THEME_` 映射分支要求消息含 `THEME_`
/// 且按冒号前的码查状态码表,少了前缀就静默退化成兜底的 400 + 通用 `BAD_REQUEST`,客户端只能去
/// 匹配中文——这正是本项目出过一次的真缺陷(游标错误不是领域变体,只是裸字符串,`Display` 的
/// 前缀测试覆盖不到它,所以另有一条测试专门枚举全部主题码)。
///
/// 仍然刻意避开的中文子串:只保留了**排在 `THEME_` 分支之前**的 `FORK_` 不能出现(否则会被
/// `FORK_` 分支抢先映射);「不存在」「状态」等子串不再有害——`THEME_` 分支先命中,码由前缀决定。
pub fn parse_game_distribution_theme_cursor(value: &str) -> Result<(i64, String), String> {
let invalid = || {
format!(
"{}: 主题列表游标格式无效",
crate::GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE
)
};
let (micros, theme_id) = value.split_once(':').ok_or_else(invalid)?;
let micros = micros.parse::<i64>().map_err(|_| invalid())?;
if theme_id.is_empty() {
return Err(invalid());
}
Ok((micros, theme_id.to_string()))
}
/// 公开主题列表的稳定排序:`created_at` **倒序** + `theme_id` **升序**兜底。
///
/// 为什么这个比较器是**全序**:`created_at` 相同的两条必须还能分出先后,否则同一页边界上的项
/// 会在翻页时重复或漏掉。`theme_id` 是主键、唯一,因此 `(created_at_micros, theme_id)` 唯一
/// 决定顺序 —— 相同 `created_at` 不会被吞掉。
///
/// 作品详情的 `themes` 增量用**同一份**比较器,避免「列表一种顺序、详情另一种顺序」的第二套语义。
pub fn sort_public_themes<T>(
mut items: Vec<GameDistributionThemePageItem<T>>,
) -> Vec<GameDistributionThemePageItem<T>> {
items.sort_by(|left, right| {
right
.created_at_micros
.cmp(&left.created_at_micros)
.then_with(|| left.theme_id.cmp(&right.theme_id))
});
items
}
/// 公开主题列表分页:按上面的比较器排序、跳过游标之前的位置、取 `limit` 条,并返回下一页游标
/// (没有下一页时为 `None`)。
///
/// 调用方必须**先**按可见性过滤、**再**把结果交给本函数:游标位置定义在已过滤的序列上。
/// 反过来先把未过滤序列切页、再逐页过滤,会让被滤掉的行凭空占掉名额,并使下一页的游标指回
/// 过滤前的序列——每翻一页都漏掉自己的若干条(与 `/my-collections` 同一条纪律)。
pub fn page_public_themes<T>(
items: Vec<GameDistributionThemePageItem<T>>,
cursor: Option<(i64, String)>,
limit: u32,
) -> (Vec<T>, Option<String>) {
let limit = game_distribution_theme_page_limit(limit);
let mut items = sort_public_themes(items);
if let Some((cursor_micros, cursor_id)) = cursor.as_ref() {
// 严格「在游标之后」:时间更早,或时间相同且 theme_id 更大(升序方向)。
items.retain(|item| {
item.created_at_micros < *cursor_micros
|| (item.created_at_micros == *cursor_micros
&& item.theme_id.as_str() > cursor_id.as_str())
});
}
// 多取一条判断是否还有下一页;游标只指向这一页真正回传的最后一条。
let has_more = items.len() > limit;
items.truncate(limit);
let next_cursor = if has_more {
items.last().map(|item| {
encode_game_distribution_theme_cursor(item.created_at_micros, item.theme_id.as_str())
})
} else {
None
};
(
items.into_iter().map(|item| item.payload).collect(),
next_cursor,
)
}
/// 主题内成员的一条:排序所需两个键 + 调用方自己的负载(与主题列表同一写法)。
#[derive(Clone, Debug, PartialEq)]
pub struct GameDistributionThemeMemberPageItem<T> {
/// 成员主键 `{theme_id}:{root_game_id}`;同一主题内唯一,因此可作全序兜底键。
pub member_id: String,
/// 运营权重,主排序键;**允许重复**(重复由 `member_id` 兜底成全序)。
pub sort_order: i64,
pub payload: T,
}
/// 主题内成员的稳定排序:`sort_order` **升序** + `member_id` **升序**兜底。
///
/// 为什么需要兜底键:运营完全可能给多个成员填同一个 `sort_order`(「靠前的这几个先放一起」)。
/// 若比较器在 `sort_order` 相同时不稳定,同一页边界上的成员会在翻页 / 渲染时重复或漏掉。
/// `member_id` 是主键、在同一主题内唯一,因此 `(sort_order, member_id)` 唯一决定顺序。
pub fn sort_theme_members<T>(
mut items: Vec<GameDistributionThemeMemberPageItem<T>>,
) -> Vec<GameDistributionThemeMemberPageItem<T>> {
items.sort_by(|left, right| {
left.sort_order
.cmp(&right.sort_order)
.then_with(|| left.member_id.cmp(&right.member_id))
});
items
}
/// 主题详情成员清单:排序后取前 `limit` 条负载。
///
/// `limit` 由调用方显式给出(主题详情用 [`GAME_DISTRIBUTION_THEME_PAGE_LIMIT_MAX`] 约束单次响应
/// 体积),`0` 表示该次不回传成员。可见性过滤同样必须发生在**调用本函数之前**(游标/截断位置
/// 定义在已过滤序列上,理由与主题列表一致)。
pub fn page_theme_members<T>(
items: Vec<GameDistributionThemeMemberPageItem<T>>,
limit: usize,
) -> Vec<T> {
let mut items = sort_theme_members(items);
items.truncate(limit);
items.into_iter().map(|item| item.payload).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn member_id_is_deterministic_and_pair_specific() {
assert_eq!(
game_distribution_theme_member_id("theme_a", "game_a"),
"theme_a:game_a"
);
// 同一对组合永远得到同一个键(重复 upsert 只留一行、成员幂等删除都依赖这一点)。
assert_eq!(
game_distribution_theme_member_id("theme_a", "game_a"),
game_distribution_theme_member_id("theme_a", "game_a")
);
// 主键派生必须对「主题」「根」两个维度都敏感:漏掉任一维度,不同主题 / 不同根的成员就会
// 共用同一行而互相覆盖(这是结构防重的前提)。
assert_ne!(
game_distribution_theme_member_id("theme_a", "game_a"),
game_distribution_theme_member_id("theme_b", "game_a")
);
assert_ne!(
game_distribution_theme_member_id("theme_a", "game_a"),
game_distribution_theme_member_id("theme_a", "game_b")
);
}
/// 状态白名单与公开可见性是**两条**不同的规则:`archived` 合法但不可见。
#[test]
fn status_whitelist_and_public_visibility_are_separate_rules() {
for status in GAME_DISTRIBUTION_THEME_STATUSES {
assert!(game_distribution_theme_status_valid(status), "{status}");
}
for status in ["", "PUBLISHED", "deleted", "hidden", " all"] {
assert!(!game_distribution_theme_status_valid(status), "{status}");
}
// 只有 published 公开可见;draft 与 archived 都不可见,但它们的值仍然合法。
assert!(game_distribution_theme_public_visible(
GAME_DISTRIBUTION_THEME_STATUS_PUBLISHED
));
assert!(!game_distribution_theme_public_visible(
GAME_DISTRIBUTION_THEME_STATUS_DRAFT
));
assert!(!game_distribution_theme_public_visible(
GAME_DISTRIBUTION_THEME_STATUS_ARCHIVED
));
assert!(game_distribution_theme_status_valid(
GAME_DISTRIBUTION_THEME_STATUS_ARCHIVED
));
// 非法值当然也不可见(不是「非 published 一律可见」的口子)。
assert!(!game_distribution_theme_public_visible("published "));
}
/// 成员可见性四组合 + 「未删已公开但无当前公开版本」必须 false;并与收藏口径**逐格相等**。
///
/// 最后一组断言是这条「委托而不是复制」的守门人:将来若只改收藏判定而忘了成员,或反之,
/// 这个测试会红。
#[test]
fn member_visibility_matches_the_collection_ruling_cell_by_cell() {
assert!(game_distribution_theme_member_visible(false, true, true));
assert!(!game_distribution_theme_member_visible(false, false, true));
assert!(!game_distribution_theme_member_visible(true, true, true));
assert!(!game_distribution_theme_member_visible(false, true, false));
for is_deleted in [false, true] {
for is_published in [false, true] {
for has_public_version in [false, true] {
assert_eq!(
game_distribution_theme_member_visible(
is_deleted,
is_published,
has_public_version
),
game_distribution_collection_visible(
is_deleted,
is_published,
has_public_version
),
"成员与收藏必须共用同一条公开可读口径(deleted={is_deleted}, published={is_published}, version={has_public_version})"
);
}
}
}
}
/// 「成员下架 → 投影跳过 → 重新公开 → 自动回来」:行不变,只有判定翻转。
#[test]
fn member_row_survives_unpublish_and_returns_after_republish() {
assert!(game_distribution_theme_member_visible(false, true, true));
assert!(!game_distribution_theme_member_visible(false, false, true));
assert!(game_distribution_theme_member_visible(false, true, true));
}
/// 成员只允许根:`has_lineage_row == true`(非根)⇒ 拒绝;无血缘行 ⇒ 接受。
#[test]
fn only_root_games_are_acceptable_members() {
assert!(game_distribution_theme_root_acceptable(false));
assert!(!game_distribution_theme_root_acceptable(true));
}
/// 后台过滤白名单 = `all` + 三个真实状态;`all` 自己**不是**能落进主题行的状态。
#[test]
fn admin_status_filter_accepts_all_plus_real_statuses_only() {
assert!(game_distribution_theme_admin_status_filter_valid(
GAME_DISTRIBUTION_THEME_ADMIN_STATUS_ALL
));
for status in GAME_DISTRIBUTION_THEME_STATUSES {
assert!(
game_distribution_theme_admin_status_filter_valid(status),
"{status}"
);
}
for status in ["", "ALL", "deleted", "published ", " archived"] {
assert!(
!game_distribution_theme_admin_status_filter_valid(status),
"{status}"
);
}
// 两条白名单刻意分开:`all` 只服务过滤视图,混进状态白名单会让它被当成可落库的值。
assert!(!game_distribution_theme_status_valid(
GAME_DISTRIBUTION_THEME_ADMIN_STATUS_ALL
));
}
/// 后台主题文本校验:空名 / 超长各有明确原因;边界取「刚好合法」,且按**字符**而不是字节计数。
#[test]
fn theme_text_validation_pins_limits_at_the_boundary() {
assert_eq!(
game_distribution_theme_text_violation("", "", ""),
Some("主题名不能为空")
);
assert_eq!(
game_distribution_theme_text_violation(" ", "", ""),
Some("主题名不能为空"),
"调用方传 trim 后的值:一串空格与空名同罪"
);
// 40 个中文字符 = 120 字节:按字节计数的实现会在这里红。
let max_name = "主".repeat(GAME_DISTRIBUTION_THEME_NAME_MAX_CHARS);
assert_eq!(
game_distribution_theme_text_violation(&max_name, "", ""),
None
);
let too_long_name = "主".repeat(GAME_DISTRIBUTION_THEME_NAME_MAX_CHARS + 1);
assert_eq!(
game_distribution_theme_text_violation(&too_long_name, "", ""),
Some("主题名不能超过 40 个字符")
);
let max_summary = "简".repeat(GAME_DISTRIBUTION_THEME_SUMMARY_MAX_CHARS);
assert_eq!(
game_distribution_theme_text_violation("主题", &max_summary, ""),
None
);
assert_eq!(
game_distribution_theme_text_violation("主题", &format!("{max_summary}简"), ""),
Some("主题简介不能超过 200 个字符")
);
let max_badge = "标".repeat(GAME_DISTRIBUTION_THEME_BADGE_MAX_CHARS);
assert_eq!(
game_distribution_theme_text_violation("主题", "", &max_badge),
None
);
assert_eq!(
game_distribution_theme_text_violation("主题", "", &format!("{max_badge}标")),
Some("主题角标不能超过 16 个字符")
);
// 三个上限各自独立:简介/角标超长不会因为名字合法而被放过。
assert_eq!(
game_distribution_theme_text_violation("主题", &max_summary, &format!("{max_badge}标")),
Some("主题角标不能超过 16 个字符")
);
// 长度按 trim 后的字符数算:40 个字 + 首尾空格仍然合法(空格不占额度)。
assert_eq!(
game_distribution_theme_text_violation(&format!(" {max_name} "), "", ""),
None
);
}
/// `limit` 语义:`0` 取默认 20(不是「返回 0 条」),超界截断到 50(不是报错)。
#[test]
fn page_limit_defaults_zero_and_truncates_overflow() {
assert_eq!(game_distribution_theme_page_limit(0), 20);
assert_eq!(game_distribution_theme_page_limit(1), 1);
assert_eq!(game_distribution_theme_page_limit(50), 50);
assert_eq!(
game_distribution_theme_page_limit(51),
50,
"超界截断而不是报错"
);
assert_eq!(game_distribution_theme_page_limit(u32::MAX), 50);
}
/// 游标编解码:往返双射、解析只切第一个冒号、负数微秒可逆,各种非法输入一律 `Err`。
#[test]
fn cursor_round_trips_and_rejects_malformed_values() {
let cursor = encode_game_distribution_theme_cursor(1_700_000_000_000_000, "theme_a");
assert_eq!(cursor, "1700000000000000:theme_a");
assert_eq!(
parse_game_distribution_theme_cursor(&cursor).unwrap(),
(1_700_000_000_000_000, "theme_a".to_string())
);
// 负数微秒(仅编码假设,仍必须可逆)。
assert_eq!(
parse_game_distribution_theme_cursor("-5:theme_a").unwrap(),
(-5, "theme_a".to_string())
);
// 解析只切第一个冒号:micros 之后的整段(含更多冒号)都是 theme_id。
assert_eq!(
parse_game_distribution_theme_cursor("9:theme:a").unwrap(),
(9, "theme:a".to_string())
);
for malformed in [
"",
"not-a-cursor",
"100",
"abc:theme_a",
"100:",
" :theme_a",
] {
assert!(
parse_game_distribution_theme_cursor(malformed).is_err(),
"非法游标必须报错:{malformed:?}"
);
}
}
/// 非法游标文案必须以**稳定码**开头,且不含会被 api-server 抢先映射的子串。
///
/// 只有排在 `THEME_` 分支**之前**的分支才会抢:`map_spacetime_error` 里 `FORK_` 在前、
/// `THEME_` 紧随其后,`owner 不匹配` / `已被删除` / 409 的「状态」子串都在 `THEME_` **之后**,
/// 因此不再需要回避它们(这里保留断言,是为了让「顺序一变就红」这件事仍然可见)。
#[test]
fn invalid_cursor_message_stays_in_the_bad_request_bucket() {
let message =
parse_game_distribution_theme_cursor("不是游标").expect_err("非法游标必须报错");
assert!(
message.starts_with(crate::GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE),
"游标错误必须以稳定码开头,否则 HTTP 面只能回通用 BAD_REQUEST:{message}"
);
assert!(message.contains("格式无效"), "{message}");
for forbidden in [
"不存在",
"已被删除",
"状态",
"不匹配",
"幂等",
"已存在",
"FORK_",
] {
assert!(
!message.contains(forbidden),
"游标错误文案不得含「{forbidden}」:{message}"
);
}
}
fn item(theme_id: &str, created_at_micros: i64) -> GameDistributionThemePageItem<String> {
GameDistributionThemePageItem {
theme_id: theme_id.to_string(),
created_at_micros,
payload: theme_id.to_string(),
}
}
/// 不足一页:全部回传,且**没有**下一页游标(不能给一个指向空页的游标,否则客户端会多翻一次死页)。
#[test]
fn page_returns_everything_without_cursor_when_under_limit() {
let (page, next) =
page_public_themes(vec![item("theme_a", 300), item("theme_b", 200)], None, 20);
assert_eq!(page, vec!["theme_a", "theme_b"]);
assert_eq!(next, None);
}
/// 恰好一页:`has_more` 依赖「多取一条」,因此 `len == limit` 时不能再给游标。
#[test]
fn page_at_exact_limit_has_no_cursor() {
let (page, next) =
page_public_themes(vec![item("theme_a", 300), item("theme_b", 200)], None, 2);
assert_eq!(page.len(), 2);
assert_eq!(next, None);
}
/// 连续翻页直到消耗完:每页**不重复**上一页的项,也**不跳过**任何项;最后一页游标为 `None`。
#[test]
fn paging_walks_the_whole_sequence_without_gaps_or_duplicates() {
let entries = vec![
item("theme_a", 500),
item("theme_b", 400),
item("theme_c", 300),
item("theme_d", 200),
item("theme_e", 100),
];
let mut seen: Vec<String> = Vec::new();
let mut cursor = None;
let mut pages = 0;
loop {
let (page, next) = page_public_themes(entries.clone(), cursor, 2);
assert!(!page.is_empty(), "非末页不得为空");
if next.is_some() {
assert_eq!(page.len(), 2, "还有下一页时必须填满 limit 条");
}
seen.extend(page);
pages += 1;
match next {
Some(value) => cursor = Some(parse_game_distribution_theme_cursor(&value).unwrap()),
None => break,
}
}
assert_eq!(pages, 3);
assert_eq!(
seen,
vec!["theme_a", "theme_b", "theme_c", "theme_d", "theme_e"],
"翻页顺序必须严格是「创建时间倒序」且不重不漏"
);
}
/// 同一 `created_at` 多条:`theme_id` 升序兜底保证全序,翻页边界不重不漏。
///
/// 这是唯一能钉住「排序是全序」的场景:若比较器在 `created_at` 相同时不稳定,三条同刻主题里
/// 的某一条就会被下一页重复返回或被整段跳过。
#[test]
fn page_tiebreaks_equal_created_at_by_theme_id_ascending() {
let entries = vec![
item("theme_c", 100),
item("theme_a", 100),
item("theme_d", 99),
item("theme_b", 100),
];
let (first, next) = page_public_themes(entries.clone(), None, 2);
assert_eq!(first, vec!["theme_a", "theme_b"]);
let cursor = next.expect("还有同刻与更早的项");
assert_eq!(cursor, "100:theme_b");
let (second, last) = page_public_themes(
entries,
Some(parse_game_distribution_theme_cursor(&cursor).unwrap()),
2,
);
assert_eq!(second, vec!["theme_c", "theme_d"]);
assert_eq!(last, None, "第二页就是最后一页");
}
/// **先过滤、再排序切页**:被滤掉的行不得凭空占掉名额,也不得出现在任何一页里。
///
/// 这里用「可见性过滤在调用方、发生在切页之前」的结构模拟模块侧的真实顺序:把不可见的行
/// 混在同一批输入里,先 `retain`,再交给 `page_public_themes`;若顺序反过来(先切页再过滤),
/// 第一页就会因为不可见行占位而只回传 1 条,翻页也会漏项。
#[test]
fn visibility_filter_must_happen_before_paging() {
// theme_b / theme_d 是「不可见」的主题(draft / archived),它们不该占页名额。
let raw = vec![
item("theme_a", 500),
item("theme_b", 400),
item("theme_c", 300),
item("theme_d", 200),
item("theme_e", 100),
];
let visible = raw
.into_iter()
.filter(|entry| entry.theme_id != "theme_b" && entry.theme_id != "theme_d")
.collect::<Vec<_>>();
let mut seen: Vec<String> = Vec::new();
let mut cursor = None;
loop {
let (page, next) = page_public_themes(visible.clone(), cursor, 1);
if next.is_some() {
assert_eq!(page.len(), 1, "过滤后仍有余量时必须填满 limit 条");
}
seen.extend(page);
match next {
Some(value) => cursor = Some(parse_game_distribution_theme_cursor(&value).unwrap()),
None => break,
}
}
assert_eq!(seen, vec!["theme_a", "theme_c", "theme_e"]);
assert!(
!seen.iter().any(|id| id == "theme_b" || id == "theme_d"),
"不可见主题不得出现在任何一页"
);
}
fn member(member_id: &str, sort_order: i64) -> GameDistributionThemeMemberPageItem<String> {
GameDistributionThemeMemberPageItem {
member_id: member_id.to_string(),
sort_order,
payload: member_id.to_string(),
}
}
/// 成员排序:`sort_order` 升序;同 `sort_order` 按 `member_id` 升序兜底(输入顺序无关)。
#[test]
fn member_sort_orders_by_rank_then_member_id() {
let sorted = sort_theme_members(vec![
member("theme_a:game_c", 10),
member("theme_a:game_a", 5),
member("theme_a:game_d", 10),
member("theme_a:game_b", 5),
])
.into_iter()
.map(|entry| entry.payload)
.collect::<Vec<_>>();
assert_eq!(
sorted,
vec![
"theme_a:game_a",
"theme_a:game_b",
"theme_a:game_c",
"theme_a:game_d",
]
);
}
/// 成员排序是全序:同一 `sort_order` 下无论输入怎么打乱,输出都相同(稳定且不重不漏)。
#[test]
fn member_sort_is_a_total_order_for_duplicate_ranks() {
let expected = vec![
"theme_a:game_a",
"theme_a:game_b",
"theme_a:game_c",
"theme_a:game_d",
];
for rotation in 0..4 {
let mut items = vec![
member("theme_a:game_b", 7),
member("theme_a:game_d", 7),
member("theme_a:game_a", 7),
member("theme_a:game_c", 7),
];
items.rotate_left(rotation);
let sorted = sort_theme_members(items)
.into_iter()
.map(|entry| entry.payload)
.collect::<Vec<_>>();
assert_eq!(sorted, expected, "rotation={rotation}");
}
}
/// 成员清单截断:保留排序后的前缀,不改变顺序;`limit` 大于总数时原样回传。
#[test]
fn member_page_truncates_after_sorting() {
let items = vec![
member("theme_a:game_c", 30),
member("theme_a:game_a", 10),
member("theme_a:game_b", 20),
];
assert_eq!(
page_theme_members(items.clone(), 2),
vec!["theme_a:game_a", "theme_a:game_b"],
"截断必须发生在排序之后,不能按输入顺序取前两条"
);
assert_eq!(
page_theme_members(items.clone(), 3),
vec!["theme_a:game_a", "theme_a:game_b", "theme_a:game_c"]
);
assert_eq!(
page_theme_members(items.clone(), usize::MAX).len(),
3,
"上限大于总数时原样回传"
);
assert!(
page_theme_members(items, 0).is_empty(),
"limit=0 不回传成员"
);
}
}