模板库假数据注入改为 Cargo feature 控制
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m37s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 4m59s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m50s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m58s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m36s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / Frontend tests (pull_request) Failing after 4m59s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m56s
Project CI / Native shell tests (pull_request) Successful in 8m58s

- 新增 feature template-library-fixtures(默认关闭):关闭时 apply_template_library_fixtures 为恒等透传,正式产物不含注入分支
- 注入实现下沉为 cfg 模块:按真实清单循环补齐假数据(唯一 id/标题/封面地址,标签追加批次分组,安装态 1/3 混合),条数由 AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT 控制(默认 1000)
- 两种编译模式各补一条用例:默认构建必须恒等透传,开启 feature 必须补齐到配置条数;另补假数据识别/计数解析单测
- 前端补 1000 条渲染回归:1000 张卡片渲染 + 已安装/标签/关键词过滤数量自洽
- 技术方案补「本地压测假数据注入」与 1000 条实测结论(标签条膨胀、一次性渲染 1000 卡片与封面请求需后续收口)
This commit is contained in:
kdletters
2026-09-17 15:09:02 +08:00
parent a9d5ac0931
commit 19e9dea45c
4 changed files with 270 additions and 0 deletions
@@ -6,6 +6,9 @@ publish = false
[features]
default = []
# 模板库假数据注入(仅本地页面压测/演示用):只有显式开启该 feature 才会编译并在读取清单后
# 把条目循环补齐成假数据;计数由 AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT 控制(默认 1000)。
template-library-fixtures = []
cocos-editor = ["cocos-editor-bridge/process-discovery"]
cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-bootstrap"]
cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"]
@@ -359,6 +359,153 @@ fn to_entry(
})
}
/// 本地假数据注入:只在 `template-library-fixtures` feature(或测试构建)下编译。
///
/// 开启时把真实清单循环补齐成假数据(条数见 `fixtures::synthetic_template_count`);
/// 未开启时是恒等透传,正式构建里没有任何注入分支。
fn apply_template_library_fixtures(entries: Vec<GameTemplateEntry>) -> Vec<GameTemplateEntry> {
#[cfg(feature = "template-library-fixtures")]
{
return fixtures::pad_synthetic_templates(entries, fixtures::synthetic_template_count());
}
#[cfg(not(feature = "template-library-fixtures"))]
{
entries
}
}
/// 本地假数据注入实现:只在 `template-library-fixtures` feature(或测试构建)下编译。
///
/// 位置刻意放在 Rust 侧:模板库的清单校验、安装状态与建项目都在这一侧,TS 只消费快照做渲染;
/// 在这里注入才能压到与真实一致的整条链路。正式构建不含该模块,因此不存在误触发路径。
#[cfg(any(test, feature = "template-library-fixtures"))]
pub(crate) mod fixtures {
use super::*;
pub(crate) const SYNTHETIC_COUNT_ENV: &str = "AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT";
pub(crate) const DEFAULT_SYNTHETIC_COUNT: usize = 1_000;
const MAX_SYNTHETIC_COUNT: usize = 20_000;
/// 假数据条数:环境变量优先,缺省 1000;0 表示不注入。
pub(crate) fn synthetic_template_count() -> usize {
parse_synthetic_count(std::env::var(SYNTHETIC_COUNT_ENV).ok().as_deref())
}
fn parse_synthetic_count(raw: Option<&str>) -> usize {
raw.and_then(|value| value.trim().parse::<usize>().ok())
.unwrap_or(DEFAULT_SYNTHETIC_COUNT)
.min(MAX_SYNTHETIC_COUNT)
}
/// 把真实清单循环复制成指定条数:id/标题/封面地址唯一,安装态按 1/3 混合。
pub(crate) fn pad_synthetic_templates(
entries: Vec<GameTemplateEntry>,
target: usize,
) -> Vec<GameTemplateEntry> {
if target <= entries.len() || entries.is_empty() {
return entries;
}
let base = entries.clone();
let mut padded = entries;
let mut index = padded.len();
while padded.len() < target {
let source = &base[index % base.len()];
let installed = index % 3 == 0;
let mut next = source.clone();
next.id = format!("{}-{:04}", source.id, index);
next.title = format!("{} · 假数据 {index:04}", source.title);
let mut tags = source.tags.clone();
tags.push(format!("批次-{:02}", index % 20));
next.tags = tags;
next.cover_url = format!("{}?synthetic={index}", source.cover_url);
next.installed = installed;
next.installed_version = installed.then(|| source.template_version.clone());
next.installed_at_millis = installed.then(|| now_millis());
padded.push(next);
index += 1;
}
padded
}
#[cfg(test)]
mod tests {
use super::*;
fn base_entry(id: &str, tag: &str) -> GameTemplateEntry {
GameTemplateEntry {
id: id.to_string(),
title: format!("模板 {id}"),
summary: "假数据基础条目".to_string(),
tags: vec![tag.to_string()],
runtime: "html".to_string(),
engine: "phaser".to_string(),
engine_version: "4.2.1".to_string(),
template_version: "0.1.0".to_string(),
updated_at: "2026-09-17T00:00:00Z".to_string(),
entry: "game/index.html".to_string(),
zip_url: format!("https://oss.example/templates/v1/{id}/template.zip"),
zip_size_bytes: 1024,
zip_sha256: "a".repeat(64),
cover_url: format!("https://oss.example/templates/v1/{id}/cover.svg"),
cover_width: 960,
cover_height: 540,
installed: false,
installed_version: None,
installed_at_millis: None,
}
}
#[test]
fn parses_synthetic_count_from_env_value() {
assert_eq!(parse_synthetic_count(None), DEFAULT_SYNTHETIC_COUNT);
assert_eq!(parse_synthetic_count(Some(" 250 ")), 250);
assert_eq!(parse_synthetic_count(Some("0")), 0);
assert_eq!(
parse_synthetic_count(Some("not-a-number")),
DEFAULT_SYNTHETIC_COUNT
);
assert_eq!(parse_synthetic_count(Some("999999")), MAX_SYNTHETIC_COUNT);
}
#[test]
fn pads_entries_with_unique_identity_and_mixed_install_state() {
let base = vec![
base_entry("blank-web", "空白"),
base_entry("blank-2d", "2d"),
];
let padded = pad_synthetic_templates(base.clone(), 9);
assert_eq!(padded.len(), 9);
// 真实条目保持原样排在最前。
assert_eq!(padded[0].id, "blank-web");
assert_eq!(padded[1].id, "blank-2d");
let ids = padded
.iter()
.map(|entry| entry.id.clone())
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(ids.len(), 9, "假数据 id 必须唯一");
let covers = padded
.iter()
.map(|entry| entry.cover_url.clone())
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(covers.len(), 9, "假数据封面地址必须唯一");
assert!(padded.iter().any(|entry| entry.installed));
assert!(padded.iter().any(|entry| !entry.installed));
assert!(padded
.iter()
.skip(2)
.all(|entry| entry.tags.iter().any(|tag| tag.starts_with("批次-"))));
}
#[test]
fn padding_is_a_no_op_without_room_to_fill() {
let base = vec![base_entry("blank-web", "空白")];
assert_eq!(pad_synthetic_templates(base.clone(), 1).len(), 1);
assert_eq!(pad_synthetic_templates(base.clone(), 0).len(), 1);
assert!(pad_synthetic_templates(Vec::new(), 10).is_empty());
}
}
}
fn build_template_library_client() -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
@@ -624,6 +771,7 @@ pub(crate) async fn fetch_game_template_library(
)
})
.collect::<Result<Vec<_>, _>>()?;
let entries = apply_template_library_fixtures(entries);
Ok(GameTemplateLibrarySnapshot {
schema_version: header.schema_version,
library: header.library,
@@ -1013,6 +1161,38 @@ mod tests {
assert!(error.contains("模板尚未安装"), "{error}");
}
/// 正式构建(未开 feature)必须恒等透传:注入路径不能出现在默认产物里。
#[cfg(not(feature = "template-library-fixtures"))]
#[test]
fn fixtures_are_inert_without_the_feature() {
let index_body = include_str!("../tests/fixtures/agc-template-library-index.json");
let (_, templates) =
parse_game_template_library_index(index_body).expect("parse fixture index");
let entries = templates
.iter()
.map(|summary| to_entry(summary, None).expect("entry"))
.collect::<Vec<_>>();
let original = entries.len();
let applied = apply_template_library_fixtures(entries);
assert_eq!(applied.len(), original, "默认构建不应注入假数据");
}
/// 开启 feature 后同一次调用必须补齐到配置条数。
#[cfg(feature = "template-library-fixtures")]
#[test]
fn fixtures_expand_entries_when_the_feature_is_enabled() {
let index_body = include_str!("../tests/fixtures/agc-template-library-index.json");
let (_, templates) =
parse_game_template_library_index(index_body).expect("parse fixture index");
let entries = templates
.iter()
.map(|summary| to_entry(summary, None).expect("entry"))
.collect::<Vec<_>>();
let applied = apply_template_library_fixtures(entries);
assert_eq!(applied.len(), fixtures::synthetic_template_count());
assert!(applied.len() > templates.len());
}
/// 可选的真连检查:`cargo test --bin genarrative-ai-game-creator-shell template_library -- --ignored`。
/// 默认跳过,避免离线环境因网络失败误报。
#[tokio::test]
@@ -6,6 +6,11 @@ import type {
GameTemplateEntry,
TemplateLibraryFilters,
} from '../src/features/template-library/templateLibraryModel';
import {
collectGameTemplateTags,
EMPTY_TEMPLATE_LIBRARY_FILTERS,
filterGameTemplates,
} from '../src/features/template-library/templateLibraryModel';
import type { TemplateLibraryController } from '../src/features/template-library/useTemplateLibrary';
import TemplateRecommendations from '../src/view/home/TemplateRecommendations';
import TemplateLibraryView from '../src/view/template-library';
@@ -327,6 +332,62 @@ describe('TemplateLibraryView', () => {
});
});
describe('大库量渲染(1000 条假数据)', () => {
const bulk = Array.from({ length: 1000 }, (_, index) =>
template({
id: `bulk-${index}`,
title: `批量模板 ${index}`,
summary: '压测条目',
tags: ['起步工程', `批次-${String(index % 20).padStart(2, '0')}`],
installed: index % 3 === 0,
installedVersion: index % 3 === 0 ? '0.1.0' : null,
}),
);
it('renders every card and keeps filters consistent at 1000 entries', () => {
const { container } = render(
<TemplateLibraryView
controller={controller({
templates: bulk,
visibleTemplates: bulk,
installedCount: bulk.filter((entry) => entry.installed).length,
tagOptions: collectGameTemplateTags(bulk),
})}
onBack={() => {}}
/>,
);
expect(container.querySelectorAll('article')).toHaveLength(1000);
expect(screen.getByText('共 1000 个模板 · 已下载 334 个')).toBeTruthy();
// 标签筛选条会随库量膨胀,这里先记录当前聚合出来的规模(1000 条 × 批次标签)。
const tagButtons = screen
.getAllByRole('button')
.filter((button) =>
button.getAttribute('aria-label')?.startsWith('标签筛选'),
);
expect(tagButtons.length).toBeGreaterThan(20);
// 纯前端筛选在大库量下仍然是 O(n) 的一遍过滤,数量与已安装态自洽。
const installedOnly = filterGameTemplates(bulk, {
...EMPTY_TEMPLATE_LIBRARY_FILTERS,
installedOnly: true,
});
expect(installedOnly).toHaveLength(334);
expect(
filterGameTemplates(bulk, {
...EMPTY_TEMPLATE_LIBRARY_FILTERS,
tags: ['批次-07'],
}),
).toHaveLength(50);
expect(
filterGameTemplates(bulk, {
...EMPTY_TEMPLATE_LIBRARY_FILTERS,
query: '批量模板 999',
}).map((entry) => entry.id),
).toEqual(['bulk-999']);
});
});
describe('TemplateRecommendations', () => {
it('renders the recommended templates and opens the library', () => {
const onOpenLibrary = vi.fn();
@@ -75,6 +75,32 @@ templates/
## 验收与验证
## 本地压测假数据注入(feature 控制)
模板库的数据源在 Rust 侧(清单校验、安装状态、下载与建项目都在这里),TS 只消费快照做渲染,所以假数据注入也放在 Rust 侧,走与真实完全一致的链路。
- 开关:Cargo feature `template-library-fixtures`**默认关闭**)。关闭时 `apply_template_library_fixtures` 是恒等透传,正式产物里不存在注入分支,并有单测保证这一点。
- 条数:环境变量 `AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT`(默认 1000`0` 表示不注入;上限 20000)。
- 假数据特征:真实条目保留在最前,其余按真实条目循环复制;`id`/标题唯一,封面地址追加 `?synthetic=N`(强制逐张请求,模拟“每个模板各自封面”);标签追加 `批次-00..19`;安装态按 1/3 混合。
- 运行方式:
```bash
# 本机 dev 客户端(保留 Windows 默认 feature
AGC_DEV_CARGO_FEATURES=cocos-editor-execute,template-library-fixtures npm run dev
# 直接跑二进制
cargo run --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --features template-library-fixtures
# 覆盖条数
AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT=300 AGC_DEV_CARGO_FEATURES=template-library-fixtures npm run dev
```
两种编译模式都要过模板库单测:默认构建跑「恒等透传」用例,`--features template-library-fixtures` 跑「补齐到配置条数」用例。
### 1000 条实测结论
- 页面能正常渲染 1000 张卡片(头部显示「共 1000 个模板 · 已下载 335 个」),并且滚动容器生效(窗口高度压到 430px 时右侧出现滚动条,页面内容被裁切而不是溢出到窗口外)。
- 需要后续收口的两点(本次未改):① 标签筛选条随库量膨胀——1000 条时聚合出 35 个标签、占三行;② 一次性渲染 1000 个卡片节点并触发 1000 次封面请求。建议标签只展示 Top N + 「更多」,卡片列表加分页或虚拟滚动。
- 前端回归:1000 条渲染 + 已安装过滤(334)/标签过滤(50)/关键词过滤数量自洽,见 `apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx`
```bash
# 模板库单测(清单校验、键安全、解压路径逃逸、安装与建项目)
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell template_library