task-41: 建立 Collect Data 性能基线夹具(1k/10k 行、多 chunk、品牌检测)

新增 CollectDataPerfFixture:确定性生成 1k/10k 行、多关键词多 chunk 与
品牌检测场景(成功/失败/查询失败/空 ASIN)行集合并采样 payload 字节与
chunk 划分;同一输入重复生成结果一致(幂等),行数/计数/关键词均有上限
校验。8 个用例覆盖正常/批量/幂等/空/单元素/超限/非法输入/序列化失败
降级路径,mvn 全量 681 测试通过。
This commit is contained in:
2026-08-30 12:58:26 +08:00
parent 2ce37f0a1a
commit 33d0f02c55
2 changed files with 311 additions and 0 deletions
@@ -0,0 +1,166 @@
package com.nanri.aiimage.modules.collectdata.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Task 41Collect Data 性能基线夹具。
* 覆盖 1k/10k 行、多个 chunk 与品牌检测(成功/失败/查询失败/空 ASIN)场景的
* 确定性生成与 payload 采样;同一输入重复生成结果一致(幂等),超限与非法
* 输入被拒绝,序列化失败抛可识别异常且不产生部分结果。
*/
class CollectDataPerfFixtureTest {
private final CollectDataPerfFixture fixture =
new CollectDataPerfFixture(new ObjectMapper());
private static final List<String> KEYWORDS = List.of("phone case", "iphone case", "samsung case");
@Test
void test_task_041_chunk_brand_normal_default_path() {
// 正常输入:1000 行 3 关键词 1 个失败品牌行,payload 与 chunk 统计正确。
List<CollectDataResultRowVo> rows =
fixture.generateRows(1000, KEYWORDS, 1, 0, 0);
CollectDataPerfFixture.Metrics metrics = fixture.samplePayload(rows, 200);
assertEquals(1000, rows.size(), "1000 行全部生成");
assertEquals(1000, metrics.rowCount(), "采样行数一致");
assertEquals(5, metrics.chunkCount(), "200 行一个 chunk 共 5 个");
assertTrue(metrics.payloadBytes() > 0, "payload 非空");
assertEquals(1, metrics.failedBrandRows(), "1 行失败品牌");
assertEquals(0, metrics.queryFailedBrandRows(), "无查询失败品牌");
assertEquals(0, metrics.blankAsinRows(), "无空 ASIN");
assertEquals(0, metrics.blankBrandRows(), "无空品牌");
assertTrue(rows.getFirst().getAsin().startsWith("B0"), "ASIN 确定性生成");
assertTrue(rows.getFirst().getKeyword() != null && !rows.getFirst().getKeyword().isBlank(), "关键词非空");
}
@Test
void test_task_041_chunk_brand_normal_multiple_items() {
// 批量场景:10k 行 5 关键词多 chunk,行数不丢失且按关键词循环顺序稳定。
List<String> keywords = List.of("a", "b", "c", "d", "e");
List<CollectDataResultRowVo> rows =
fixture.generateRows(10000, keywords, 500, 300, 200);
CollectDataPerfFixture.Metrics metrics = fixture.samplePayload(rows, 500);
assertEquals(10000, metrics.rowCount(), "10k 行不丢失");
assertEquals(20, metrics.chunkCount(), "500 行一个 chunk 共 20 个");
assertEquals(500, metrics.failedBrandRows(), "失败品牌行数稳定");
assertEquals(300, metrics.queryFailedBrandRows(), "查询失败品牌行数稳定");
assertEquals(200, metrics.blankAsinRows(), "空 ASIN 行数稳定");
for (int i = 0; i < rows.size(); i++) {
assertEquals(keywords.get(i % keywords.size()), rows.get(i).getKeyword(), "关键词循环分配");
}
}
@Test
void test_task_041_chunk_brand_normal_repeated_operation_is_idempotent() {
// 幂等:同一输入两次生成,行数与 payload 字节数完全一致,ASIN 可重复。
List<CollectDataResultRowVo> first =
fixture.generateRows(5000, KEYWORDS, 50, 25, 10);
List<CollectDataResultRowVo> second =
fixture.generateRows(5000, KEYWORDS, 50, 25, 10);
assertEquals(first.size(), second.size(), "行数幂等");
assertEquals(first.get(0).getAsin(), second.get(0).getAsin(), "首行 ASIN 幂等");
assertEquals(first.get(4999).getAsin(), second.get(4999).getAsin(), "末行 ASIN 幂等");
CollectDataPerfFixture.Metrics m1 = fixture.samplePayload(first, 1000);
CollectDataPerfFixture.Metrics m2 = fixture.samplePayload(second, 1000);
assertEquals(m1.payloadBytes(), m2.payloadBytes(), "payload 字节幂等");
assertEquals(m1.chunkCount(), m2.chunkCount(), "chunk 数幂等");
assertEquals(m1.failedBrandRows(), m2.failedBrandRows(), "失败品牌统计幂等");
}
@Test
void test_task_041_chunk_brand_boundary_empty_input() {
// 空输入:0 行返回空集合,采样统计全零且无 chunk。
List<CollectDataResultRowVo> rows = fixture.generateRows(0, KEYWORDS, 0, 0, 0);
CollectDataPerfFixture.Metrics metrics = fixture.samplePayload(rows, 200);
assertEquals(0, rows.size(), "空输入 0 行");
assertEquals(0, metrics.rowCount(), "采样行数 0");
assertEquals(0, metrics.chunkCount(), "空输入无 chunk");
assertEquals(0, metrics.payloadBytes(), "空输入无 payload");
assertEquals(0, metrics.failedBrandRows(), "空输入无失败品牌");
assertEquals(0, metrics.queryFailedBrandRows(), "空输入无查询失败品牌");
assertEquals(0, metrics.blankAsinRows(), "空输入无空 ASIN");
}
@Test
void test_task_041_chunk_brand_boundary_single_item() {
// 单元素:1 行 1 关键词单 chunk,不依赖批量路径,全部统计可算。
List<CollectDataResultRowVo> rows = fixture.generateRows(1, List.of("only"), 0, 0, 0);
CollectDataPerfFixture.Metrics metrics = fixture.samplePayload(rows, 200);
assertEquals(1, rows.size(), "单行");
assertEquals(1, metrics.chunkCount(), "单行一个 chunk");
assertEquals(1, metrics.rowCount(), "采样行数 1");
assertEquals(0, metrics.failedBrandRows(), "单行有效品牌");
assertTrue(metrics.payloadBytes() > 0, "单行 payload 非空");
assertEquals("only", rows.getFirst().getKeyword(), "单关键词正确");
assertEquals("FBA", rows.getFirst().getDeliveryMethod(), "单行配送方式确定");
}
@Test
void test_task_041_chunk_brand_boundary_limit_and_overflow() {
// 上限/超限:10k 行最大值可执行;超限行数、负计数、品牌合计超行数被拒绝。
List<CollectDataResultRowVo> maxRows = fixture.generateRows(10000, KEYWORDS, 0, 0, 0);
assertEquals(10000, maxRows.size(), "最大行数可执行");
assertThrows(IllegalArgumentException.class,
() -> fixture.generateRows(10001, KEYWORDS, 0, 0, 0), "超限行数被拒绝");
assertThrows(IllegalArgumentException.class,
() -> fixture.generateRows(100, KEYWORDS, -1, 0, 0), "负失败品牌计数被拒绝");
assertThrows(IllegalArgumentException.class,
() -> fixture.generateRows(100, KEYWORDS, 60, 50, 0), "品牌合计超行数被拒绝");
assertThrows(IllegalArgumentException.class,
() -> fixture.generateRows(100, KEYWORDS, 0, 0, 101), "空 ASIN 计数超行数被拒绝");
assertThrows(IllegalArgumentException.class,
() -> fixture.generateRows(100, List.of(), 0, 0, 0), "空关键词集合被拒绝");
}
@Test
void test_task_041_chunk_brand_invalid_input_rejected() {
// 非法输入:null 集合、null 行列表、非正 chunkSize、负行数抛可识别异常。
assertThrows(IllegalArgumentException.class,
() -> fixture.generateRows(-1, KEYWORDS, 0, 0, 0), "负行数被拒绝");
assertThrows(IllegalArgumentException.class,
() -> fixture.generateRows(100, null, 0, 0, 0), "null 关键词集合被拒绝");
assertThrows(IllegalArgumentException.class,
() -> fixture.samplePayload(null, 200), "null 行列表被拒绝");
assertThrows(IllegalArgumentException.class,
() -> fixture.samplePayload(List.of(), 0), "非正 chunkSize 被拒绝");
assertThrows(IllegalArgumentException.class,
() -> fixture.samplePayload(List.of(), -5), "负 chunkSize 被拒绝");
}
@Test
void test_task_041_chunk_brand_dependency_failure_releases_resources() throws Exception {
// 依赖失败:序列化失败抛 IllegalStateException 且不产生部分结果;
// 恢复后同一输入可重新采样且结果一致。
ObjectMapper spy = org.mockito.Mockito.spy(new ObjectMapper());
org.mockito.Mockito.doAnswer(invocation -> {
throw new java.io.IOException("serializer down");
}).when(spy).writeValueAsBytes(org.mockito.ArgumentMatchers.any());
CollectDataPerfFixture failingFixture = new CollectDataPerfFixture(spy);
List<CollectDataResultRowVo> rows = fixture.generateRows(500, KEYWORDS, 10, 5, 3);
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> failingFixture.samplePayload(rows, 100), "序列化失败必须抛出");
assertTrue(ex.getMessage().contains("采样序列化失败"), "异常消息可识别");
CollectDataPerfFixture.Metrics metrics = fixture.samplePayload(rows, 100);
assertEquals(500, metrics.rowCount(), "恢复后采样行数完整");
assertEquals(5, metrics.chunkCount(), "恢复后 chunk 数正确");
assertEquals(10, metrics.failedBrandRows(), "恢复后失败品牌统计正确");
assertEquals(5, metrics.queryFailedBrandRows(), "恢复后查询失败统计正确");
assertEquals(3, metrics.blankAsinRows(), "恢复后空 ASIN 统计正确");
}
}