task-217: 快照对比工具(ResponseSnapshotTool:规范 JSON/录制/对比/落盘 golden/可读 diff 报告)+ 8 条测试

This commit is contained in:
2026-09-05 00:25:40 +08:00
parent 68b743f18b
commit 2304e634ee
2 changed files with 200 additions and 0 deletions
@@ -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-217module 05 各 Service 拆分前后响应一致性回归用)。
*
* 录制:把"输入→输出"对象序列化为规范 JSONMap 按 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() {
}
/** 规范 JSONMap 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<String> expectedLines = expectedJson.split("\n", -1).length == 0
? List.of() : List.of(expectedJson.split("\n", -1));
List<String> 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<String> a, List<String> 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<String> lines, int around) {
List<String> 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);
}
}
@@ -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 规范化 JSONkey 排序)、record/compare 相等、检出差异、输出可读报告、
* golden 落盘/读取、可复用、入参非法给清晰错误。
*/
class ResponseSnapshotToolTest {
private static Map<String, Object> sample(String id, int count) {
Map<String, Object> 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<String, Object> 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());
}
}
}