修复 SFX V2 T1 验收问题
修复音频 compact 结果与 Agent reconcile 契约 统一正式 SFX 提交流程的 Unicode canonicalization 锁定队列 duration 与 Loop JSON 形态 收紧 Rust V2 metadata 构造和反序列化不变量 补齐共享边界、52 个预设 ID 和任意小数时长测试
This commit is contained in:
@@ -137,6 +137,8 @@ External v1 `model` 先删除首尾 Unicode `White_Space`,再按大小写敏
|
||||
|
||||
实施记录(`2026-08-06`):T1 已完成。前后端共享 canonicalization fixture 已锁定 Unicode 边界与 `2048 / 2049` 行为;52 个预设、最小优化 DTO、固定模型、nullable duration、Loop 默认值、响应结果字段和强类型 V2 metadata 已落地。duration 纯校验接受自动 `null` 与手动 `0.5–30s`,拒绝非有限值和越界值。External `model` 当前只落地纯 canonicalizer 与输入矩阵测试,正式定价、预扣、enqueue、OpenAPI 和副作用测试仍严格归属 T5;在 T5 完成前不得发布当前中间态。
|
||||
|
||||
补充验收(`2026-08-06`):音频 compact 结果保留完整 DTO 必填的 `provider`,SFX / BGM 均通过真实 compact → Agent reconcile 回归;正式 SFX 提交流程不再执行原生 `trim()` 或默认 Prompt 回退。Rust V2 metadata 只能经校验构造并拒绝错误版本、模型、时长组合与实际时长;共享 fixture 直接锁定 `1 / 2048 / 2049`,52 个预设 ID 和非 `0.1s` 步进小数时长均有固定断言。
|
||||
|
||||
### T2:一键优化 BFF 和 Worker 翻译 service
|
||||
|
||||
- 增加登录态 SFX Prompt 优化 BFF,固定 Luna + Medium + `max_output_tokens = 8192` completion 总预算,32 KiB body limit,严格唯一 JSON envelope,不调用音频 provider 或正式计费。
|
||||
|
||||
@@ -46,5 +46,30 @@
|
||||
"input": "",
|
||||
"prompt": "",
|
||||
"charCount": 0
|
||||
},
|
||||
{
|
||||
"name": "one-code-point-boundary",
|
||||
"input": "声",
|
||||
"prompt": "声",
|
||||
"charCount": 1,
|
||||
"validation": "valid"
|
||||
},
|
||||
{
|
||||
"name": "exactly-2048-code-points",
|
||||
"prefix": "\u0085",
|
||||
"input": "声",
|
||||
"suffix": "\u2003",
|
||||
"prompt": "声",
|
||||
"repeat": 2048,
|
||||
"charCount": 2048,
|
||||
"validation": "valid"
|
||||
},
|
||||
{
|
||||
"name": "over-limit-2049-code-points",
|
||||
"input": "声",
|
||||
"prompt": "声",
|
||||
"repeat": 2049,
|
||||
"charCount": 2049,
|
||||
"validation": "too-long"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -11,3 +11,6 @@ pub use api::{
|
||||
create_editor_agent_conversation, delete_editor_agent_conversation,
|
||||
get_editor_agent_conversation, list_editor_agent_conversations,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use reconcile::reconcile_completed_editor_agent_tool_call_for_test;
|
||||
|
||||
@@ -207,6 +207,15 @@ fn reconcile_completed_editor_agent_tool_call(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reconcile_completed_editor_agent_tool_call_for_test(
|
||||
message: &mut EditorAgentMessage,
|
||||
result_payload_json: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
reconcile_completed_editor_agent_tool_call(message, result_payload_json)
|
||||
.map_err(|error| format!("{error:?}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1554,8 +1554,9 @@ mod tests {
|
||||
assert_eq!(job.price_mud_points, 5);
|
||||
assert_eq!(
|
||||
job.payload["duration"],
|
||||
GenerateSoundEffectTool::DEFAULT_DURATION
|
||||
json!(f64::from(GenerateSoundEffectTool::DEFAULT_DURATION))
|
||||
);
|
||||
assert_eq!(job.payload["loop"], json!(false));
|
||||
assert_eq!(job.payload["projectId"], "project-1");
|
||||
assert_eq!(job.payload["generationInputs"]["toolCallMessageId"], 7);
|
||||
}
|
||||
|
||||
@@ -1251,9 +1251,15 @@ fn compact_editor_generation_result(mut result: Value) -> Value {
|
||||
let Some(object) = result.as_object_mut() else {
|
||||
return result;
|
||||
};
|
||||
// 紧凑结果会回传给普通用户的 Agent 工具调用卡片,不能把供应商或内部后处理实现
|
||||
// 当作可见生成信息下发。正常的用户可见模型仍然保留,以便卡片恢复原有展示。
|
||||
object.remove("provider");
|
||||
// 音频 Agent 使用完整音频 DTO 回填,必须保留其必填 provider;其它生成结果继续隐藏
|
||||
// 供应商和内部后处理实现。正常的用户可见模型仍然保留,以便卡片恢复原有展示。
|
||||
let is_audio_result = object
|
||||
.get("audioKind")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|kind| matches!(kind, "sound-effect" | "background-music"));
|
||||
if !is_audio_result {
|
||||
object.remove("provider");
|
||||
}
|
||||
if object
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
@@ -2069,6 +2075,84 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_agent_audio_compact_results_remain_reconcileable() {
|
||||
use crate::editor_agent::reconcile_completed_editor_agent_tool_call_for_test;
|
||||
use platform_editor_agent::{
|
||||
agent::tools::{
|
||||
generate_background_music::GenerateBackgroundMusicTool,
|
||||
generate_sound_effect::GenerateSoundEffectTool,
|
||||
},
|
||||
framework::tool::Tool,
|
||||
};
|
||||
use shared_contracts::editor_agent::{EditorAgentMessage, EditorAgentToolCallStatus};
|
||||
|
||||
for (tool_name, prompt, audio_kind) in [
|
||||
(GenerateSoundEffectTool::NAME, "按钮点击声", "sound-effect"),
|
||||
(
|
||||
GenerateBackgroundMusicTool::NAME,
|
||||
"森林背景音乐",
|
||||
"background-music",
|
||||
),
|
||||
] {
|
||||
let mut job = external_generation_job_record_fixture(Some("lease-1"));
|
||||
job.request_payload_json = json!({
|
||||
"generationInputs": { "source": "editor-agent" },
|
||||
})
|
||||
.to_string();
|
||||
let response = json!({
|
||||
"ok": true,
|
||||
"audioSrc": "/generated/audio.mp3",
|
||||
"width": 420,
|
||||
"height": 120,
|
||||
"sourceType": "generated",
|
||||
"prompt": prompt,
|
||||
"model": "audio1.0",
|
||||
"provider": "vectorengine",
|
||||
"taskId": "task-1",
|
||||
"priceMudPoints": 5,
|
||||
"audioKind": audio_kind,
|
||||
});
|
||||
let payload: Value =
|
||||
serde_json::from_str(&editor_generation_result_payload_json(&job, &response))
|
||||
.expect("worker compact payload should serialize");
|
||||
assert_eq!(
|
||||
payload["editor-agent-tool-call-result"]["provider"],
|
||||
json!("vectorengine")
|
||||
);
|
||||
let mut message: EditorAgentMessage = serde_json::from_value(json!({
|
||||
"id": 1,
|
||||
"role": "system",
|
||||
"text": "waiting",
|
||||
"attachments": [],
|
||||
"toolCall": {
|
||||
"toolName": tool_name,
|
||||
"status": "not_completed",
|
||||
"args": { "prompt": prompt },
|
||||
"displayArgs": {
|
||||
"stringArgs": [],
|
||||
"imageArgs": [],
|
||||
"extras": { "priceMudPoints": 5 }
|
||||
},
|
||||
"externalJobId": "job-1",
|
||||
"images": [],
|
||||
"audios": []
|
||||
},
|
||||
"createdAt": "2026-08-06T00:00:00Z"
|
||||
}))
|
||||
.expect("pending audio Agent message should deserialize");
|
||||
let payload_json = payload.to_string();
|
||||
reconcile_completed_editor_agent_tool_call_for_test(
|
||||
&mut message,
|
||||
Some(payload_json.as_str()),
|
||||
)
|
||||
.expect("worker compact audio result should reconcile");
|
||||
let tool_call = message.tool_call.expect("tool call should remain present");
|
||||
assert_eq!(tool_call.status, EditorAgentToolCallStatus::Completed);
|
||||
assert_eq!(tool_call.audios[0].audio_src, "/generated/audio.mp3");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_agent_spritesheet_result_keeps_all_persisted_slices() {
|
||||
let mut job = external_generation_job_record_fixture(Some("lease-1"));
|
||||
|
||||
@@ -22,21 +22,29 @@ fn sound_effect_prompt_matches_shared_unicode_canonicalization_fixtures() {
|
||||
let name = case["name"]
|
||||
.as_str()
|
||||
.expect("fixture name should be a string");
|
||||
let input = case["input"]
|
||||
let input_unit = case["input"]
|
||||
.as_str()
|
||||
.expect("fixture input should be a string");
|
||||
let expected_prompt = case["prompt"]
|
||||
let expected_prompt_unit = case["prompt"]
|
||||
.as_str()
|
||||
.expect("fixture prompt should be a string");
|
||||
let repeat = case.get("repeat").and_then(Value::as_u64).unwrap_or(1) as usize;
|
||||
let input = format!(
|
||||
"{}{}{}",
|
||||
case.get("prefix").and_then(Value::as_str).unwrap_or(""),
|
||||
input_unit.repeat(repeat),
|
||||
case.get("suffix").and_then(Value::as_str).unwrap_or("")
|
||||
);
|
||||
let expected_prompt = expected_prompt_unit.repeat(repeat);
|
||||
let expected_char_count = case["charCount"]
|
||||
.as_u64()
|
||||
.expect("fixture charCount should be an unsigned integer")
|
||||
as usize;
|
||||
|
||||
let canonical_prompt = canonicalize_sound_effect_prompt(input);
|
||||
let canonical_prompt = canonicalize_sound_effect_prompt(&input);
|
||||
assert_eq!(canonical_prompt, expected_prompt, "fixture={name}");
|
||||
assert_eq!(
|
||||
sound_effect_prompt_code_point_count(input),
|
||||
sound_effect_prompt_code_point_count(&input),
|
||||
expected_char_count,
|
||||
"fixture={name}"
|
||||
);
|
||||
@@ -45,6 +53,17 @@ fn sound_effect_prompt_matches_shared_unicode_canonicalization_fixtures() {
|
||||
canonical_prompt,
|
||||
"canonicalization should be idempotent for fixture={name}"
|
||||
);
|
||||
match case.get("validation").and_then(Value::as_str) {
|
||||
Some("valid") => {
|
||||
validate_sound_effect_prompt(&input)
|
||||
.unwrap_or_else(|error| panic!("fixture={name}: {error}"));
|
||||
}
|
||||
Some("too-long") => {
|
||||
validate_sound_effect_prompt(&input)
|
||||
.expect_err("shared over-limit fixture should be rejected");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +108,7 @@ fn sound_effect_duration_accepts_auto_and_frozen_boundaries() {
|
||||
);
|
||||
for duration_seconds in [
|
||||
SOUND_EFFECT_DURATION_MIN_SECONDS,
|
||||
1.23456789,
|
||||
7.5,
|
||||
SOUND_EFFECT_DURATION_MAX_SECONDS,
|
||||
] {
|
||||
|
||||
@@ -522,18 +522,128 @@ pub enum EditorSoundEffectDurationMode {
|
||||
Manual,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Clone, Debug, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorSoundEffectGenerationMetadataV2 {
|
||||
pub schema_version: u8,
|
||||
pub user_prompt: String,
|
||||
pub actual_prompt: String,
|
||||
pub model: String,
|
||||
pub duration_mode: EditorSoundEffectDurationMode,
|
||||
pub requested_duration_seconds: Option<f64>,
|
||||
pub actual_duration_seconds: f64,
|
||||
schema_version: u8,
|
||||
user_prompt: String,
|
||||
actual_prompt: String,
|
||||
model: String,
|
||||
duration_mode: EditorSoundEffectDurationMode,
|
||||
requested_duration_seconds: Option<f64>,
|
||||
actual_duration_seconds: f64,
|
||||
#[serde(rename = "loop")]
|
||||
pub loop_enabled: bool,
|
||||
loop_enabled: bool,
|
||||
}
|
||||
|
||||
impl EditorSoundEffectGenerationMetadataV2 {
|
||||
pub fn try_new(
|
||||
user_prompt: String,
|
||||
actual_prompt: String,
|
||||
duration_mode: EditorSoundEffectDurationMode,
|
||||
requested_duration_seconds: Option<f64>,
|
||||
actual_duration_seconds: f64,
|
||||
loop_enabled: bool,
|
||||
) -> Result<Self, &'static str> {
|
||||
let requested_duration_is_valid = match duration_mode {
|
||||
EditorSoundEffectDurationMode::Auto => requested_duration_seconds.is_none(),
|
||||
EditorSoundEffectDurationMode::Manual => requested_duration_seconds
|
||||
.is_some_and(|duration| duration.is_finite() && (0.5..=30.0).contains(&duration)),
|
||||
};
|
||||
if !requested_duration_is_valid {
|
||||
return Err("SFX V2 metadata 的时长模式与请求时长不一致");
|
||||
}
|
||||
if !actual_duration_seconds.is_finite()
|
||||
|| actual_duration_seconds <= 0.0
|
||||
|| actual_duration_seconds > 600.0
|
||||
{
|
||||
return Err("SFX V2 metadata 的实际时长无效");
|
||||
}
|
||||
Ok(Self {
|
||||
schema_version: 2,
|
||||
user_prompt,
|
||||
actual_prompt,
|
||||
model: EDITOR_SOUND_EFFECT_MODEL.to_string(),
|
||||
duration_mode,
|
||||
requested_duration_seconds,
|
||||
actual_duration_seconds,
|
||||
loop_enabled,
|
||||
})
|
||||
}
|
||||
|
||||
pub const fn schema_version(&self) -> u8 {
|
||||
self.schema_version
|
||||
}
|
||||
|
||||
pub fn user_prompt(&self) -> &str {
|
||||
self.user_prompt.as_str()
|
||||
}
|
||||
|
||||
pub fn actual_prompt(&self) -> &str {
|
||||
self.actual_prompt.as_str()
|
||||
}
|
||||
|
||||
pub fn model(&self) -> &str {
|
||||
self.model.as_str()
|
||||
}
|
||||
|
||||
pub const fn duration_mode(&self) -> EditorSoundEffectDurationMode {
|
||||
self.duration_mode
|
||||
}
|
||||
|
||||
pub const fn requested_duration_seconds(&self) -> Option<f64> {
|
||||
self.requested_duration_seconds
|
||||
}
|
||||
|
||||
pub const fn actual_duration_seconds(&self) -> f64 {
|
||||
self.actual_duration_seconds
|
||||
}
|
||||
|
||||
pub const fn loop_enabled(&self) -> bool {
|
||||
self.loop_enabled
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for EditorSoundEffectGenerationMetadataV2 {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de::Error as _;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Payload {
|
||||
schema_version: u8,
|
||||
user_prompt: String,
|
||||
actual_prompt: String,
|
||||
model: String,
|
||||
duration_mode: EditorSoundEffectDurationMode,
|
||||
requested_duration_seconds: Option<f64>,
|
||||
actual_duration_seconds: f64,
|
||||
#[serde(rename = "loop")]
|
||||
loop_enabled: bool,
|
||||
}
|
||||
|
||||
let payload = Payload::deserialize(deserializer)?;
|
||||
if payload.schema_version != 2 {
|
||||
return Err(D::Error::custom("SFX metadata 只支持 schemaVersion 2"));
|
||||
}
|
||||
if payload.model != EDITOR_SOUND_EFFECT_MODEL {
|
||||
return Err(D::Error::custom(format!(
|
||||
"SFX V2 metadata 只支持模型 {EDITOR_SOUND_EFFECT_MODEL}"
|
||||
)));
|
||||
}
|
||||
Self::try_new(
|
||||
payload.user_prompt,
|
||||
payload.actual_prompt,
|
||||
payload.duration_mode,
|
||||
payload.requested_duration_seconds,
|
||||
payload.actual_duration_seconds,
|
||||
payload.loop_enabled,
|
||||
)
|
||||
.map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
@@ -1559,16 +1669,29 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn editor_sound_effect_v2_metadata_uses_the_frozen_camel_case_shape() {
|
||||
let metadata = EditorSoundEffectGenerationMetadataV2 {
|
||||
schema_version: 2,
|
||||
user_prompt: "金币落地,轻快可爱".to_string(),
|
||||
actual_prompt: "A bright, cute coin landing chime".to_string(),
|
||||
model: EDITOR_SOUND_EFFECT_MODEL.to_string(),
|
||||
duration_mode: EditorSoundEffectDurationMode::Manual,
|
||||
requested_duration_seconds: Some(5.0),
|
||||
actual_duration_seconds: 5.12,
|
||||
loop_enabled: false,
|
||||
};
|
||||
let metadata = EditorSoundEffectGenerationMetadataV2::try_new(
|
||||
"金币落地,轻快可爱".to_string(),
|
||||
"A bright, cute coin landing chime".to_string(),
|
||||
EditorSoundEffectDurationMode::Manual,
|
||||
Some(5.0),
|
||||
5.12,
|
||||
false,
|
||||
)
|
||||
.expect("valid SFX V2 metadata should construct");
|
||||
assert_eq!(metadata.schema_version(), 2);
|
||||
assert_eq!(metadata.model(), EDITOR_SOUND_EFFECT_MODEL);
|
||||
assert_eq!(metadata.user_prompt(), "金币落地,轻快可爱");
|
||||
assert_eq!(
|
||||
metadata.actual_prompt(),
|
||||
"A bright, cute coin landing chime"
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.duration_mode(),
|
||||
EditorSoundEffectDurationMode::Manual
|
||||
);
|
||||
assert_eq!(metadata.requested_duration_seconds(), Some(5.0));
|
||||
assert_eq!(metadata.actual_duration_seconds(), 5.12);
|
||||
assert!(!metadata.loop_enabled());
|
||||
let payload = serde_json::to_value(&metadata).expect("metadata should serialize");
|
||||
assert_eq!(
|
||||
payload,
|
||||
@@ -1584,10 +1707,40 @@ mod tests {
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<EditorSoundEffectGenerationMetadataV2>(payload)
|
||||
serde_json::from_value::<EditorSoundEffectGenerationMetadataV2>(payload.clone())
|
||||
.expect("metadata should deserialize"),
|
||||
metadata
|
||||
);
|
||||
|
||||
for invalid in [
|
||||
json!({ "schemaVersion": 1 }),
|
||||
json!({ "model": "audio1.0" }),
|
||||
json!({
|
||||
"durationMode": "auto",
|
||||
"requestedDurationSeconds": 5.0,
|
||||
}),
|
||||
json!({
|
||||
"durationMode": "manual",
|
||||
"requestedDurationSeconds": null,
|
||||
}),
|
||||
json!({ "actualDurationSeconds": 600.1 }),
|
||||
] {
|
||||
let mut invalid_payload = payload.clone();
|
||||
invalid_payload
|
||||
.as_object_mut()
|
||||
.expect("metadata payload should be an object")
|
||||
.extend(
|
||||
invalid
|
||||
.as_object()
|
||||
.expect("invalid override should be an object")
|
||||
.clone(),
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<EditorSoundEffectGenerationMetadataV2>(invalid_payload)
|
||||
.is_err(),
|
||||
"invalid metadata override should be rejected: {invalid}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -22,6 +22,60 @@ describe('ImageCanvasSoundEffectPresetModel', () => {
|
||||
expect(new Set(SOUND_EFFECT_PROMPT_PRESETS.map(({ id }) => id)).size).toBe(
|
||||
52,
|
||||
);
|
||||
expect(SOUND_EFFECT_PROMPT_PRESETS.map(({ id }) => id)).toEqual([
|
||||
'button-tap',
|
||||
'operation-confirm',
|
||||
'back-cancel',
|
||||
'page-transition',
|
||||
'notification',
|
||||
'operation-error',
|
||||
'coin-pickup',
|
||||
'item-pickup',
|
||||
'reward-received',
|
||||
'chest-open',
|
||||
'content-unlock',
|
||||
'rare-drop',
|
||||
'item-craft',
|
||||
'character-level-up',
|
||||
'quest-complete',
|
||||
'achievement-unlocked',
|
||||
'challenge-victory',
|
||||
'challenge-defeat',
|
||||
'character-jump',
|
||||
'character-land',
|
||||
'light-hit',
|
||||
'heavy-hit',
|
||||
'attack-swing',
|
||||
'attack-hit',
|
||||
'block-success',
|
||||
'object-break',
|
||||
'skill-charge',
|
||||
'magic-cast',
|
||||
'healing',
|
||||
'shield-create',
|
||||
'teleport',
|
||||
'ice-skill',
|
||||
'fire-skill',
|
||||
'status-buff',
|
||||
'door-open',
|
||||
'mechanism-start',
|
||||
'lever-trigger',
|
||||
'stone-move',
|
||||
'portal-open',
|
||||
'countdown-warning',
|
||||
'casual-cute',
|
||||
'retro-arcade',
|
||||
'sci-fi-electronic',
|
||||
'fantasy-magic',
|
||||
'realistic-natural',
|
||||
'cartoon-exaggerated',
|
||||
'gentle-feedback',
|
||||
'strong-feedback',
|
||||
'short-feedback',
|
||||
'two-stage-rise',
|
||||
'clean-prominent',
|
||||
'soft-not-harsh',
|
||||
]);
|
||||
expect(
|
||||
new Set(SOUND_EFFECT_PROMPT_PRESETS.map(({ label }) => label)).size,
|
||||
).toBe(52);
|
||||
|
||||
@@ -11,19 +11,29 @@ import {
|
||||
describe('ImageCanvasSoundEffectPromptModel', () => {
|
||||
it('matches the shared Unicode canonicalization fixtures', () => {
|
||||
for (const fixture of soundEffectPromptCanonicalizationCases) {
|
||||
expect(canonicalizeSoundEffectPrompt(fixture.input), fixture.name).toBe(
|
||||
fixture.prompt,
|
||||
const repeat = fixture.repeat ?? 1;
|
||||
const input = `${fixture.prefix ?? ''}${fixture.input.repeat(repeat)}${fixture.suffix ?? ''}`;
|
||||
const expectedPrompt = fixture.prompt.repeat(repeat);
|
||||
expect(canonicalizeSoundEffectPrompt(input), fixture.name).toBe(
|
||||
expectedPrompt,
|
||||
);
|
||||
expect(countSoundEffectPromptCodePoints(input), fixture.name).toBe(
|
||||
fixture.charCount,
|
||||
);
|
||||
expect(
|
||||
countSoundEffectPromptCodePoints(fixture.input),
|
||||
canonicalizeSoundEffectPrompt(canonicalizeSoundEffectPrompt(input)),
|
||||
fixture.name,
|
||||
).toBe(fixture.charCount);
|
||||
expect(
|
||||
canonicalizeSoundEffectPrompt(
|
||||
canonicalizeSoundEffectPrompt(fixture.input),
|
||||
),
|
||||
fixture.name,
|
||||
).toBe(fixture.prompt);
|
||||
).toBe(expectedPrompt);
|
||||
if (fixture.validation === 'valid') {
|
||||
expect(validateSoundEffectPrompt(input).ok, fixture.name).toBe(true);
|
||||
} else if (fixture.validation === 'too-long') {
|
||||
expect(validateSoundEffectPrompt(input), fixture.name).toEqual({
|
||||
ok: false,
|
||||
prompt: expectedPrompt,
|
||||
charCount: fixture.charCount,
|
||||
reason: 'too-long',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1898,7 +1898,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
initialDialog={{
|
||||
id: 'dialog-sound',
|
||||
mode: 'audio-sound-effect',
|
||||
prompt: ' 金币掉落叮当声 ',
|
||||
prompt: '\u0085\uFEFF金币掉落叮当声\uFEFF\u0085',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
soundDurationSeconds: 7,
|
||||
@@ -1920,7 +1920,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
await waitFor(() => {
|
||||
expect(generateEditorSoundEffectMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: '金币掉落叮当声',
|
||||
prompt: '\uFEFF金币掉落叮当声\uFEFF',
|
||||
duration: 7,
|
||||
projectId: 'editor-project-audio',
|
||||
assetFolderId: 'project',
|
||||
@@ -1953,6 +1953,33 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an empty canonical sound effect prompt without a fallback', async () => {
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
initialDialog={{
|
||||
id: 'dialog-sound-empty',
|
||||
mode: 'audio-sound-effect',
|
||||
prompt: '\u0085 \n\t\u0085',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(generateEditorSoundEffectMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('tracked-dialog').textContent).toBe(
|
||||
'audio-sound-effect::failed:open:音效描述不能为空',
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('tracked-dialog').textContent).not.toContain(
|
||||
'游戏音效',
|
||||
);
|
||||
});
|
||||
|
||||
it('claims and canonicalizes one background music submission before the first await', async () => {
|
||||
const representativeComplexPromptCase =
|
||||
backgroundMusicPromptCanonicalizationCases.find(
|
||||
|
||||
@@ -83,6 +83,7 @@ import {
|
||||
buildImageGenerationSubmissionPlan,
|
||||
resolveGenerationAssetLabel,
|
||||
} from './ImageCanvasGenerationSubmissionModel';
|
||||
import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel';
|
||||
import type {
|
||||
UiAssetExtractionMark,
|
||||
UiAssetExtractionState,
|
||||
@@ -1882,14 +1883,15 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
currentBackgroundMusicSubmissionScopeRef.current.version,
|
||||
}
|
||||
: null;
|
||||
const soundEffectPrompt =
|
||||
dialog.mode === 'audio-sound-effect'
|
||||
? validateSoundEffectPrompt(dialog.prompt)
|
||||
: null;
|
||||
const normalizedPrompt =
|
||||
backgroundMusicSubmission?.prompt ??
|
||||
soundEffectPrompt?.prompt ??
|
||||
(dialog.prompt.trim() ||
|
||||
(dialog.mode === 'edit'
|
||||
? '修改当前图片'
|
||||
: dialog.mode === 'audio-sound-effect'
|
||||
? '游戏音效'
|
||||
: 'AI 生成图片'));
|
||||
(dialog.mode === 'edit' ? '修改当前图片' : 'AI 生成图片'));
|
||||
if (!backgroundMusicSubmission && canvasDialog) {
|
||||
updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({
|
||||
...currentDialog,
|
||||
|
||||
Reference in New Issue
Block a user