task-217: 快照对比工具(ResponseSnapshotTool:规范 JSON/录制/对比/落盘 golden/可读 diff 报告)+ 8 条测试
This commit is contained in:
@@ -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<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 规范化 JSON(key 排序)、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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user