From 19e9dea45c60d0b1d9d6c14f72c539d5efdb152d Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:09:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E5=BA=93=E5=81=87=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=B3=A8=E5=85=A5=E6=94=B9=E4=B8=BA=20Cargo=20feature?= =?UTF-8?q?=20=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 feature template-library-fixtures(默认关闭):关闭时 apply_template_library_fixtures 为恒等透传,正式产物不含注入分支 - 注入实现下沉为 cfg 模块:按真实清单循环补齐假数据(唯一 id/标题/封面地址,标签追加批次分组,安装态 1/3 混合),条数由 AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT 控制(默认 1000) - 两种编译模式各补一条用例:默认构建必须恒等透传,开启 feature 必须补齐到配置条数;另补假数据识别/计数解析单测 - 前端补 1000 条渲染回归:1000 张卡片渲染 + 已安装/标签/关键词过滤数量自洽 - 技术方案补「本地压测假数据注入」与 1000 条实测结论(标签条膨胀、一次性渲染 1000 卡片与封面请求需后续收口) --- .../src-tauri/Cargo.toml | 3 + .../src-tauri/src/template_library.rs | 180 ++++++++++++++++++ .../tests/templateLibraryView.test.tsx | 61 ++++++ ...技术方案】AGC模板库与模板建项-2026-09-17.md | 26 +++ 4 files changed, 270 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index b5cb1f2d2..4dd833d85 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -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"] diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index 11ad74279..ee2cea8dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -359,6 +359,153 @@ fn to_entry( }) } +/// 本地假数据注入:只在 `template-library-fixtures` feature(或测试构建)下编译。 +/// +/// 开启时把真实清单循环补齐成假数据(条数见 `fixtures::synthetic_template_count`); +/// 未开启时是恒等透传,正式构建里没有任何注入分支。 +fn apply_template_library_fixtures(entries: Vec) -> Vec { + #[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::().ok()) + .unwrap_or(DEFAULT_SYNTHETIC_COUNT) + .min(MAX_SYNTHETIC_COUNT) + } + + /// 把真实清单循环复制成指定条数:id/标题/封面地址唯一,安装态按 1/3 混合。 + pub(crate) fn pad_synthetic_templates( + entries: Vec, + target: usize, + ) -> Vec { + 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::>(); + assert_eq!(ids.len(), 9, "假数据 id 必须唯一"); + let covers = padded + .iter() + .map(|entry| entry.cover_url.clone()) + .collect::>(); + 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::, _>>()?; + 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::>(); + 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::>(); + 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] diff --git a/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx index d98547ff3..a7d2344b3 100644 --- a/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx +++ b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx @@ -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( + 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(); diff --git a/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md index 2435b532e..09cba6c0e 100644 --- a/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md +++ b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md @@ -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