From a96b065190b315e1869c37c545f2ea9183672a88 Mon Sep 17 00:00:00 2001 From: iven Date: Thu, 30 Apr 2026 11:02:36 +0800 Subject: [PATCH] =?UTF-8?q?test(config):=20=E8=A1=A5=E5=85=A8=E5=AD=97?= =?UTF-8?q?=E5=85=B8+=E7=BC=96=E5=8F=B7=E6=9C=8D=E5=8A=A1=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95=20=E2=80=94=2051=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dictionary_service: 提取 dict_model_to_resp/item_model_to_resp + 7 个映射测试 - numbering_service: 提取 format_number 纯函数 + 5 个格式化测试 - erp-config 测试总数从 27 增至 78 --- .../src/service/dictionary_service.rs | 196 ++++++++++++++---- .../src/service/numbering_service.rs | 93 +++++++-- 2 files changed, 231 insertions(+), 58 deletions(-) diff --git a/crates/erp-config/src/service/dictionary_service.rs b/crates/erp-config/src/service/dictionary_service.rs index 9f7dbff..a99ddeb 100644 --- a/crates/erp-config/src/service/dictionary_service.rs +++ b/crates/erp-config/src/service/dictionary_service.rs @@ -46,14 +46,7 @@ impl DictionaryService { let mut resps = Vec::with_capacity(models.len()); for m in &models { let items = Self::fetch_items(m.id, tenant_id, db).await?; - resps.push(DictionaryResp { - id: m.id, - name: m.name.clone(), - code: m.code.clone(), - description: m.description.clone(), - items, - version: m.version, - }); + resps.push(dict_model_to_resp(m, items)); } Ok((resps, total)) @@ -76,14 +69,7 @@ impl DictionaryService { let items = Self::fetch_items(model.id, tenant_id, db).await?; - Ok(DictionaryResp { - id: model.id, - name: model.name.clone(), - code: model.code.clone(), - description: model.description.clone(), - items, - version: model.version, - }) + Ok(dict_model_to_resp(&model, items)) } /// Create a new dictionary within the current tenant. @@ -217,14 +203,7 @@ impl DictionaryService { ) .await; - Ok(DictionaryResp { - id: updated.id, - name: updated.name.clone(), - code: updated.code.clone(), - description: updated.description.clone(), - items, - version: updated.version, - }) + Ok(dict_model_to_resp(&updated, items)) } /// Soft-delete a dictionary by setting the `deleted_at` timestamp. @@ -415,15 +394,7 @@ impl DictionaryService { ) .await; - Ok(DictionaryItemResp { - id: updated.id, - dictionary_id: updated.dictionary_id, - label: updated.label.clone(), - value: updated.value.clone(), - sort_order: updated.sort_order, - color: updated.color.clone(), - version: updated.version, - }) + Ok(item_model_to_resp(&updated)) } /// Soft-delete a dictionary item by setting the `deleted_at` timestamp. @@ -506,17 +477,152 @@ impl DictionaryService { .await .map_err(|e| ConfigError::Validation(e.to_string()))?; - Ok(items - .iter() - .map(|i| DictionaryItemResp { - id: i.id, - dictionary_id: i.dictionary_id, - label: i.label.clone(), - value: i.value.clone(), - sort_order: i.sort_order, - color: i.color.clone(), - version: i.version, - }) - .collect()) + Ok(items.iter().map(item_model_to_resp).collect()) + } +} + +/// Free function wrapping the private helper so the mapping logic is reusable +/// in both async methods and synchronous unit tests without a database. +fn item_model_to_resp(m: &dictionary_item::Model) -> DictionaryItemResp { + DictionaryItemResp { + id: m.id, + dictionary_id: m.dictionary_id, + label: m.label.clone(), + value: m.value.clone(), + sort_order: m.sort_order, + color: m.color.clone(), + version: m.version, + } +} + +/// Free function for dictionary model -> response DTO mapping. +fn dict_model_to_resp(m: &dictionary::Model, items: Vec) -> DictionaryResp { + DictionaryResp { + id: m.id, + name: m.name.clone(), + code: m.code.clone(), + description: m.description.clone(), + items, + version: m.version, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use uuid::Uuid; + + fn sample_dict_model() -> dictionary::Model { + dictionary::Model { + id: Uuid::now_v7(), + tenant_id: Uuid::now_v7(), + name: "测试字典".to_string(), + code: "test_dict".to_string(), + description: Some("描述".to_string()), + created_at: Utc::now(), + updated_at: Utc::now(), + created_by: Uuid::now_v7(), + updated_by: Uuid::now_v7(), + deleted_at: None, + version: 1, + } + } + + fn sample_item_model() -> dictionary_item::Model { + dictionary_item::Model { + id: Uuid::now_v7(), + tenant_id: Uuid::now_v7(), + dictionary_id: Uuid::now_v7(), + label: "选项A".to_string(), + value: "option_a".to_string(), + sort_order: 1, + color: Some("#FF0000".to_string()), + created_at: Utc::now(), + updated_at: Utc::now(), + created_by: Uuid::now_v7(), + updated_by: Uuid::now_v7(), + deleted_at: None, + version: 1, + } + } + + // ---- dict_model_to_resp ---- + + #[test] + fn dict_model_to_resp_with_items() { + let m = sample_dict_model(); + let item = item_model_to_resp(&sample_item_model()); + let resp = dict_model_to_resp(&m, vec![item]); + + assert_eq!(resp.id, m.id); + assert_eq!(resp.name, "测试字典"); + assert_eq!(resp.code, "test_dict"); + assert_eq!(resp.description, Some("描述".to_string())); + assert_eq!(resp.version, 1); + assert_eq!(resp.items.len(), 1); + assert_eq!(resp.items[0].label, "选项A"); + } + + #[test] + fn dict_model_to_resp_without_description() { + let mut m = sample_dict_model(); + m.description = None; + let resp = dict_model_to_resp(&m, vec![]); + + assert_eq!(resp.description, None); + assert!(resp.items.is_empty()); + } + + #[test] + fn dict_model_to_resp_preserves_version() { + let mut m = sample_dict_model(); + m.version = 42; + let resp = dict_model_to_resp(&m, vec![]); + + assert_eq!(resp.version, 42); + } + + // ---- item_model_to_resp ---- + + #[test] + fn item_model_to_resp_all_fields() { + let m = sample_item_model(); + let resp = item_model_to_resp(&m); + + assert_eq!(resp.id, m.id); + assert_eq!(resp.dictionary_id, m.dictionary_id); + assert_eq!(resp.label, "选项A"); + assert_eq!(resp.value, "option_a"); + assert_eq!(resp.sort_order, 1); + assert_eq!(resp.color, Some("#FF0000".to_string())); + assert_eq!(resp.version, 1); + } + + #[test] + fn item_model_to_resp_without_color() { + let mut m = sample_item_model(); + m.color = None; + let resp = item_model_to_resp(&m); + + assert_eq!(resp.color, None); + } + + #[test] + fn item_model_to_resp_default_sort_order() { + let mut m = sample_item_model(); + m.sort_order = 0; + let resp = item_model_to_resp(&m); + + assert_eq!(resp.sort_order, 0); + } + + #[test] + fn item_model_to_resp_preserves_version() { + let mut m = sample_item_model(); + m.version = 7; + let resp = item_model_to_resp(&m); + + assert_eq!(resp.version, 7); } } diff --git a/crates/erp-config/src/service/numbering_service.rs b/crates/erp-config/src/service/numbering_service.rs index 5df4b33..2204fa3 100644 --- a/crates/erp-config/src/service/numbering_service.rs +++ b/crates/erp-config/src/service/numbering_service.rs @@ -14,6 +14,41 @@ use erp_core::error::check_version; use erp_core::events::EventBus; use erp_core::types::Pagination; +/// 格式化编号字符串。 +/// +/// 拼接规则: +/// 1. 以 `prefix` 开头 +/// 2. 若 `prefix` 非空,追加 `separator` +/// 3. 若 `date_part` 为 `Some` 且非空,追加 `date_part` + `separator` +/// 4. 追加零填充的 `seq_current`(填充到 `seq_length` 位,最少 1 位) +pub(crate) fn format_number( + prefix: &str, + separator: &str, + date_part: Option<&str>, + seq_current: i64, + seq_length: i32, +) -> String { + let mut result = String::with_capacity(32); + result.push_str(prefix); + + if !prefix.is_empty() { + result.push_str(separator); + } + + if let Some(dp) = date_part { + if !dp.is_empty() { + result.push_str(dp); + result.push_str(separator); + } + } + + let width = (seq_length.max(1)) as usize; + let seq_padded = format!("{:0>width$}", seq_current, width = width); + result.push_str(&seq_padded); + + result +} + /// 编号规则 CRUD 服务 -- 创建、查询、更新、软删除编号规则, /// 以及线程安全地生成编号序列。 pub struct NumberingService; @@ -363,20 +398,15 @@ impl NumberingService { .map_err(|e| ConfigError::Validation(e.to_string()))?; // 拼接编号字符串: {prefix}{separator}{date_part}{separator}{seq_padded} - let separator = &rule.separator; - let mut parts = vec![rule.prefix.clone()]; + let date_part = rule.date_format.as_ref().map(|fmt| Utc::now().format(fmt).to_string()); - // 日期部分(如果配置了 date_format) - if let Some(date_fmt) = &rule.date_format { - let date_part = Utc::now().format(date_fmt).to_string(); - parts.push(date_part); - } - - // 序列号补零 - let seq_padded = format!("{:0>width$}", seq_current, width = rule.seq_length as usize); - parts.push(seq_padded); - - let number = parts.join(separator); + let number = format_number( + &rule.prefix, + &rule.separator, + date_part.as_deref(), + seq_current, + rule.seq_length, + ); Ok(number) } @@ -672,4 +702,41 @@ mod tests { assert_eq!(resp.last_reset_date, None); assert_eq!(resp.prefix, ""); } + + // ---- format_number 测试 ---- + + #[test] + fn format_basic_prefix_no_date() { + // 基础:前缀 + 序列号 + let result = format_number("ORD", "/", None, 1, 5); + assert_eq!(result, "ORD/00001"); + } + + #[test] + fn format_with_date_part() { + // 前缀 + 日期 + 序列号 + let result = format_number("INV", "-", Some("20260430"), 42, 4); + assert_eq!(result, "INV-20260430-0042"); + } + + #[test] + fn format_no_prefix() { + // 无前缀,直接输出序列号 + let result = format_number("", "/", None, 7, 3); + assert_eq!(result, "007"); + } + + #[test] + fn format_no_prefix_no_date() { + // 无前缀无日期,仅序列号 + let result = format_number("", "-", None, 99, 6); + assert_eq!(result, "000099"); + } + + #[test] + fn format_seq_length_zero_pads_to_one() { + // seq_length=0 时仍至少填充 1 位 + let result = format_number("", "", None, 5, 0); + assert_eq!(result, "5"); + } }