@@ -351,7 +351,16 @@ pub fn ai_refine_apply(
351351// ────────────────────────────────────────────────────────────
352352
353353/// 工作台数据(前端 RefineWorkbench 模态数据源)。
354+ ///
355+ /// @ai-context: 必须 camelCase(与 AiRefineResult 等兄弟结构体一致)——本模块
356+ /// 其余结构体均已 2026-08 统一契约;此处缺失曾导致前端按
357+ /// ruleMarkdown 读取得到 undefined → renderMd(undefined).split
358+ /// ("Cannot read properties of undefined (reading 'split')",
359+ /// v0.11.5 引入、真机验收漏测,v0.12.3 修复补测)。
360+ /// @ai-context: 嵌套 section(SectionDiff)保持 snake_case 是刻意契约
361+ /// (首次出现即定,前端类型注释"勿改"),rename 只作用于顶层键。
354362#[ derive( Debug , Clone , serde:: Serialize ) ]
363+ #[ serde( rename_all = "camelCase" ) ]
355364pub struct WorkbenchData {
356365 pub rule_markdown : String ,
357366 pub refined_markdown : Option < String > ,
@@ -515,3 +524,50 @@ pub(crate) fn ensure_balance_for(st: &AppState, chars: usize, model: &str) -> Re
515524 Err ( _) => Ok ( ( ) ) , // 余额查询失败 → 放行(宽容降级,费用由确认弹窗展示)
516525 }
517526}
527+
528+ #[ cfg( test) ]
529+ mod tests {
530+ use super :: * ;
531+
532+ /// v0.12.3 回归(Bug#2):WorkbenchData 顶层键必须 camelCase——
533+ /// 前端按 ruleMarkdown/refinedMarkdown 读取;snake_case 键使字段为
534+ /// undefined → renderMd(undefined).split 抛
535+ /// "Cannot read properties of undefined (reading 'split')"
536+ /// (原实现缺失 rename_all,v0.11.5 引入、真机验收漏测)。
537+ #[ test]
538+ fn workbench_data_serializes_camel_case_top_level ( ) {
539+ let data = WorkbenchData {
540+ rule_markdown : "# 标题\n 正文" . to_string ( ) ,
541+ refined_markdown : Some ( "# 标题\n 精修正文" . to_string ( ) ) ,
542+ sections : vec ! [ crate :: note_diff:: SectionDiff {
543+ heading: "标题" . to_string( ) ,
544+ status: crate :: note_diff:: DiffStatus :: Modified ,
545+ removed_lines: vec![ "正文" . to_string( ) ] ,
546+ added_lines: vec![ "精修正文" . to_string( ) ] ,
547+ } ] ,
548+ stats : crate :: note_diff:: DiffStats {
549+ added : 1 ,
550+ removed : 1 ,
551+ unchanged : 0 ,
552+ } ,
553+ meta : Some ( crate :: note_version:: VersionMeta {
554+ cost_yuan : Some ( 0.01 ) ,
555+ model : Some ( "test-model" . to_string ( ) ) ,
556+ slices : Some ( 1 ) ,
557+ merged_from : None ,
558+ } ) ,
559+ } ;
560+ let v = serde_json:: to_value ( & data) . expect ( "序列化失败" ) ;
561+ let obj = v. as_object ( ) . expect ( "应为 JSON 对象" ) ;
562+ assert ! ( obj. contains_key( "ruleMarkdown" ) , "顶层键必须为 ruleMarkdown(camelCase)" ) ;
563+ assert ! ( obj. contains_key( "refinedMarkdown" ) , "顶层键必须为 refinedMarkdown(camelCase)" ) ;
564+ assert ! ( obj. contains_key( "sections" ) ) ;
565+ assert ! ( obj. contains_key( "stats" ) ) ;
566+ assert ! ( obj. contains_key( "meta" ) ) ;
567+ // 嵌套 SectionDiff 保持 snake_case(首次出现即定的契约,勿随顶层 rename)
568+ let sec = & obj[ "sections" ] [ 0 ] ;
569+ assert ! ( sec. get( "removed_lines" ) . is_some( ) , "SectionDiff 嵌套键保持 snake_case" ) ;
570+ assert ! ( sec. get( "added_lines" ) . is_some( ) ) ;
571+ assert_eq ! ( sec[ "status" ] , "modified" ) ;
572+ }
573+ }
0 commit comments