task-1: 建立 Similar ASIN 性能基线夹具 (1000/5000行、图片开关、chunk与payload采样)

新增 SimilarAsinPerfFixture 确定性夹具:按 sourceFileKey+rowIndex 派生字段,
支持 0/1/1000/5000 行、图片开关两种模式、chunk 划分与 payload 字节采样;
空输入返回空、超限(>5000行/非法chunk/空key)抛 IllegalArgumentException。
含 10 个测试覆盖默认路径/批量/幂等/边界/非法输入/依赖失败恢复。
This commit is contained in:
2026-08-29 14:22:07 +08:00
parent 322905607a
commit 2039acdfe6
4 changed files with 1097 additions and 0 deletions
@@ -0,0 +1,198 @@
package com.nanri.aiimage.modules.similarasin.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Task 1Similar ASIN 性能基线夹具(1000/5000 行、图片开关、chunk 数与 payload 大小采样)。
* 先写测试确认 RED,再实现 SimilarAsinPerfFixture。
*/
class SimilarAsinPerfFixtureTest {
private final SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(new ObjectMapper());
@Test
void test_task_001_payload_chunk_image_normal_default_path() {
// 1000 行、带图片开关,默认 chunk 大小
List<SimilarAsinParsedRowVo> rows = fixture.generateRows("uploads/20260829/base.xlsx", 1000, true);
assertEquals(1000, rows.size());
// 行必须包含 url 图片地址
assertFalse(rows.get(0).getUrl().isBlank());
assertTrue(rows.get(0).getUrl().startsWith("http"));
// rowToken 稳定且唯一
assertEquals(rows.get(0).getRowToken(), fixture.rowTokenFor(rows.get(0).getSourceFileKey(), rows.get(0).getRowIndex()));
assertEquals(1000, rows.stream().map(SimilarAsinParsedRowVo::getRowToken).distinct().count());
List<List<SimilarAsinParsedRowVo>> chunks = fixture.splitChunks(rows, 200);
assertEquals(5, chunks.size());
assertEquals(200, chunks.get(0).size());
SimilarAsinPerfFixture.Metrics metrics = fixture.samplePayload(rows, true, 200);
assertEquals(1000, metrics.rowCount());
assertEquals(5, metrics.chunkCount());
assertTrue(metrics.payloadBytes() > 0, "payload 采样字节数必须大于 0");
}
@Test
void test_task_001_payload_chunk_image_normal_multiple_items() {
// 5000 行批量场景:顺序稳定、chunk 数正确、结果不丢失
List<SimilarAsinParsedRowVo> rows = fixture.generateRows("uploads/20260829/multi.xlsx", 5000, false);
assertEquals(5000, rows.size());
assertTrue(rows.get(0).getUrl().isBlank(), "图片开关关闭时 url 必须为空");
List<List<SimilarAsinParsedRowVo>> chunks = fixture.splitChunks(rows, 200);
assertEquals(25, chunks.size());
// 顺序稳定:拼接后与原始一致
List<SimilarAsinParsedRowVo> restored = chunks.stream().flatMap(List::stream).toList();
assertEquals(rows.size(), restored.size());
for (int i = 0; i < rows.size(); i++) {
assertEquals(rows.get(i).getRowToken(), restored.get(i).getRowToken());
}
// 所有行 rowIndex 递增
for (int i = 0; i < rows.size(); i++) {
assertEquals(i + 1, rows.get(i).getRowIndex());
}
}
@Test
void test_task_001_payload_chunk_image_normal_repeated_operation_is_idempotent() {
List<SimilarAsinParsedRowVo> first = fixture.generateRows("uploads/20260829/idem.xlsx", 1000, true);
List<SimilarAsinParsedRowVo> second = fixture.generateRows("uploads/20260829/idem.xlsx", 1000, true);
// 同一输入重复生成:token 完全一致,不产生重复差异
assertEquals(first.size(), second.size());
for (int i = 0; i < first.size(); i++) {
assertEquals(first.get(i).getRowToken(), second.get(i).getRowToken());
assertEquals(first.get(i).getAsin(), second.get(i).getAsin());
}
// splitChunks 幂等:两次划分 chunk 数一致
assertEquals(fixture.splitChunks(first, 200).size(), fixture.splitChunks(second, 200).size());
// 采样指标幂等
SimilarAsinPerfFixture.Metrics m1 = fixture.samplePayload(first, true, 200);
SimilarAsinPerfFixture.Metrics m2 = fixture.samplePayload(second, true, 200);
assertEquals(m1.payloadBytes(), m2.payloadBytes());
}
@Test
void test_task_001_payload_chunk_image_boundary_empty_input() {
List<SimilarAsinParsedRowVo> rows = fixture.generateRows("uploads/20260829/empty.xlsx", 0, true);
assertNotNull(rows);
assertEquals(0, rows.size());
// 空行 splitChunks 返回空,不产生无效 chunk
assertEquals(0, fixture.splitChunks(rows, 200).size());
// 空行采样:行数 0、chunk 0、payload 字节数为 0(不创建任何载荷)
SimilarAsinPerfFixture.Metrics metrics = fixture.samplePayload(rows, true, 200);
assertEquals(0, metrics.rowCount());
assertEquals(0, metrics.chunkCount());
assertEquals(0, metrics.payloadBytes());
}
@Test
void test_task_001_payload_chunk_image_boundary_single_item() {
// 单行:不依赖批量路径且结果正确
List<SimilarAsinParsedRowVo> rows = fixture.generateRows("uploads/20260829/single.xlsx", 1, true);
assertEquals(1, rows.size());
assertEquals(1, rows.get(0).getRowIndex());
assertEquals(1, fixture.splitChunks(rows, 200).size());
// 单行 chunk 划分后仍只含 1 行
List<List<SimilarAsinParsedRowVo>> chunks = fixture.splitChunks(rows, 200);
assertEquals(1, chunks.get(0).size());
SimilarAsinPerfFixture.Metrics metrics = fixture.samplePayload(rows, true, 200);
assertEquals(1, metrics.rowCount());
assertEquals(1, metrics.chunkCount());
assertTrue(metrics.payloadBytes() > 0);
}
@Test
void test_task_001_payload_chunk_image_boundary_limit_and_overflow() {
// 超过最大行数(5000)时拒绝,不发生无界内存增长
assertThrows(IllegalArgumentException.class, () -> fixture.generateRows("uploads/20260829/overflow.xlsx", 5001, true));
// 达到最大允许值 5000 时允许
assertEquals(5000, fixture.generateRows("uploads/20260829/max.xlsx", 5000, true).size());
// chunkSize 非法值拒绝
assertThrows(IllegalArgumentException.class, () -> fixture.splitChunks(fixture.generateRows("uploads/20260829/a.xlsx", 100, true), 0));
assertThrows(IllegalArgumentException.class, () -> fixture.splitChunks(fixture.generateRows("uploads/20260829/b.xlsx", 100, true), -1));
}
@Test
void test_task_001_payload_chunk_image_invalid_input_rejected() {
// null 文件 key 拒绝
assertThrows(IllegalArgumentException.class, () -> fixture.generateRows(null, 100, true));
// 空白文件 key 拒绝
assertThrows(IllegalArgumentException.class, () -> fixture.generateRows(" ", 100, true));
// 负行数拒绝
assertThrows(IllegalArgumentException.class, () -> fixture.generateRows("uploads/20260829/neg.xlsx", -1, true));
// null 行集合分块拒绝
assertThrows(IllegalArgumentException.class, () -> fixture.splitChunks(null, 200));
}
@Test
void test_task_001_payload_chunk_image_dependency_failure_releases_resources() {
// 采样时序列化器失败(mock 抛异常):错误可恢复,不产生部分结果
ObjectMapper broken = new ObjectMapper() {
@Override
public String writeValueAsString(Object value) {
throw new IllegalStateException("serializer down");
}
};
SimilarAsinPerfFixture failingFixture = new SimilarAsinPerfFixture(broken);
List<SimilarAsinParsedRowVo> rows = failingFixture.generateRows("uploads/20260829/fail.xlsx", 1000, true);
assertThrows(IllegalStateException.class, () -> failingFixture.samplePayload(rows, true, 200));
// 恢复后(换回正常 mapper)仍能正常工作
SimilarAsinPerfFixture.Metrics recovered = fixture.samplePayload(fixture.generateRows("uploads/20260829/recover.xlsx", 1000, true), true, 200);
assertTrue(recovered.payloadBytes() > 0);
assertNotNull(recovered);
// 行对象在失败后仍可复用(不持有任何锁或已关闭资源)
assertTrue(rows.get(0).getAsin().startsWith("B0"));
// 验证图片开关两种模式下行字段差异明确
List<SimilarAsinParsedRowVo> noImg = fixture.generateRows("uploads/20260829/nimg.xlsx", 10, false);
assertTrue(noImg.get(0).getUrl().isBlank());
}
@Test
void test_task_001_payload_chunk_image_normal_fields_populated() {
// 行字段完整性:asin/country/sku/title/values 均填充且稳定
List<SimilarAsinParsedRowVo> rows = fixture.generateRows("uploads/20260829/fields.xlsx", 10, false);
SimilarAsinParsedRowVo row = rows.get(3);
assertTrue(row.getAsin().matches("B0[A-Z0-9]{8}"));
assertFalse(row.getCountry().isBlank());
assertFalse(row.getSku().isBlank());
assertFalse(row.getTitle().isBlank());
assertNotNull(row.getValues());
assertFalse(row.getValues().isEmpty());
assertTrue(row.getValues().containsKey("asin"));
assertTrue(row.getValues().containsKey("国家"));
// 行号与 sourceId 关联正确
assertEquals("4", row.getSourceId());
assertEquals(4, row.getRowIndex());
// values 是独立副本,修改不影响后续生成
row.getValues().put("价格", "999");
List<SimilarAsinParsedRowVo> again = fixture.generateRows("uploads/20260829/fields.xlsx", 10, false);
assertTrue(!again.get(3).getValues().getOrDefault("价格", "").equals("999"));
}
@Test
void test_task_001_payload_chunk_image_boundary_chunk_size_edge() {
// chunk 边界:行数恰好整除 / 有余数 / 单 chunk 放不下
List<SimilarAsinParsedRowVo> rows = fixture.generateRows("uploads/20260829/edge.xlsx", 100, false);
// 100 行 / 40 → 3 chunks40+40+20
List<List<SimilarAsinParsedRowVo>> chunks = fixture.splitChunks(rows, 40);
assertEquals(3, chunks.size());
assertEquals(40, chunks.get(0).size());
assertEquals(20, chunks.get(2).size());
// chunkSize 大于总行数 → 单 chunk
assertEquals(1, fixture.splitChunks(rows, 500).size());
// chunkSize 恰好等于行数 → 单 chunk 全量
assertEquals(1, fixture.splitChunks(rows, 100).size());
assertEquals(100, fixture.splitChunks(rows, 100).get(0).size());
}
}