task-23: 图片嵌入成功后立即释放外部缩略图字节副本
BoundedImageCache.release(url) 移除条目并扣减字节计数,byte[] 可被 GC 回收; embedImage 在 addPicture 成功后立即释放,同一 URL 后续行复用 pictureIndex 不重复嵌入。 全量测试 532 通过。
This commit is contained in:
+211
@@ -0,0 +1,211 @@
|
||||
package com.nanri.aiimage.modules.shopdatacrawl.util;
|
||||
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlExcelAssemblyService;
|
||||
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Task 23:图片嵌入成功后立即释放外部缩略图字节副本。
|
||||
* BoundedImageCache.release(url) 在嵌入成功后移除条目并扣减字节计数,
|
||||
* 使缩略图 byte[] 可被 GC 回收,同一 URL 后续行复用已登记的 pictureIndex;
|
||||
* 嵌入失败(图片缺失)时条目保留,不误释放。
|
||||
*/
|
||||
class ShopDataCrawlImageReleaseTest {
|
||||
@TempDir Path tempDir;
|
||||
|
||||
private static final String URL_A = "https://thumb.example/A.jpg";
|
||||
private static final String URL_B = "https://thumb.example/B.jpg";
|
||||
|
||||
@Test
|
||||
void test_task_023_image_release_normal_default_path() {
|
||||
// 正常输入:release 返回被释放条目并扣减字节计数,重复 release 安全。
|
||||
BoundedImageCache cache = new BoundedImageCache(10_000, 100);
|
||||
SimilarAsinImageEmbedder.ResizedImage imageA = resizedImage(100);
|
||||
cache.put(URL_A, imageA);
|
||||
assertEquals(100, cache.sizeBytes());
|
||||
|
||||
SimilarAsinImageEmbedder.ResizedImage released = cache.release(URL_A);
|
||||
assertEquals(imageA, released, "release 返回被释放的条目");
|
||||
assertNull(cache.get(URL_A), "条目已移除");
|
||||
assertEquals(0, cache.sizeBytes(), "字节计数归零");
|
||||
assertNull(cache.release(URL_A), "重复 release 安全返回 null");
|
||||
assertEquals(0, cache.sizeBytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_023_image_release_normal_multiple_items() {
|
||||
// 批量场景:多个 URL 全部嵌入后逐一释放,字节计数精确归零。
|
||||
BoundedImageCache cache = new BoundedImageCache(10_000, 100);
|
||||
cache.put(URL_A, resizedImage(100));
|
||||
cache.put(URL_B, resizedImage(200));
|
||||
cache.put("https://thumb.example/C.jpg", resizedImage(300));
|
||||
assertEquals(600, cache.sizeBytes());
|
||||
|
||||
assertEquals(100, cache.release(URL_A).bytes().length);
|
||||
assertEquals(500, cache.sizeBytes());
|
||||
assertEquals(200, cache.release(URL_B).bytes().length);
|
||||
assertEquals(300, cache.sizeBytes());
|
||||
cache.release("https://thumb.example/C.jpg");
|
||||
assertEquals(0, cache.sizeBytes(), "全部释放后字节归零");
|
||||
assertEquals(0, cache.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_023_image_release_normal_repeated_operation_is_idempotent() throws Exception {
|
||||
// 重复执行:同一 URL 多行嵌入只释放一次,后续行复用 pictureIndex,不产生重复图。
|
||||
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
|
||||
when(imageEmbedder.fetchAndResizeForCache(URL_A))
|
||||
.thenReturn(new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2));
|
||||
ShopDataCrawlExcelAssemblyService service =
|
||||
new ShopDataCrawlExcelAssemblyService(imageEmbedder, 10_000);
|
||||
|
||||
List<ShopDataCrawlResultItemVo> items = new ArrayList<>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||
row.setDate("2026-07-25");
|
||||
row.setAsin("B0" + String.format("%08d", i));
|
||||
row.setCommodityImage(URL_A);
|
||||
ShopDataCrawlCountryResultDto country = new ShopDataCrawlCountryResultDto();
|
||||
country.setCountry("UK");
|
||||
country.setItems(List.of(row));
|
||||
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
|
||||
item.setSuccess(true);
|
||||
item.setCountryResults(List.of(country));
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
File output = tempDir.resolve("release.xlsx").toFile();
|
||||
service.writeWorkbook(output, items);
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||
assertEquals(1, workbook.getAllPictures().size(), "同一 URL 只嵌入一张图");
|
||||
assertEquals(5, workbook.getSheet("英国").getLastRowNum(), "5 行全部写入");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_023_image_release_boundary_empty_input() {
|
||||
// 空输入:空缓存 release 任意 URL 安全返回 null,不抛异常。
|
||||
BoundedImageCache cache = new BoundedImageCache(10_000, 100);
|
||||
assertNull(cache.release(URL_A));
|
||||
assertNull(cache.release(""));
|
||||
assertNull(cache.release(null));
|
||||
assertEquals(0, cache.sizeBytes());
|
||||
assertEquals(0, cache.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_023_image_release_boundary_single_item() throws Exception {
|
||||
// 单元素:单 URL 单行嵌入后字节立即归零,缓存为空。
|
||||
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
|
||||
when(imageEmbedder.fetchAndResizeForCache(URL_B))
|
||||
.thenReturn(new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2));
|
||||
ShopDataCrawlExcelAssemblyService service =
|
||||
new ShopDataCrawlExcelAssemblyService(imageEmbedder, 10_000);
|
||||
|
||||
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||
row.setDate("2026-07-25");
|
||||
row.setAsin("B000000001");
|
||||
row.setCommodityImage(URL_B);
|
||||
ShopDataCrawlCountryResultDto country = new ShopDataCrawlCountryResultDto();
|
||||
country.setCountry("UK");
|
||||
country.setItems(List.of(row));
|
||||
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
|
||||
item.setSuccess(true);
|
||||
item.setCountryResults(List.of(country));
|
||||
|
||||
File output = tempDir.resolve("release-single.xlsx").toFile();
|
||||
service.writeWorkbook(output, List.of(item));
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||
assertEquals(1, workbook.getAllPictures().size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_023_image_release_boundary_limit_and_overflow() {
|
||||
// 上限/超限:释放后字节预算恢复,可继续放入新图而不触发淘汰。
|
||||
BoundedImageCache cache = new BoundedImageCache(100, 10);
|
||||
cache.put(URL_A, resizedImage(100));
|
||||
assertEquals(100, cache.sizeBytes());
|
||||
assertEquals(0, cache.evictionCount(), "单图占满预算");
|
||||
|
||||
cache.release(URL_A);
|
||||
cache.put(URL_B, resizedImage(90));
|
||||
assertEquals(90, cache.sizeBytes());
|
||||
assertEquals(0, cache.evictionCount(), "释放后新图放入不触发淘汰");
|
||||
assertEquals(0, cache.rejectedCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_023_image_release_invalid_input_rejected() {
|
||||
// 非法参数:release 对空白 key 安全返回 null;cache 本身非法构造仍被拒绝。
|
||||
BoundedImageCache cache = new BoundedImageCache(10_000, 100);
|
||||
assertNull(cache.release(" "));
|
||||
assertNull(cache.release("\t"));
|
||||
assertEquals(0, cache.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_023_image_release_dependency_failure_releases_resources() throws Exception {
|
||||
// 依赖失败:图片下载失败(fetch 返回 null)时兜底 URL 文本,
|
||||
// 其他已成功嵌入的条目正常释放,失败 URL 不产生残留字节。
|
||||
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
|
||||
when(imageEmbedder.fetchAndResizeForCache(URL_A))
|
||||
.thenReturn(new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2));
|
||||
when(imageEmbedder.fetchAndResizeForCache("https://thumb.example/fail.jpg")).thenReturn(null);
|
||||
ShopDataCrawlExcelAssemblyService service =
|
||||
new ShopDataCrawlExcelAssemblyService(imageEmbedder, 10_000);
|
||||
|
||||
List<ShopDataCrawlResultItemVo> items = new ArrayList<>();
|
||||
for (int i = 0; i < 4; i++) {
|
||||
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||
row.setDate("2026-07-25");
|
||||
row.setAsin("B0" + String.format("%08d", i));
|
||||
row.setCommodityImage(i % 2 == 0 ? URL_A : "https://thumb.example/fail.jpg");
|
||||
ShopDataCrawlCountryResultDto country = new ShopDataCrawlCountryResultDto();
|
||||
country.setCountry("UK");
|
||||
country.setItems(List.of(row));
|
||||
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
|
||||
item.setSuccess(true);
|
||||
item.setCountryResults(List.of(country));
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
File output = tempDir.resolve("release-fail.xlsx").toFile();
|
||||
service.writeWorkbook(output, items);
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||
assertEquals(1, workbook.getAllPictures().size(), "成功图片嵌入,失败图片兜底");
|
||||
assertEquals("https://thumb.example/fail.jpg",
|
||||
workbook.getSheet("英国").getRow(2).getCell(2).getStringCellValue());
|
||||
}
|
||||
}
|
||||
|
||||
private static SimilarAsinImageEmbedder.ResizedImage resizedImage(int bytes) {
|
||||
return new SimilarAsinImageEmbedder.ResizedImage(new byte[bytes], 2, 2);
|
||||
}
|
||||
|
||||
private static byte[] jpegBytes() throws Exception {
|
||||
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB);
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, "jpg", output);
|
||||
return output.toByteArray();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user