修复 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:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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;
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+59
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user