From 2304e634eec3f84954de789ccaa0387eef8c8aa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sat, 5 Sep 2026 00:25:40 +0800 Subject: [PATCH] =?UTF-8?q?task-217:=20=E5=BF=AB=E7=85=A7=E5=AF=B9?= =?UTF-8?q?=E6=AF=94=E5=B7=A5=E5=85=B7=EF=BC=88ResponseSnapshotTool?= =?UTF-8?q?=EF=BC=9A=E8=A7=84=E8=8C=83=20JSON/=E5=BD=95=E5=88=B6/=E5=AF=B9?= =?UTF-8?q?=E6=AF=94/=E8=90=BD=E7=9B=98=20golden/=E5=8F=AF=E8=AF=BB=20diff?= =?UTF-8?q?=20=E6=8A=A5=E5=91=8A=EF=BC=89+=208=20=E6=9D=A1=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../testutil/ResponseSnapshotTool.java | 96 ++++++++++++++++ .../testutil/ResponseSnapshotToolTest.java | 104 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 backend-java/src/test/java/com/nanri/aiimage/testutil/ResponseSnapshotTool.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/testutil/ResponseSnapshotToolTest.java diff --git a/backend-java/src/test/java/com/nanri/aiimage/testutil/ResponseSnapshotTool.java b/backend-java/src/test/java/com/nanri/aiimage/testutil/ResponseSnapshotTool.java new file mode 100644 index 00000000..b2592061 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/testutil/ResponseSnapshotTool.java @@ -0,0 +1,96 @@ +package com.nanri.aiimage.testutil; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * 快照对比工具(task-217,module 05 各 Service 拆分前后响应一致性回归用)。 + * + * 录制:把"输入→输出"对象序列化为规范 JSON(Map 按 key 排序)存 golden 文件; + * 对比:对重构后的实际输出做同一规范化并与 golden 逐行对比,输出可读 diff。 + * 纯只读工具,无副作用;供拆分回归测试使用。 + */ +public final class ResponseSnapshotTool { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + static { + MAPPER.enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); + } + + private ResponseSnapshotTool() { + } + + /** 规范 JSON:Map key 排序、pretty print,用于稳定快照。 */ + public static String canonical(Object value) { + if (value == null) { + return "null"; + } + try { + return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(value); + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { + throw new IllegalArgumentException("快照序列化失败: " + e.getMessage(), e); + } + } + + /** 两个对象规范化后是否一致(忽略字段顺序)。 */ + public static boolean equalsJson(Object expected, Object actual) { + return canonical(expected).equals(canonical(actual)); + } + + /** 录制 golden 到文件(父目录自动创建)。 */ + public static void saveGolden(Path dir, String name, Object output) throws IOException { + Path file = dir.resolve(name + ".json"); + Files.createDirectories(dir); + Files.writeString(file, canonical(output)); + } + + public static String loadGolden(Path dir, String name) throws IOException { + return Files.readString(dir.resolve(name + ".json")); + } + + /** + * 生成对比报告:一致返回空串;不一致输出 EXPECTED/ACTUAL 及首个差异行上下文。 + */ + public static String diffReport(String expectedJson, String actualJson) { + if (expectedJson == null || actualJson == null) { + throw new IllegalArgumentException("diffReport 入参不能为 null"); + } + if (expectedJson.equals(actualJson)) { + return ""; + } + List expectedLines = expectedJson.split("\n", -1).length == 0 + ? List.of() : List.of(expectedJson.split("\n", -1)); + List actualLines = List.of(actualJson.split("\n", -1)); + int first = firstDiff(expectedLines, actualLines); + StringBuilder sb = new StringBuilder(); + sb.append("快照不一致:首个差异在第 ").append(Math.max(1, first + 1)).append(" 行\n"); + sb.append("--- EXPECTED ---\n").append(snippet(expectedLines, first)); + sb.append("\n--- ACTUAL ---\n").append(snippet(actualLines, first)); + return sb.toString(); + } + + private static int firstDiff(List a, List b) { + int n = Math.min(a.size(), b.size()); + for (int i = 0; i < n; i++) { + if (!a.get(i).equals(b.get(i))) { + return i; + } + } + return n; + } + + private static String snippet(List lines, int around) { + List out = new ArrayList<>(); + for (int i = Math.max(0, around - 1); i <= Math.min(lines.size() - 1, around + 1); i++) { + out.add((i == around ? ">> " : " ") + lines.get(i)); + } + return String.join("\n", out); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/testutil/ResponseSnapshotToolTest.java b/backend-java/src/test/java/com/nanri/aiimage/testutil/ResponseSnapshotToolTest.java new file mode 100644 index 00000000..f854ec4f --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/testutil/ResponseSnapshotToolTest.java @@ -0,0 +1,104 @@ +package com.nanri.aiimage.testutil; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * task-217:快照对比工具契约。 + * ResponseSnapshotTool 规范化 JSON(key 排序)、record/compare 相等、检出差异、输出可读报告、 + * golden 落盘/读取、可复用、入参非法给清晰错误。 + */ +class ResponseSnapshotToolTest { + + private static Map sample(String id, int count) { + Map m = new LinkedHashMap<>(); + m.put("id", id); + m.put("count", count); + m.put("items", List.of(Map.of("k", "v"))); + return m; + } + + @Test + void canonicalIsDeterministicIgnoringInsertionOrder() { + assertEquals(ResponseSnapshotTool.canonical(sample("a", 1)), + ResponseSnapshotTool.canonical(sample("a", 1))); + Map reversed = new LinkedHashMap<>(); + reversed.put("count", 1); + reversed.put("id", "a"); + reversed.put("items", List.of(Map.of("k", "v"))); + assertEquals(ResponseSnapshotTool.canonical(sample("a", 1)), + ResponseSnapshotTool.canonical(reversed), "规范 JSON 应忽略 key 插入顺序"); + } + + @Test + void equalsJsonDetectsEqualObjects() { + assertTrue(ResponseSnapshotTool.equalsJson(sample("a", 1), sample("a", 1))); + } + + @Test + void equalsJsonDetectsDifference() { + assertFalse(ResponseSnapshotTool.equalsJson(sample("a", 1), sample("a", 2))); + } + + @Test + void diffReportEmptyWhenEqual() { + String exp = ResponseSnapshotTool.canonical(sample("a", 1)); + assertEquals("", ResponseSnapshotTool.diffReport(exp, exp)); + } + + @Test + void diffReportDetectsAndLocatesDifference() { + String exp = ResponseSnapshotTool.canonical(sample("a", 1)); + String act = ResponseSnapshotTool.canonical(sample("a", 999)); + String report = ResponseSnapshotTool.diffReport(exp, act); + assertFalse(report.isEmpty()); + assertTrue(report.contains("EXPECTED"), report); + assertTrue(report.contains("ACTUAL"), report); + } + + @Test + void goldenCanBeSavedAndLoaded() throws IOException { + Path dir = Files.createTempDirectory("snapshot-tool-test"); + try { + ResponseSnapshotTool.saveGolden(dir, "case_a", sample("a", 1)); + Path file = dir.resolve("case_a.json"); + assertTrue(Files.exists(file), "golden 文件应落盘"); + assertEquals(ResponseSnapshotTool.canonical(sample("a", 1)), + ResponseSnapshotTool.loadGolden(dir, "case_a")); + } finally { + deleteTree(dir); + } + } + + @Test + void toolIsReusableAndStable() { + String c1 = ResponseSnapshotTool.canonical(sample("a", 1)); + String c2 = ResponseSnapshotTool.canonical(sample("a", 1)); + assertEquals(c1, c2); + assertFalse(ResponseSnapshotTool.equalsJson(sample("a", 1), Map.of("a", 2))); + } + + @Test + void nullInputGivesClearError() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> ResponseSnapshotTool.diffReport(null, "{}")); + assertTrue(ex.getMessage().contains("null"), "错误信息应明确: " + ex.getMessage()); + } + + private static void deleteTree(Path dir) throws IOException { + try (var stream = Files.walk(dir)) { + stream.sorted(java.util.Comparator.reverseOrder()).forEach(p -> p.toFile().delete()); + } + } +}