修复 M3 复核问题(第一批):族谱页双重空态、节点上限常量未钉住、窄屏网格溢出

- 族谱页空态收敛为一条:nodes 为空时只给整体空态(这部作品还没有可展示的族谱节点),有节点但无衍生时才给衍生空态,两者互斥不再同时渲染
- 新增两条组件用例覆盖该组合:空树只出现一条空态且无 .game-lineage-tree;只有母版时渲染母版节点 + 衍生空态且不出现整体空态
- 纯函数测试改用 GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT 常量,并新增 node_limit_constant_is_pinned_and_governs_truncation:同时钉住常量值 200(合同值)与满量/超量时的 truncated 行为,改常量或改截断实现都必须过门
- LineageTreeNode 文档写明作者名空值语义:空串/纯空白归一为 None 与 M1 的 fork_lineage_visible_identity 三态刻意保留属有意为之,两端展示层按 falsy 判断、行为等价
- 族谱节点网格列下限改为 min(13rem, 100%):桌面列宽不变,极窄视口不再横向溢出
- 未动涉及 M2a session 的 spacetime-module / shared-contracts 注释与 lineage_entries 图装配测试,留待第二批
This commit is contained in:
2026-10-05 01:17:37 +08:00
parent 39669763fe
commit 1ce3adb8d0
7 changed files with 275 additions and 56 deletions
@@ -48,6 +48,11 @@ pub struct LineageTreeEntry {
}
/// 对外节点;`author_name` 为 `None` 表示作者信息不可得(账号与冻结资料都没有名字)。
///
/// 作者名的空值语义:调用方(`spacetime-module`)把空串与纯空白归一成 `None`——节点上没有
/// 可渲染的名字就不占位。这与 M1 的 `fork_lineage_visible_identity` **刻意保留三态**
/// (`Some("")` / `Some(" ")` 原样透传)不同,是有意为之:溯源卡由展示层按 falsy 判断是否
/// 渲染「由 X 制作」,两种取值在两端渲染结果等价,但节点侧不该把空串当成一个名字发出去。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LineageTreeNode {
pub game_id: String,
@@ -175,7 +180,12 @@ mod lineage_tree_tests {
play_count: generation as u64 * 10,
is_deleted,
is_published,
status: if is_published { "published" } else { "unpublished" }.to_string(),
status: if is_published {
"published"
} else {
"unpublished"
}
.to_string(),
created_at_micros,
}
}
@@ -185,7 +195,10 @@ mod lineage_tree_tests {
}
fn node_ids(tree: &LineageTree) -> Vec<&str> {
tree.nodes.iter().map(|node| node.game_id.as_str()).collect()
tree.nodes
.iter()
.map(|node| node.game_id.as_str())
.collect()
}
/// 锚点可读性:四种组合逐一钉死。「已软删除 + 可见性仍是 published」必须为 false——
@@ -215,13 +228,11 @@ mod lineage_tree_tests {
entry("removed", 1, 2, true, true),
published("alive", 1, 3),
];
let tree = build_lineage_tree(&entries, "root", 200);
let tree = build_lineage_tree(&entries, "root", GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT);
assert_eq!(node_ids(&tree), vec!["root", "alive"]);
assert!(
tree.nodes
.iter()
.all(|node| node.title != "作品 removed"
&& node.author_name.as_deref() != Some("作者 removed")),
tree.nodes.iter().all(|node| node.title != "作品 removed"
&& node.author_name.as_deref() != Some("作者 removed")),
"被删作品的标题与作者名都不得出现在响应里"
);
}
@@ -235,15 +246,16 @@ mod lineage_tree_tests {
entry("hidden", 1, 2, false, false),
published("alive", 1, 3),
];
let tree = build_lineage_tree(&entries, "root", 200);
let tree = build_lineage_tree(&entries, "root", GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT);
assert_eq!(node_ids(&tree), vec!["root", "alive"]);
// 被点名的锚点未公开时也不得出现(没有「自身豁免」)。
let unpublished_anchor = [
published("root", 0, 1),
entry("anchor", 1, 2, false, false),
];
let tree = build_lineage_tree(&unpublished_anchor, "root", 200);
let unpublished_anchor = [published("root", 0, 1), entry("anchor", 1, 2, false, false)];
let tree = build_lineage_tree(
&unpublished_anchor,
"root",
GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT,
);
assert_eq!(node_ids(&tree), vec!["root"]);
assert!(tree.nodes.iter().all(|node| node.game_id != "anchor"));
}
@@ -256,7 +268,7 @@ mod lineage_tree_tests {
published("mid", 1, 2),
published("leaf", 2, 3),
];
let tree = build_lineage_tree(&entries, "root", 200);
let tree = build_lineage_tree(&entries, "root", GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT);
assert_eq!(node_ids(&tree), vec!["mid", "leaf"]);
assert!(tree.root.is_none(), "根不可用时 root 降级为空");
let mid = tree
@@ -279,7 +291,7 @@ mod lineage_tree_tests {
#[test]
fn visible_root_is_exposed_as_tree_top() {
let entries = [published("root", 0, 5), published("child", 1, 6)];
let tree = build_lineage_tree(&entries, "root", 200);
let tree = build_lineage_tree(&entries, "root", GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT);
let root = tree.root.expect("根应可见");
assert_eq!(root.game_id, "root");
assert_eq!(root.title, "作品 root");
@@ -302,7 +314,7 @@ mod lineage_tree_tests {
assert!(tree.truncated);
assert_eq!(node_ids(&tree), vec!["g0", "g1"]);
let full = build_lineage_tree(&entries, "g0", 200);
let full = build_lineage_tree(&entries, "g0", GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT);
assert!(!full.truncated);
assert_eq!(node_ids(&full).len(), 4);
}
@@ -316,7 +328,7 @@ mod lineage_tree_tests {
published("g1-b", 1, 10),
published("g1-a", 1, 10),
];
let tree = build_lineage_tree(&entries, "g0", 200);
let tree = build_lineage_tree(&entries, "g0", GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT);
assert_eq!(
node_ids(&tree),
vec!["g0", "g1-a", "g1-b", "g1-late"],
@@ -328,7 +340,7 @@ mod lineage_tree_tests {
#[test]
fn duplicate_entries_collapse_to_one_node() {
let entries = [published("root", 0, 1), published("root", 0, 1)];
let tree = build_lineage_tree(&entries, "root", 200);
let tree = build_lineage_tree(&entries, "root", GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT);
assert_eq!(node_ids(&tree), vec!["root"]);
}
@@ -342,7 +354,7 @@ mod lineage_tree_tests {
entry("child-removed", 3, 3, true, true),
entry("child-hidden", 3, 4, false, false),
];
let list = build_derived_node_list(&entries, 200);
let list = build_derived_node_list(&entries, GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT);
let ids = list
.nodes
.iter()
@@ -369,4 +381,45 @@ mod lineage_tree_tests {
.collect::<Vec<_>>();
assert_eq!(ids, vec!["early", "mid"]);
}
/// 节点上限是**合同值**:一条断言同时钉住「常量本身」与「动作行为」。
///
/// 只按常量符号构造输入不够——改常量时输入会一起变,测试仍绿。所以这里既断言
/// `200` 这个对外承诺值(技术方案 §3.4 与里程碑验收都写它),又用常量本身构造
/// 满量 / 超量输入,保证「改常量」和「改截断实现」都必须过这道门。
#[test]
fn node_limit_constant_is_pinned_and_governs_truncation() {
assert_eq!(
GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT, 200,
"节点上限是合同值(技术方案 §3.4 与里程碑验收都引用 200);改动需同步契约与文档"
);
let mut entries = (0..GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT)
.map(|index| published(&format!("g{index}"), 0, index as i64))
.collect::<Vec<_>>();
// 恰好等于上限:不算截断。
let exact = build_lineage_tree(&entries, "g0", GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT);
assert!(!exact.truncated);
assert_eq!(exact.nodes.len(), GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT);
// 多一个:截断到常量值,并如实标注。
entries.push(published("overflow", 0, 10_000));
let overflowed = build_lineage_tree(&entries, "g0", GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT);
assert!(overflowed.truncated);
assert_eq!(
overflowed.nodes.len(),
GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT,
"截断后节点数必须等于常量值"
);
// 衍生列表共用同一个上限常量,同样在超量时标注截断。
let derived_overflow =
build_derived_node_list(&entries, GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT);
assert!(derived_overflow.truncated);
assert_eq!(
derived_overflow.nodes.len(),
GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT
);
}
}
@@ -0,0 +1,15 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct GameDistributionForkSourceInput {
pub game_id: String,
}
impl __sdk::InModule for GameDistributionForkSourceInput {
type Module = super::RemoteModule;
}
@@ -0,0 +1,22 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct GameDistributionForkSourceResult {
pub ok: bool,
pub found: bool,
pub available: bool,
pub fork_authorization: String,
pub version_id: Option<String>,
pub package_sha_256: Option<String>,
pub package_bytes: Option<u64>,
pub error_message: Option<String>,
}
impl __sdk::InModule for GameDistributionForkSourceResult {
type Module = super::RemoteModule;
}
@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::game_distribution_fork_source_input_type::GameDistributionForkSourceInput;
use super::game_distribution_fork_source_result_type::GameDistributionForkSourceResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct GetGameDistributionForkSourceAndReturnArgs {
pub input: GameDistributionForkSourceInput,
}
impl __sdk::InModule for GetGameDistributionForkSourceAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `get_game_distribution_fork_source_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait get_game_distribution_fork_source_and_return {
fn get_game_distribution_fork_source_and_return(&self, input: GameDistributionForkSourceInput) {
self.get_game_distribution_fork_source_and_return_then(input, |_, _| {});
}
fn get_game_distribution_fork_source_and_return_then(
&self,
input: GameDistributionForkSourceInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<GameDistributionForkSourceResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl get_game_distribution_fork_source_and_return for super::RemoteProcedures {
fn get_game_distribution_fork_source_and_return_then(
&self,
input: GameDistributionForkSourceInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<GameDistributionForkSourceResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, GameDistributionForkSourceResult>(
"get_game_distribution_fork_source_and_return",
GetGameDistributionForkSourceAndReturnArgs { input },
__callback,
);
}
}
@@ -1022,6 +1022,67 @@ describe('GameLineagePage', () => {
).toBeTruthy();
});
it('没有任何可见节点时只渲染一条整体空态,不叠加衍生空态', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
root: null,
nodes: [],
truncated: false,
});
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(
await screen.findByText('这部作品还没有可展示的族谱节点。'),
).toBeTruthy();
// 双重空态回归:整体空态已覆盖「没有节点」,不能再叠一条衍生空态。
expect(
screen.queryByText('这部作品还没有衍生作品,欢迎从它开始共创。'),
).toBeNull();
expect(document.querySelectorAll('.game-lineage-tree')).toHaveLength(0);
});
it('只有母版时渲染母版节点并只给衍生空态,不给整体空态', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
root: createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
}),
nodes: [
createLineageNode({
gameId: 'game-root',
title: '母版',
generation: 0,
parentGameId: null,
}),
],
truncated: false,
});
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
expect(await screen.findByRole('button', { name: '母版' })).toBeTruthy();
expect(
screen.getByText('这部作品还没有衍生作品,欢迎从它开始共创。'),
).toBeTruthy();
expect(
screen.queryByText('这部作品还没有可展示的族谱节点。'),
).toBeNull();
});
it('超出节点上限时如实标注截断,不静默丢弃', async () => {
getGameLineageMock.mockResolvedValueOnce({
rootGameId: 'game-root',
@@ -211,47 +211,54 @@ export function GameLineagePage({
母版已不可用,下面按代际关系展示仍可阅读的衍生作品。
</p>
)}
{groups.length === 0 ? (
{/*
空态只有一条,避免「双重空态」:`nodes` 为空说明整棵树没有任何可见节点
(连母版都不可读),此时只给整体空态;只有「有节点但没有任何衍生」时才给衍生空态。
两者互斥,不会同时出现。
*/}
{nodes.length === 0 ? (
<PlatformEmptyState surface="subpanel" size="panel">
<span>这部作品还没有可展示的族谱节点。</span>
</PlatformEmptyState>
) : (
<div className="game-lineage-tree">
{groups.map(([generation, groupNodes]) => (
<section
key={generation}
className="game-lineage-generation"
aria-label={generationLabel(generation)}
>
<h3 className="game-lineage-generation__label">
{generationLabel(generation)}
<span className="game-lineage-generation__count">
{groupNodes.length}
</span>
</h3>
<div className="game-lineage-generation__nodes">
{groupNodes.map((node) => (
<LineageNodeCard
key={node.gameId}
node={node}
isAnchor={node.gameId === anchorId}
hasVisibleParent={
!node.parentGameId ||
visibleIds.has(node.parentGameId)
}
onOpenDetail={onOpenDetail}
/>
))}
</div>
</section>
))}
</div>
<>
<div className="game-lineage-tree">
{groups.map(([generation, groupNodes]) => (
<section
key={generation}
className="game-lineage-generation"
aria-label={generationLabel(generation)}
>
<h3 className="game-lineage-generation__label">
{generationLabel(generation)}
<span className="game-lineage-generation__count">
{groupNodes.length}
</span>
</h3>
<div className="game-lineage-generation__nodes">
{groupNodes.map((node) => (
<LineageNodeCard
key={node.gameId}
node={node}
isAnchor={node.gameId === anchorId}
hasVisibleParent={
!node.parentGameId ||
visibleIds.has(node.parentGameId)
}
onOpenDetail={onOpenDetail}
/>
))}
</div>
</section>
))}
</div>
{derivedCount === 0 ? (
<PlatformEmptyState surface="subpanel" size="panel">
<span>这部作品还没有衍生作品,欢迎从它开始共创。</span>
</PlatformEmptyState>
) : null}
</>
)}
{derivedCount === 0 ? (
<PlatformEmptyState surface="subpanel" size="panel">
<span>这部作品还没有衍生作品,欢迎从它开始共创。</span>
</PlatformEmptyState>
) : null}
</>
) : null}
@@ -1258,7 +1258,9 @@
}
.game-lineage-generation__nodes {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
/* `min(13rem, 100%)`:桌面端列宽不变,视口窄于 13rem 时下限跟着收缩,
避免固定下限把内容撑出容器产生横向溢出。 */
grid-template-columns: repeat(auto-fill, minmax(min(13rem, 100%), 1fr));
gap: 0.6rem;
}
.game-lineage-node {