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
端到端实测(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。
791 lines
35 KiB
Rust
791 lines
35 KiB
Rust
//! 共创主题的纯函数规则。
|
||
//!
|
||
//! 表与事务在 `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 不回传成员"
|
||
);
|
||
}
|
||
}
|