package cn.lihu.jh.module.ecg.service.appointment;
|
|
import org.junit.Test;
|
|
import java.lang.reflect.Method;
|
import java.util.*;
|
|
import static org.junit.Assert.*;
|
|
/**
|
* 回归测试:HIS 报文解析的**类型安全导航**。
|
*
|
* <h3>背景</h3>
|
* 2026-09-28 生产日志出现 **287 次** {@code ClassCastException:
|
* class java.lang.String cannot be cast to class java.util.Map},
|
* 集中在 {@code extractCheckCodeFromComponent2} 等方法的链式强制转换上。
|
*
|
* <p>原因是 HIS 报文经 Jackson 反序列化后**同一字段类型并不稳定**:
|
* {@code observationRequest.code} 在部分报文中是 {@code Map}
|
* ({@code code={code=7,...}}),在另一些报文中是**空字符串**({@code code=})。
|
* 链式 {@code .map(m -> (Map<String,Object>) m.get("code"))} 遇到字符串即抛异常,
|
* **导致整条消息处理中断**(预约时间、房间等字段写不进库)。
|
*
|
* <p>本测试锁定修复后的行为:遇到任意类型混合都返回 null 而**不抛异常**。
|
*/
|
public class HisParseSafetyTest {
|
|
/** 构造典型报文片段:{@code code=} 为空字符串 */
|
private static Map<String, Object> payloadWithEmptyStringCode() {
|
Map<String, Object> codeLeaf = new LinkedHashMap<>();
|
codeLeaf.put("code", ""); // ← 关键:空字符串而非 Map
|
codeLeaf.put("displayName", mapOf("value", "检查结束"));
|
|
Map<String, Object> obs = new LinkedHashMap<>();
|
obs.put("id", mapOf("item", mapOf("extension", "2126092800574")));
|
obs.put("code", ""); // ← 关键:observationRequest.code 是空字符串
|
obs.put("component1", mapOf("processStep", mapOf("code", codeLeaf)));
|
obs.put("component2", mapOf("observationRequest", mapOf("code", "")));
|
|
return mapOf("controlActProcess",
|
mapOf("subject", mapOf("placerGroup", mapOf("component2", obs))));
|
}
|
|
/** 正常形态:{@code code} 是 Map(按 S0201ECG 真实结构构造) */
|
private static Map<String, Object> payloadWithMapCode() {
|
// 检查状态:component2.observationRequest.component1.processStep.code.code
|
Map<String, Object> statusCode = mapOf(
|
"code", "7",
|
"codeSystem", "2.16.156.10011.2.5.1.12",
|
"displayName", mapOf("value", "检查结束"));
|
Map<String, Object> processStep = mapOf("code", statusCode);
|
Map<String, Object> component1 = mapOf("processStep", processStep);
|
|
// 检查项目:component2.observationRequest.code.code
|
Map<String, Object> checkCode = mapOf(
|
"code", "Y1202065",
|
"displayName", mapOf("value", "床边心电图(常规+频谱)"));
|
|
Map<String, Object> innerObs = mapOf(
|
"id", mapOf("item", mapOf("root", "2.16.156.10011.1.28", "extension", "113034157")),
|
"code", checkCode,
|
"component1", component1);
|
|
Map<String, Object> component2 = mapOf("observationRequest", innerObs);
|
|
Map<String, Object> outerObs = mapOf("component2", component2);
|
|
return mapOf("controlActProcess",
|
mapOf("subject",
|
mapOf("observationRequest", outerObs)));
|
}
|
|
private static Map<String, Object> mapOf(Object... kv) {
|
Map<String, Object> m = new LinkedHashMap<>();
|
for (int i = 0; i + 1 < kv.length; i += 2) {
|
m.put((String) kv[i], kv[i + 1]);
|
}
|
return m;
|
}
|
|
/** 反射调用私有的 dig 方法 */
|
private static Map<String, Object> invokeDig(Map<String, Object> root, String... path) throws Exception {
|
Method m = AppointmentServiceImpl.class.getDeclaredMethod("dig", Map.class, String[].class);
|
m.setAccessible(true);
|
@SuppressWarnings("unchecked")
|
Map<String, Object> r = (Map<String, Object>) m.invoke(null, root, path);
|
return r;
|
}
|
|
/** 反射调用私有的 digText 方法 */
|
private static String invokeDigText(Map<String, Object> root, String... path) throws Exception {
|
Method m = AppointmentServiceImpl.class.getDeclaredMethod("digText", Map.class, String[].class);
|
m.setAccessible(true);
|
return (String) m.invoke(null, root, path);
|
}
|
|
// ==================== 核心:不得抛 ClassCastException ====================
|
|
@Test
|
public void testDigText_emptyStringInPath_shouldReturnNullNotThrow() throws Exception {
|
Map<String, Object> payload = payloadWithEmptyStringCode();
|
// code 是空字符串 → 应安全返回 null,而不是抛 ClassCastException
|
String v = invokeDigText(payload, "controlActProcess", "subject", "placerGroup",
|
"component2", "observationRequest", "code", "code");
|
assertNull(v);
|
}
|
|
@Test
|
public void testDigText_stringWhereMapExpected_shouldNotThrow() throws Exception {
|
// 整条路径上放字符串,模拟最恶劣的形态
|
Map<String, Object> payload = mapOf("controlActProcess", "I-AM-A-STRING");
|
assertNull(invokeDigText(payload, "controlActProcess", "subject", "placerGroup"));
|
}
|
|
@Test
|
public void testDig_emptyStringInPath_shouldReturnNullNotThrow() throws Exception {
|
Map<String, Object> payload = payloadWithEmptyStringCode();
|
assertNull(invokeDig(payload, "controlActProcess", "subject", "placerGroup",
|
"component2", "observationRequest", "component1", "processStep", "code", "ZDY"));
|
}
|
|
@Test
|
public void testDig_nullInPath_shouldReturnNull() throws Exception {
|
assertNull(invokeDig(mapOf("a", null), "a", "b"));
|
assertNull(invokeDig(new LinkedHashMap<>(), "notExist"));
|
}
|
|
@Test
|
public void testDigText_listInPath_shouldTakeFirstElement() throws Exception {
|
// item=[{code=X}] 形态:digText 应自动取首元素继续下钻
|
Map<String, Object> payload = mapOf("methodCode",
|
mapOf("item", Collections.singletonList(mapOf("code", "M001"))));
|
assertEquals("M001", invokeDigText(payload, "methodCode", "item", "code"));
|
}
|
|
@Test
|
public void testDigText_emptyListInPath_shouldReturnNull() throws Exception {
|
Map<String, Object> payload = mapOf("methodCode", mapOf("item", Collections.emptyList()));
|
assertNull(invokeDigText(payload, "methodCode", "item", "code"));
|
}
|
|
@Test
|
public void testDigText_shouldStillReadNormalValues() throws Exception {
|
Map<String, Object> payload = payloadWithMapCode();
|
// 正常形态必须照常读出,不能因加了防御而失效
|
// 与生产代码 extractCheckCodeFromComponent2 使用的是**同样的路径写法**
|
assertEquals("Y1202065", invokeDigText(payload, "controlActProcess", "subject",
|
"observationRequest", "component2", "observationRequest", "code", "code"));
|
}
|
|
@Test
|
public void testDigText_shouldReadProcessStepStatus() throws Exception {
|
// 检查状态码:controlActProcess.subject.observationRequest.component2
|
// .observationRequest.component1.processStep.code.code
|
Map<String, Object> payload = payloadWithMapCode();
|
assertEquals("7", invokeDigText(payload, "controlActProcess", "subject",
|
"observationRequest", "component2", "observationRequest",
|
"component1", "processStep", "code", "code"));
|
}
|
|
@Test
|
public void testDigText_basicTraversal() throws Exception {
|
// 最基础:三层 Map 取值
|
Map<String, Object> payload = mapOf("a", mapOf("b", mapOf("c", "VALUE")));
|
assertEquals("VALUE", invokeDigText(payload, "a", "b", "c"));
|
// 两层
|
assertEquals("X", invokeDigText(mapOf("a", mapOf("b", "X")), "a", "b"));
|
// 一层
|
assertEquals("Y", invokeDigText(mapOf("a", "Y"), "a"));
|
// Map 末端取 value
|
assertEquals("Z", invokeDigText(mapOf("a", mapOf("b", mapOf("value", "Z"))), "a", "b"));
|
}
|
|
@Test
|
public void testDigText_blankString_shouldReturnNull() throws Exception {
|
// 空/纯空白字符串应视为"无值",便于上层用 isEmpty 判断
|
Map<String, Object> payload = mapOf("k", " ");
|
assertNull(invokeDigText(payload, "k"));
|
assertNull(invokeDigText(mapOf("k", ""), "k"));
|
}
|
|
// ==================== 医嘱号提取(findExtensionByRoot) ====================
|
//
|
// 依据 HIS《交互服务通用接口文档》:
|
// 6.1 申请单新增服务 → observationRequest.id.item[root=2.16.156.10011.1.28] 注释为「医嘱ID」
|
// 6.4 检查预约状态服务 → 同一 root 注释为「医嘱号」
|
// 实测 S0201ECG 220 条、S0202ECG 29 条均携带该值(形如 112663505)。
|
//
|
// 关键风险:id.item 在部分服务中是**数组**,同时含
|
// 1.24 申请单号 与 1.28 医嘱号
|
// 若用 digText(遇 List 只取首元素)会把**申请单号当成医嘱号**。
|
// 下列用例锁定「按 root 匹配」的正确行为。
|
|
private static final String ROOT_ORDER_NO = "2.16.156.10011.1.28";
|
private static final String ROOT_APPLY_NO = "2.16.156.10011.1.24";
|
|
/** 反射调用私有的 findExtensionByRoot 方法 */
|
private static String invokeFindExtensionByRoot(Object idNode, String root) throws Exception {
|
Method m = AppointmentServiceImpl.class.getDeclaredMethod("findExtensionByRoot", Object.class, String.class);
|
m.setAccessible(true);
|
return (String) m.invoke(null, idNode, root);
|
}
|
|
@Test
|
public void testFindExtensionByRoot_singleMap_shouldReturnExtension() throws Exception {
|
// 实测 S0201ECG 的真实形态:id={item={root=1.28, extension=112663505}}
|
Object idNode = mapOf("item", mapOf("root", ROOT_ORDER_NO, "extension", "112663505"));
|
assertEquals("112663505", invokeFindExtensionByRoot(idNode, ROOT_ORDER_NO));
|
}
|
|
@Test
|
public void testFindExtensionByRoot_listWithBothRoots_shouldPickOrderNo() throws Exception {
|
// 关键用例:数组同时含 1.24(申请单号) 与 1.28(医嘱号)
|
// digText 会取首元素(申请单号)→ 错;findExtensionByRoot 必须按 root 命中 1.28
|
List<Object> items = Arrays.asList(
|
mapOf("root", ROOT_APPLY_NO, "extension", "2126091700011"),
|
mapOf("root", ROOT_ORDER_NO, "extension", "112663505"));
|
Object idNode = mapOf("item", items);
|
assertEquals("112663505", invokeFindExtensionByRoot(idNode, ROOT_ORDER_NO));
|
}
|
|
@Test
|
public void testFindExtensionByRoot_listWithBothRoots_reverseOrder_shouldStillPickOrderNo() throws Exception {
|
// 顺序颠倒也必须命中(不能依赖"恰好第一个是医嘱号")
|
List<Object> items = Arrays.asList(
|
mapOf("root", ROOT_ORDER_NO, "extension", "112663700"),
|
mapOf("root", ROOT_APPLY_NO, "extension", "2126091700012"));
|
assertEquals("112663700", invokeFindExtensionByRoot(mapOf("item", items), ROOT_ORDER_NO));
|
}
|
|
@Test
|
public void testFindExtensionByRoot_listWithoutOrderNo_shouldReturnNull() throws Exception {
|
// 只有申请单号时,绝不能把申请单号当医嘱号返回
|
List<Object> items = Collections.singletonList(
|
mapOf("root", ROOT_APPLY_NO, "extension", "2126091700011"));
|
assertNull(invokeFindExtensionByRoot(mapOf("item", items), ROOT_ORDER_NO));
|
}
|
|
@Test
|
public void testFindExtensionByRoot_emptyOrBlankExtension_shouldReturnNull() throws Exception {
|
// 规范中该节点 extension 可为空(6.1 示例即 extension="")→ 应返回 null 而非空串
|
assertNull(invokeFindExtensionByRoot(
|
mapOf("item", mapOf("root", ROOT_ORDER_NO, "extension", "")), ROOT_ORDER_NO));
|
assertNull(invokeFindExtensionByRoot(
|
mapOf("item", mapOf("root", ROOT_ORDER_NO, "extension", " ")), ROOT_ORDER_NO));
|
assertNull(invokeFindExtensionByRoot(
|
mapOf("item", mapOf("root", ROOT_ORDER_NO)), ROOT_ORDER_NO));
|
}
|
|
@Test
|
public void testFindExtensionByRoot_nullAndMalformed_shouldReturnNullNotThrow() throws Exception {
|
assertNull(invokeFindExtensionByRoot(null, ROOT_ORDER_NO));
|
assertNull(invokeFindExtensionByRoot(new LinkedHashMap<>(), ROOT_ORDER_NO));
|
assertNull(invokeFindExtensionByRoot(mapOf("item", null), ROOT_ORDER_NO));
|
assertNull(invokeFindExtensionByRoot(mapOf("item", Collections.emptyList()), ROOT_ORDER_NO));
|
// item 是字符串(类型不稳定)→ 不得抛异常
|
assertNull(invokeFindExtensionByRoot(mapOf("item", "I-AM-A-STRING"), ROOT_ORDER_NO));
|
// root 缺失
|
assertNull(invokeFindExtensionByRoot(mapOf("item", mapOf("extension", "X")), ROOT_ORDER_NO));
|
// root 为 null
|
assertNull(invokeFindExtensionByRoot(mapOf("item", mapOf("root", null, "extension", "X")), ROOT_ORDER_NO));
|
}
|
|
@Test
|
public void testFindExtensionByRoot_listContainingNonMap_shouldSkipGracefully() throws Exception {
|
List<Object> items = Arrays.asList(
|
"NOT-A-MAP",
|
mapOf("root", ROOT_ORDER_NO, "extension", "112663932"));
|
assertEquals("112663932", invokeFindExtensionByRoot(mapOf("item", items), ROOT_ORDER_NO));
|
}
|
|
@Test
|
public void testFindExtensionByRoot_rootWithWhitespace_shouldStillMatch() throws Exception {
|
// 报文里 root 可能带空白,需 trim 后比较
|
Object idNode = mapOf("item", mapOf("root", " " + ROOT_ORDER_NO + " ", "extension", "112664036"));
|
assertEquals("112664036", invokeFindExtensionByRoot(idNode, ROOT_ORDER_NO));
|
}
|
|
// ======================================================================
|
// 需求2:ISCB「是否床边」解析(zdy 自定义字段区)
|
// ======================================================================
|
|
/** 反射调用私有的 findBedsideFlag(dataMap) */
|
private static String invokeFindBedsideFlag(Map<String, Object> dataMap) throws Exception {
|
Method m = AppointmentServiceImpl.class.getDeclaredMethod("findBedsideFlag", Map.class);
|
m.setAccessible(true);
|
return (String) m.invoke(null, dataMap);
|
}
|
|
/** 按真实 S0201ECG 结构构造含 zdy 的报文 */
|
private static Map<String, Object> payloadWithZdy(Map<String, Object> zdyContent) {
|
Map<String, Object> obs = new LinkedHashMap<>();
|
obs.put("zdy", zdyContent);
|
return mapOf("controlActProcess", mapOf("subject", mapOf("observationRequest", obs)));
|
}
|
|
@Test
|
public void testFindBedsideFlag_presentZero_shouldReturnZero() throws Exception {
|
// 实测真实报文中 ISCB 恒为 "0"
|
Map<String, Object> zdy = new LinkedHashMap<>();
|
zdy.put("ISCB", "0");
|
zdy.put("ISDN", "0");
|
assertEquals("0", invokeFindBedsideFlag(payloadWithZdy(zdy)));
|
}
|
|
@Test
|
public void testFindBedsideFlag_presentOne_shouldReturnOne() throws Exception {
|
Map<String, Object> zdy = new LinkedHashMap<>();
|
zdy.put("ISCB", "1");
|
assertEquals("1", invokeFindBedsideFlag(payloadWithZdy(zdy)));
|
}
|
|
@Test
|
public void testFindBedsideFlag_absent_shouldReturnNull() throws Exception {
|
// 官方文档定义的 19 个字段里没有 ISCB —— 这类报文应返回 null(不写库)
|
Map<String, Object> zdy = new LinkedHashMap<>();
|
zdy.put("jjbz", "N");
|
zdy.put("babyFlag", "0");
|
assertNull(invokeFindBedsideFlag(payloadWithZdy(zdy)));
|
}
|
|
@Test
|
public void testFindBedsideFlag_emptyString_shouldReturnNull() throws Exception {
|
Map<String, Object> zdy = new LinkedHashMap<>();
|
zdy.put("ISCB", "");
|
assertNull("空字符串应视作未提供", invokeFindBedsideFlag(payloadWithZdy(zdy)));
|
|
Map<String, Object> zdy2 = new LinkedHashMap<>();
|
zdy2.put("ISCB", " ");
|
assertNull("纯空白应视作未提供", invokeFindBedsideFlag(payloadWithZdy(zdy2)));
|
}
|
|
@Test
|
public void testFindBedsideFlag_nonStringValue_shouldToString() throws Exception {
|
// Jackson 反序列化后标量类型可能是 Integer/Boolean,需稳妥 toString
|
Map<String, Object> zdy = new LinkedHashMap<>();
|
zdy.put("ISCB", 1);
|
assertEquals("1", invokeFindBedsideFlag(payloadWithZdy(zdy)));
|
|
Map<String, Object> zdy2 = new LinkedHashMap<>();
|
zdy2.put("ISCB", 0);
|
assertEquals("0", invokeFindBedsideFlag(payloadWithZdy(zdy2)));
|
}
|
|
@Test
|
public void testFindBedsideFlag_whitespaceAroundValue_shouldTrim() throws Exception {
|
Map<String, Object> zdy = new LinkedHashMap<>();
|
zdy.put("ISCB", " 1 ");
|
assertEquals("1", invokeFindBedsideFlag(payloadWithZdy(zdy)));
|
}
|
|
@Test
|
public void testFindBedsideFlag_nullOrMalformedPayload_shouldReturnNullWithoutThrow() throws Exception {
|
// 类型不稳定是 HIS 报文的常态(历史上曾因此抛 ClassCastException 中断整条消息),
|
// 这里必须**不抛异常**
|
assertNull(invokeFindBedsideFlag(null));
|
assertNull(invokeFindBedsideFlag(mapOf()));
|
assertNull("controlActProcess 是字符串", invokeFindBedsideFlag(mapOf("controlActProcess", "x")));
|
assertNull("subject 是空字符串", invokeFindBedsideFlag(
|
mapOf("controlActProcess", mapOf("subject", ""))));
|
assertNull("observationRequest 缺失", invokeFindBedsideFlag(
|
mapOf("controlActProcess", mapOf("subject", mapOf()))));
|
assertNull("zdy 是字符串而非 Map", invokeFindBedsideFlag(
|
mapOf("controlActProcess", mapOf("subject",
|
mapOf("observationRequest", mapOf("zdy", "not-a-map"))))));
|
}
|
|
@Test
|
public void testFindBedsideFlag_realisticFullZdy_shouldReturnValue() throws Exception {
|
// 真实报文的 zdy 有 26 个字段(文档只定义 19 个),按日志原样构造关键部分
|
Map<String, Object> zdy = new LinkedHashMap<>();
|
zdy.put("ApplyTotalFee", "19");
|
zdy.put("Urgency", "");
|
zdy.put("PayStatus", "1");
|
zdy.put("HospitalCode", "01");
|
zdy.put("HospitalName", "金华市人民医院");
|
zdy.put("jjbz", "N");
|
zdy.put("babyFlag", "0");
|
zdy.put("ISCB", "1"); // ← 需求2 关注的字段
|
zdy.put("ISDN", "0");
|
zdy.put("BLLSH", "3054365");
|
zdy.put("SWCJBS", "0");
|
zdy.put("KDYSSJH", "15158660102");
|
zdy.put("KLX", "");
|
zdy.put("VTEGWBZ", "0");
|
assertEquals("1", invokeFindBedsideFlag(payloadWithZdy(zdy)));
|
}
|
}
|