删除资源 kind 兼容白名单
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m4s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (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 shard 2/4 (pull_request) Has been cancelled

服务端图片快速编辑改用共享枚举静态图片判定

移除退役 kind 的兼容放行与冗余回归测试

清理客户端恒假 art-spritesheet 判断并统一远端 kind 解析

同步 AGC kind 里程碑、实施计划与决策记录
This commit is contained in:
2026-09-18 17:09:36 +08:00
parent e5fd454bb3
commit 0d5940433d
9 changed files with 80 additions and 206 deletions
@@ -1,7 +1,10 @@
import type { FileManagerFile } from '@cubone/react-file-manager';
import type { ComponentType, ReactNode } from 'react';
import type { GameCreationAppAssetKind } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
type GameCreationAppAssetKind,
parseGameCreationAppAssetKind,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
export type ImportedAsset = {
id: string;
@@ -374,7 +377,13 @@ export function buildRemoteFolderFiles(
asset.imageSrc ||
'platform-image',
localPath,
assetKind: asset.assetKind ?? null,
assetKind:
asset.assetKind == null
? null
: parseGameCreationAppAssetKind(
asset.assetKind,
'asset-importer.remote.assetKind',
),
},
});
}
@@ -85,6 +85,7 @@ import type {
import { GAME_CREATION_APP_UI_DESIGN_ASSET_KIND } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
isGameCreationAppAssetKind,
isGameCreationAppAssetVisualKind,
isGameCreationAppUiDesignDocAsset,
parseGameCreationAppAssetKind,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
@@ -3398,7 +3399,8 @@ export default function ProjectDevelopmentView({
);
const hasRegisteredArtImageAssets = manifest.assets.some(
(asset) =>
asset.kind === 'art-spritesheet' && asset.mediaType.startsWith('image/'),
asset.mediaType.startsWith('image/') &&
isGameCreationAppAssetVisualKind(asset.kind),
);
const allAgentSummaries = [
summarizeAgent(manifest, 'design', '设计实现 Agent'),
@@ -26,12 +26,13 @@
- `packages/shared/src/contracts/generated/` 作为受控生成目录在 `.prettierrc.json` 中使用与 ts-rs 原始输出一致的格式选项,并在 `.gitattributes` 标记为 `linguist-generated`;生成文件仍参与 Prettier/ESLint 检查,`cargo test export_bindings` 可幂等复现且不再污染工作树。
- `Unknown` 表示解析边界无法判定的输入;写入方可以保留该结果,但必须明确决定拒绝写入或交由后续归类,不得把它误写成某个具体 kind。
- shell TS 资源引用边界消费生成绑定,资源 kind 集合从生成的 union 穷举派生,并为未知值保留原始值与上下文日志。
- api-server 图片快速编辑来源校验改为严格解析 `GameCreationAppAssetKind`,只允许共享枚举的静态图片成员;删除原有 legacy/兼容字符串白名单,退役值不再放行。
明确不修改:
- `category``mediaType``source.kind``source.generationKind`
- api-server、SpacetimeDB schema、OpenAPI
- shared-contracts 的 canonical alias 表与读时自愈:本次不新增、不扩展,留给独立词汇收敛切片
- api-server 的其他资源 taxonomy、SpacetimeDB schema、OpenAPI本切片只改图片快速编辑的 GameCreationApp manifest 来源校验。
- shared-contracts 的 canonical alias 表与读时自愈:alias 表已删除,严格解析和 `Unknown` 口径保持不变
- `packages/shared` 中重复的资源 kind 手写契约与其余前端裸 `string` 字段(后续切片)。
## 实现顺序
@@ -20,7 +20,7 @@
## 不在范围内
- server-side 其他 `asset_kind`、External API DTO、SpacetimeDB schema。
- server-side 其他 `asset_kind`、External API DTO、SpacetimeDB schema;仅纳入 api-server 图片快速编辑对 GameCreationApp manifest 来源的严格校验
- `source.kind``generationKind`、MIME 和文件格式。
- 其他领域字段为配合本里程碑进行“顺手枚举化”。
@@ -37,4 +37,4 @@
- [x] `font``audio``sound-effect``background-music` 均有真实生产路径(字体导入、上传音频推断、资源编辑音频派生)。
- [x] ts-rs 绑定由 `cargo test export_bindings` 生成,生成目录加入 `.prettierignore` / eslint `ignorePatterns`,可幂等复现且不污染工作树。
- [x] 退役的 `BindingDTO` / `BindingChange` 不再导出,手写的 `exports_ui_editor_types` 已删除。
- [ ] 旧 alias 不再由共享契约把非 canonical 输入收口成 canonical(该表属读边界,本次未新增也未扩展,留给独立词汇收敛切片)
- [x] 旧 alias 不再由共享契约把非 canonical 输入收口成 canonicalapi-server 图片快速编辑不再保留 legacy 兼容白名单,非 canonical 来源直接失败
@@ -13,17 +13,17 @@
## 范围
- 审计 shell 内所有可能表示 GameCreationApp 资源 kind 的字段、构造器、写入边界、筛选器、资源投影、Agent 资源引用和测试 fixture。
- 审计 shell 内所有可能表示 GameCreationApp 资源 kind 的字段、构造器、写入边界、筛选器、资源投影、Agent 资源引用和测试 fixture,并收紧 api-server 图片快速编辑的 manifest 来源边界
- 以 api-server 当前仍有效的同语义 kind 作为命名与 serde 值参考,并评估 shell 当前实际生产 kind;排除 api-server legacy、billing/storage taxonomy 和其他开放领域。
- 确认 Rust enum、`Unknown`、kebab-case JSON 和 ts-rs 生成文件的归属、生成命令与校验门禁。
- 形成逐项 todo:当前值、真实语义、生产/测试状态、目标 enum、是否改写、验证方式。
## 不在范围内
- 不扫描或修改 server-side 代码;api-server 只提供词汇参考
- 不扫描或修改 api-server 其他领域的 `asset_kind`;图片快速编辑来源校验属于本次 GameCreationApp manifest 边界,必须复用共享枚举且不得保留 legacy 白名单
- 不改 `mediaType`、MIME、文件扩展名、`source.kind``source.generationKind`、任务/工作流 kind、路径名或 tracking scope。
- 不在本里程碑修改 manifest writer前端消费方或公开 API
- 不实现旧 manifest migrationalias 表或 fallback 清理
- 不在本里程碑修改 manifest writer前端消费方仅清理退役 kind 的恒假判断,不扩展公开契约
- 不实现旧 manifest migration;旧值只在共享解析边界收口为 `Unknown`,在图片快速编辑请求边界被拒绝,不增加 alias 表或 fallback。
## 依赖与前置条件
@@ -34,10 +34,10 @@
## 验收标准
- [ ] shell 内 manifest 资源 kind 的生产写入点、解析点、投影点和测试 fixture 有完整清单。
- [ ] 每个旧值均已标注为:正式 GameCreationApp kind、其他领域字符串、legacy/错误 fallback;没有只按 grep 结果猜测的条目。
- [x] 每个旧值均已标注为:正式 GameCreationApp kind、其他领域字符串、legacy/错误 fallback;没有只按 grep 结果猜测的条目。
- [ ] api-server 有效值、shell 扩展值和排除值各有明确依据。
- [ ] 生成架构不会要求 server-side 默认构建安装 ts-rs。
- [ ] todo 清单的每项都有关闭条件和对应验证证据。
- [x] todo 清单的每项都有关闭条件和对应验证证据;图片快速编辑不再维护兼容白名单,退役 kind 直接失败
## 证据要求
@@ -11,7 +11,7 @@
- 决策(判据不许散落):切片残留登记只看 `assets/art-spritesheet-slices/` 路径(不再附带 `kind == Icon`);sprite 身份比较忽略随 kind 派生的 `metadata.asset_type`;TS 侧「UI 编辑器文档资产」判据只留 `isGameCreationAppUiDesignDocAsset()` 一份,资源画布入口 / UI 编辑器桥接 / 资源引用缩略图统一调用。
- 决策(已知代价,不补救):既有项目里 legacy kind 读入即 `unknown`,依赖 kind 等值比较的运行门禁会按"缺少该资源"处理。这是「不迁移、不 fallback」的必然结果,本次明确不为存量数据做迁移;将来若要迁就必须单独立项(一次性迁移或读侧白名单),不得把别名表加回解析路径。
- 决策(留痕必须真的落地):原实现用 `tracing::warn!`,而 AGC 壳没有 tracing subscriber,等于没有日志。现在 `shared-contracts` 只暴露可注册回调 `set_non_canonical_asset_kind_reporter()`AGC 壳在 `main()` 里接到 `app_log!`,日志同时含原始输入串与调用上下文;`kind-observability` feature 与 `tracing` 依赖一并删除。TS 侧对应 `parseGameCreationAppAssetKind()``console.warn`
- 决策(平台/画板词汇表):`AGENT_RUNTIME_CANVAS_ASSET_KINDS``canvas.asset_generate.assetKind` 的 enum)与 `platform_art_asset_manifest_kind()` 是「平台词汇 → manifest kind」的唯一映射点,保留原词汇但把认不出的原值改为留痕收口;对外 API 的 `EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS` 白名单保持原样(API 兼容面,不代表客户端归一规则)
- 决策(平台/画板词汇表):`AGENT_RUNTIME_CANVAS_ASSET_KINDS``canvas.asset_generate.assetKind` 的 enum)与 `platform_art_asset_manifest_kind()` 是「平台词汇 → manifest kind」的唯一映射点,保留原词汇但把认不出的原值改为留痕收口;图片快速编辑来源直接解析 `GameCreationAppAssetKind` 的 canonical 静态图片成员并要求 `mediaType=image`,原 `EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS` 兼容白名单已删除,退役 kind 不再作为 GameCreationApp manifest 来源放行
- 验证:`cargo test -p shared-contracts`(含词汇表唯一性、严格性与留痕用例)、`cargo test --locked -p shared-contracts --features ts-bindings export_bindings``git diff` 为空、AGC bin 定向用例(`platform_art_asset_manifest_kind_maps_platform_vocabulary_explicitly``derived_asset_manifest_kind_is_never_unknown_for_text_derivatives``non_canonical_manifest_asset_kinds_report_raw_values_only`)、`npx vitest run packages/shared/src/contracts/gameCreationApp.test.ts apps/ai-game-creator-shell/tests/uiDesignResourceBridge.test.ts apps/ai-game-creator-shell/tests/appSurface.test.ts`
- 关联文档:[AI 游戏创作智能体 App 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) 的 2026-09-17 节。
@@ -15,7 +15,7 @@
- **严格口径的已知代价**:既有项目 manifest 里若存的是 legacy kind`"UI"``"ui-prototype"``"art-spritesheet"``"game-background"`…),读入后就是 `unknown`,依赖 kind 等值比较的运行门禁会按「缺少该资源」处理。这是「不迁移、不 fallback」的必然结果,由项目决策接受;若将来要迁就存量数据,必须单独立项(一次性迁移或读侧白名单),不得把别名表加回解析路径。
- **留痕走 `app_log!`**`shared-contracts` 不再依赖 `tracing``kind-observability` feature 删除),改为暴露可注册回调 `set_non_canonical_asset_kind_reporter()`AGC 壳在 `main()` 里注册成 `app_log!`,日志同时含**原始输入串**与调用上下文,用于回查还有谁在写 legacy kind。TS 侧对应 `parseGameCreationAppAssetKind()``console.warn`
- **分类映射**`GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND` 改为按 `GameCreationAppAssetKind` 变体穷举(含 `unknown → unclassified`),与 `GameCreationAppAssetKind::ALL` 的对齐由单测 `asset_category_mapping_covers_every_kind` 守住;TS 侧同表按生成 union 穷举。
- **画布生成 kind 共用枚举**`canvas.asset_generate``assetKind` 白名单直接复用 `GameCreationAppAssetKind::CANVAS_ASSET_KINDS`(当前为 `image / icon-spec / ui-design / icon-spritesheet`),工具 schema、prompt 和执行前校验均从该枚举派生,不再维护 `&[&str]` 字符串目录。平台适配层若仍需要旧的外部请求字面值,只能在发送边界显式映射;manifest 始终写入枚举的 canonical 值。对外 API 的 `EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS` 白名单保持原样,属 API 兼容面,不代表客户端 kind 归一规则
- **画布生成与图片快速编辑共用枚举**`canvas.asset_generate``assetKind` 白名单直接复用 `GameCreationAppAssetKind::CANVAS_ASSET_KINDS`(当前为 `image / icon-spec / ui-design / icon-spritesheet`),工具 schema、prompt 和执行前校验均从该枚举派生,不再维护 `&[&str]` 字符串目录。图片快速编辑来源同样严格解析 `GameCreationAppAssetKind`,只允许 `is_static_image()` 的 canonical 成员并要求 `mediaType=image`;原有 `EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS` 兼容白名单已删除,退役值不再放行。平台适配层若仍需要其他领域的外部请求字面值,只能在发送边界显式映射;manifest 始终写入枚举的 canonical 值
- **版本素材替换**`subtypeEqual` 改为枚举相等(不再经别名字符串归一),`categoryEqual` 仍用 `game_creation_app_asset_effective_category` 的读时口径。
## 2026-09-15 GameCreationApp 资源 kind 枚举化(当前权威口径)
+13 -187
View File
@@ -38,6 +38,7 @@ use shared_contracts::editor_generation::{
editor_generation_stable_asset_id, editor_generation_stable_asset_object_id,
editor_generation_stable_resource_id,
};
use shared_contracts::game_creation_app::GameCreationAppAssetKind;
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::editor_project::{
EditorGenerationResultPersistItemRecordInput, EditorGenerationResultPersistRecord,
@@ -4110,52 +4111,19 @@ fn align_editor_image_edit_dimension(value: u32) -> u32 {
value.saturating_add(15) / 16 * 16
}
/// 图片快速编辑端点允许的**静态图**来源类型。
///
/// 口径:以「媒体类型为静态图的 canonical 类型」为权威集合,并额外收下平台侧按本地
/// manifest 原始类型登记来源资源时会出现的等价写法。kind 词汇表本身以
/// `server-rs/crates/shared-contracts/src/game_creation_app/asset_kind.rs` 的
/// `GameCreationAppAssetKind` 为唯一真源(严格解析、无别名表),这里保持的是**对外 API 的
/// 兼容白名单**(按库原始值放行),不参与、也不代表客户端的 kind 归一规则。
///
/// 必须与 `media_type == image` 一起构成 AND 门:视频、音频、序列帧等非静态媒体即使挂着
/// 图片类 assetKind 也照旧拒绝。
pub(crate) const EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS: [&str; 18] = [
// canonical 静态图类型
"image",
"scene",
"character",
"icon",
"icon-spritesheet",
"icon-spec",
"ui-design",
"publication-material",
"spec",
// 指向上述 canonical 类型的合法 legacy 类型
"game-background",
"character-art",
"game-art",
"illustration",
"art-spritesheet",
"art-spritesheet-slice",
"ui-prototype",
// AGC 本地 manifest 等在用的等价静态图类型
"ui",
// `register_local_asset_entry`assets.rs)空 kind 兜底写的是 `"asset"`
// 未归类上传的 PNG 会以 `kind: "asset"` 登记,而共享契约把 `"asset"` canonical 化成
// `"image"`,漏掉它就等于把「未归类静态图」挡在快速编辑之外(400)。
// 白名单必须在 canonical 静态图别名下封闭。
"asset",
];
pub(crate) fn ensure_editor_image_edit_source_kind_allowed(
asset_kind: Option<&str>,
media_type: Option<&str>,
) -> Result<(), AppError> {
let asset_kind = asset_kind.map(str::trim).filter(|value| !value.is_empty());
let asset_kind = asset_kind.filter(|value| !value.trim().is_empty());
let media_type = media_type.map(str::trim).filter(|value| !value.is_empty());
let asset_kind_allowed = asset_kind
.is_none_or(|asset_kind| EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS.contains(&asset_kind));
let asset_kind_allowed = asset_kind.is_none_or(|asset_kind| {
GameCreationAppAssetKind::parse_with_context(
asset_kind,
"editor-image-edit.source.asset_kind",
)
.is_static_image()
});
let media_type_allowed = matches!(media_type, None | Some("image"));
if asset_kind_allowed && media_type_allowed {
return Ok(());
@@ -20749,7 +20717,7 @@ mod tests {
}
#[test]
fn editor_image_edit_source_kind_whitelist_covers_static_image_kinds_only() {
fn editor_image_edit_source_kind_policy_covers_static_image_kinds_only() {
let cases = [
(None, None, true),
(None, Some("image"), true),
@@ -20762,16 +20730,6 @@ mod tests {
(Some("publication-material"), Some("image"), true),
(Some("ui-design"), Some("image"), true),
(Some("scene"), Some("image"), true),
// 对外 API 兼容白名单里的平台原始写法:平台按本地 manifest 原始类型登记来源资源时
// 会出现这些值,实测 `art-spritesheet` / `ui` 曾整片 400。
(Some("art-spritesheet"), Some("image"), true),
(Some("art-spritesheet-slice"), Some("image"), true),
(Some("ui"), Some("image"), true),
(Some("ui-prototype"), Some("image"), true),
(Some("game-art"), Some("image"), true),
(Some("game-background"), Some("image"), true),
(Some("character-art"), Some("image"), true),
(Some("illustration"), Some("image"), true),
// 非静态媒体与非静态类型必须继续拒绝。
(Some("character-animation"), Some("image-sequence"), false),
(Some("sound-effect"), Some("audio"), false),
@@ -20783,8 +20741,6 @@ mod tests {
(Some("future-asset-kind"), Some("image"), false),
(None, Some("future-media-type"), false),
// mediaType 是 AND 门:图片类 assetKind 挂非静态媒体照旧拒绝。
(Some("art-spritesheet"), Some("video"), false),
(Some("ui"), Some("audio"), false),
(Some("icon"), Some("image-sequence"), false),
(Some("scene"), Some("image-sequence"), false),
];
@@ -20793,13 +20749,13 @@ mod tests {
assert_eq!(
result.is_ok(),
expected_allowed,
"unexpected whitelist result for assetKind={asset_kind:?}, mediaType={media_type:?}",
"unexpected source-kind policy result for assetKind={asset_kind:?}, mediaType={media_type:?}",
);
}
let error =
ensure_editor_image_edit_source_kind_allowed(Some("art-spritesheet"), Some("video"))
.expect_err("非静态媒体即使挂着图片类 assetKind 不得支持快速编辑");
ensure_editor_image_edit_source_kind_allowed(Some("future-kind"), Some("image"))
.expect_err("未知 kind 不得作为快速编辑来源");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
error.details().and_then(|details| details.get("message")),
@@ -20912,10 +20868,6 @@ mod tests {
ensure_editor_image_edit_source_kind_allowed(Some("icon"), Some("image")).is_ok(),
"icon 是共享契约里的静态图 canonical 类型,与 generationInputs.source 无关"
);
assert!(
ensure_editor_image_edit_source_kind_allowed(Some("art-spritesheet"), Some("image"))
.is_ok()
);
assert!(ensure_editor_image_edit_source_kind_allowed(Some("icon"), Some("video")).is_err());
assert!(
ensure_editor_image_edit_source_kind_allowed(Some("future-kind"), Some("image"))
@@ -20923,132 +20875,6 @@ mod tests {
);
}
/// 实测 400×5 的回归用例:AGC「图片快速编辑」发出的请求,来源资源的 assetKind 是本地
/// manifest 原始类型 `art-spritesheet` / `ui` / `asset`mediaType=image)。这条路径必须
/// 放行;非静态媒体(video/audio/image-sequence)必须继续拒绝。
///
/// 夹具必须真的走进被测路径,不能是自洽空断言:请求体先反序列化成
/// `EditorImageEditRequest`,再从**请求体自己的** `generationInputs.assetKind` 取类型喂给
/// 放行判据;并用源码级闸门断言钉住
/// `resolve_editor_image_edit_source` / `ensure_editor_image_edit_target_matches_source`
/// 两个函数体内不得再出现 `generation_inputs`(历史上 `generationInputs.source` 参与过
/// gating,把 AGC 请求整体挡成 400;解析与对账需要 DB/OSS,源码级断言是无需基础设施
/// 也能钉住这条回归的唯一位置)。
#[test]
fn game_creator_client_quick_edit_accepts_local_manifest_static_image_kinds() {
// 与客户端 `submit_resource_edit_remote` 发送的 JSON 同形。
let agc_payload: EditorImageEditRequest = serde_json::from_str(
r#"{
"prompt": "把这张图改成夜间配色",
"sourceReferenceId": "resource-art-spritesheet",
"generationInputs": {
"source": "ai-game-creator-client",
"assetKind": "art-spritesheet"
}
}"#,
)
.expect("AGC quick edit payload should deserialize into the request DTO");
assert_eq!(
agc_payload.source_reference_id, "resource-art-spritesheet",
"回归夹具必须保持 AGC 客户端实际发送的请求形状"
);
assert_eq!(
agc_payload.target_layer_id, None,
"AGC 快速编辑不带 targetLayerId,走的是「主来源即目标」分支"
);
// 关键:放行判据只吃来源资源的权威 assetKind 与 mediaType。这里用请求体自己声明的
// assetKind 驱动判据——请求体里同时带着 `generationInputs.source`,它不得参与 gating。
let requested_kind = agc_payload
.generation_inputs
.as_ref()
.and_then(|inputs| inputs.pointer("/assetKind"))
.and_then(Value::as_str);
assert_eq!(
requested_kind,
Some("art-spritesheet"),
"回归夹具必须保持 AGC 客户端实际发送的请求形状"
);
assert!(
ensure_editor_image_edit_source_kind_allowed(
normalize_editor_image_edit_resolved_source_kind(requested_kind).as_deref(),
Some("image"),
)
.is_ok(),
"AGC 快速编辑请求不得因为 generationInputs.source 被拒"
);
// 源码级闸门:请求解析与目标对账都不得重新引入 generationInputs 门槛。
let source = include_str!("editor_project.rs");
assert_function_not_contains(
source,
"async fn resolve_editor_image_edit_source(",
"fn ensure_editor_image_edit_source_snapshot_matches(",
&["generation_inputs", "generationInputs"],
);
assert_function_not_contains(
source,
"fn ensure_editor_image_edit_target_matches_source(",
"async fn resolve_editor_image_edit_source(",
&["generation_inputs", "generationInputs"],
);
for (asset_kind, media_type) in [
("art-spritesheet", "image"),
("ui", "image"),
("asset", "image"),
("ui-prototype", "image"),
("game-art", "image"),
("game-background", "image"),
("icon", "image"),
] {
assert!(
ensure_editor_image_edit_source_kind_allowed(Some(asset_kind), Some(media_type))
.is_ok(),
"AGC 快速编辑必须放行 {asset_kind}/{media_type}"
);
}
for (asset_kind, media_type) in [
("art-spritesheet", "video"),
("ui", "audio"),
("asset", "video"),
("video", "video"),
("sound-effect", "audio"),
("background-music", "audio"),
("character-animation", "image-sequence"),
("future-kind", "image"),
] {
assert!(
ensure_editor_image_edit_source_kind_allowed(Some(asset_kind), Some(media_type))
.is_err(),
"非静态来源必须继续拒绝:{asset_kind}/{media_type}"
);
}
// 400 错误体必须带上真实原因,客户端才能诊断(对应 AGC 的
// `editor_api_rejection_reason` 取值顺序修正)。
let error =
ensure_editor_image_edit_source_kind_allowed(Some("future-kind"), Some("image"))
.expect_err("未知类型必须拒绝");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
error.details().and_then(|details| details.get("provider")),
Some(&json!("editor-image-edit"))
);
assert_eq!(
error.details().and_then(|details| details.get("message")),
Some(&json!("当前素材类型不支持图片快速编辑"))
);
assert_eq!(
error.details().and_then(|details| details.get("assetKind")),
Some(&json!("future-kind"))
);
assert_eq!(
error.details().and_then(|details| details.get("mediaType")),
Some(&json!("image"))
);
}
#[test]
fn editor_image_edit_target_binding_prefers_object_id_and_fails_closed() {
let target = EditorImageEditTargetLayerSource {
@@ -111,6 +111,22 @@ macro_rules! game_creation_app_asset_kinds {
)
}
/// 可作为单张静态图片来源的资源族。
pub const fn is_static_image(self) -> bool {
matches!(
self,
Self::Image
| Self::Scene
| Self::Character
| Self::Icon
| Self::IconSpritesheet
| Self::IconSpec
| Self::UiDesign
| Self::PublicationMaterial
| Self::Spec
)
}
/// 严格解析:只认 canonical 值,其余(含大小写、空白变体)一律 `Unknown`。
///
/// 私有实现细节,**不是**解析入口:外部一律走 [`Self::parse_with_context`]
@@ -320,7 +336,7 @@ mod tests {
#[test]
fn unknown_wire_values_are_closed_over_unknown() {
let _guard = record_reports();
for raw in ["asset", "ui", "game-background", "future-kind"] {
for raw in ["future-kind", "not-a-kind", "IMAGE", " image "] {
assert_eq!(
serde_json::from_str::<GameCreationAppAssetKind>(&format!("\"{raw}\"")).unwrap(),
GameCreationAppAssetKind::Unknown
@@ -381,15 +397,15 @@ mod tests {
GameCreationAppAssetKind::parse_with_context("unknown", "unit-test.vocabulary"),
GameCreationAppAssetKind::Unknown
);
for raw in ["UI", "ui", " image ", "Image", "art-spritesheet-slice"] {
for raw in ["IMAGE", " image ", "Image", "future-kind"] {
assert_eq!(
GameCreationAppAssetKind::parse_with_context(raw, "unit-test.vocabulary"),
GameCreationAppAssetKind::Unknown,
"{raw} 不许命中 canonical 值"
);
}
// 每个非 canonical 变体都要留痕(5 条大小写/空白/legacy 变体都算异常)
assert_eq!(recorded_reports().len(), 5);
// 每个非 canonical 变体都要留痕。
assert_eq!(recorded_reports().len(), 4);
}
#[test]
@@ -426,5 +442,25 @@ mod tests {
GameCreationAppAssetKind::UiDesign,
]
);
let static_images = GameCreationAppAssetKind::ALL
.iter()
.copied()
.filter(|kind| kind.is_static_image())
.collect::<Vec<_>>();
assert_eq!(
static_images,
vec![
GameCreationAppAssetKind::Image,
GameCreationAppAssetKind::Scene,
GameCreationAppAssetKind::Character,
GameCreationAppAssetKind::Icon,
GameCreationAppAssetKind::IconSpritesheet,
GameCreationAppAssetKind::IconSpec,
GameCreationAppAssetKind::UiDesign,
GameCreationAppAssetKind::PublicationMaterial,
GameCreationAppAssetKind::Spec,
]
);
}
}