package com.xly.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.xly.agent.AgentIdentity; import org.junit.jupiter.api.Test; import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; /** * 值规范化的**所见即所写**性质(2026-07-29 复审 HIGH 修复的回归锁): * FK 多条命中必须报错列候选(绝不静默绑最短)、唯一精确同名优先、shown=实际绑定的记录名; * 幻影列(字段字典有、物理表没有)必须整体拒绝,不得落到 varchar 分支跳过类型校验。 */ class FormRenderNormalizeTest { private static final AgentIdentity WHO = new AgentIdentity("tok", "u1", "brand", "sub", null); private FormRenderService svc(FormResolverService resolver) { return new FormRenderService(null, resolver, null, new ObjectMapper()); } private FormResolverService resolverWith(Map types, List> fkCands) { FormResolverService r = mock(FormResolverService.class); when(r.columnTypes(anyString())).thenReturn(types); when(r.resolveFkCandidates(anyString(), anyString(), anyString(), anyInt())).thenReturn(fkCands); return r; } @Test void ambiguousFkNameIsRejectedWithCandidates() { FormResolverService r = resolverWith(Map.of("sCustomerId", "varchar"), List.of( Map.of("sId", "id1", "name", "常州印刷"), Map.of("sId", "id2", "name", "金陵印刷"), Map.of("sId", "id3", "name", "连云港印刷"))); FormRenderService.Normalized n = svc(r).normalize("t", "sCustomerId", "elecustomer", "客户名称", "印刷", WHO); assertNotNull(n.error, "多条命中必须报错,绝不静默绑最短匹配"); assertTrue(n.error.contains("常州印刷") && n.error.contains("金陵印刷"), "报错列出候选供用户挑"); assertNull(n.stored); } @Test void duplicateExactNamesAreRejectedNotFirstPicked() { // 本地库实证:同品牌下「普通彩盒」有 10 条——完全同名时名称无从区分,绝不静默绑第一条 FormResolverService r = resolverWith(Map.of("sProductId", "varchar"), List.of( Map.of("sId", "prodA", "name", "普通彩盒"), Map.of("sId", "prodB", "name", "普通彩盒"))); FormRenderService.Normalized n = svc(r).normalize("t", "sProductId", "eleproduct", "产品名称", "普通彩盒", WHO); assertNotNull(n.error, "同名多条必须报错——shown 恰好等于原话,卡片看不出绑错了谁"); assertTrue(n.error.contains("同名")); assertNull(n.stored); } @Test void exactNameWinsOverShorterFuzzyMatches() { FormResolverService r = resolverWith(Map.of("sCustomerId", "varchar"), List.of( Map.of("sId", "idShort", "name", "印刷"), Map.of("sId", "idExact", "name", "金陵印刷"))); FormRenderService.Normalized n = svc(r).normalize("t", "sCustomerId", "elecustomer", "客户名称", "金陵印刷", WHO); assertNull(n.error); assertEquals("idExact", n.stored, "唯一精确同名直接命中"); assertEquals("金陵印刷", n.shown); } @Test void uniquePartialMatchShowsResolvedRealName() { FormResolverService r = resolverWith(Map.of("sCustomerId", "varchar"), List.of( Map.of("sId", "id9", "name", "姑苏印务"))); FormRenderService.Normalized n = svc(r).normalize("t", "sCustomerId", "elecustomer", "客户名称", "姑苏", WHO); assertNull(n.error); assertEquals("id9", n.stored); assertEquals("姑苏印务", n.shown, "卡片显示真正要绑定的记录名,而非用户原话"); } @Test void phantomColumnIsRejectedNotCoercedAsVarchar() { // 字段字典解析出的列不在物理表:不得落 varchar 分支静默接受「五千」这类值 FormResolverService r = resolverWith(Map.of("dQty", "decimal"), List.of()); FormRenderService.Normalized n = svc(r).normalize("t", "sGhostCol", null, "数量", "五千", WHO); assertNotNull(n.error); assertTrue(n.error.contains("不是") && n.error.contains("可写字段")); } @Test void numericColumnStillCoerces() { FormResolverService real = mock(FormResolverService.class); when(real.columnTypes(anyString())).thenReturn(Map.of("dQty", "decimal")); when(real.coerce(eq("decimal"), anyString())).thenCallRealMethod(); FormRenderService.Normalized ok = svc(real).normalize("t", "dQty", null, "数量", "5,000", WHO); assertNull(ok.error); assertEquals("5000", String.valueOf(ok.stored)); FormRenderService.Normalized bad = svc(real).normalize("t", "dQty", null, "数量", "五千", WHO); assertNotNull(bad.error, "数值列拒绝非数字"); } }