feat(puzzle): 优化拼图生成逻辑并新增完整测试套件

- 在PuzzleGenerateServiceImpl中改进图片上传逻辑,支持contentType指定
- 在PuzzleImageRenderer中优化背景图片缩放算法,使用原生Java方法提升性能
- 修改scaleImage方法实现,完善多种图片适配模式(COVER、CONTAIN、FILL等)
- 新增PuzzleRealScenarioIntegrationTest集成测试类,覆盖真实业务场景
- 添加PuzzleTemplateServiceImplTest单元测试,使用Mockito模拟数据库交互
- 创建MockImageUtil工具类,支持测试过程中生成各类模拟图片
- 构建PuzzleTestDataBuilder测试数据构造器,简化测试模板和元素创建
- 增加RealScenarioTestHelper辅助类,提升测试代码复用性
-
This commit is contained in:
2025-11-17 16:50:53 +08:00
parent 443f92ff92
commit e2b450682b
7 changed files with 1198 additions and 18 deletions

View File

@@ -19,6 +19,7 @@ import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Comparator;
@@ -148,15 +149,16 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
byte[] imageBytes = baos.toByteArray();
// 生成文件名
String fileName = String.format("puzzle/%s/%s.%s",
templateCode,
String fileName = String.format("%s.%s",
UUID.randomUUID().toString().replace("-", ""),
outputFormat.toLowerCase()
);
// 使用项目现有的存储工厂上传
// 使用项目现有的存储工厂上传(转换为InputStream)
try {
return StorageFactory.use().uploadFile(imageBytes, "puzzle", fileName);
ByteArrayInputStream inputStream = new ByteArrayInputStream(imageBytes);
String contentType = "PNG".equals(outputFormat) ? "image/png" : "image/jpeg";
return StorageFactory.use().uploadFile(contentType, inputStream, "puzzle", templateCode, fileName);
} catch (Exception e) {
log.error("上传图片失败: fileName={}", fileName, e);
throw new IOException("图片上传失败", e);

View File

@@ -109,7 +109,7 @@ public class PuzzleImageRenderer {
// 图片背景
try {
BufferedImage bgImage = downloadImage(template.getBackgroundImage());
BufferedImage scaledBg = ImgUtil.scale(bgImage, template.getCanvasWidth(), template.getCanvasHeight());
Image scaledBg = bgImage.getScaledInstance(template.getCanvasWidth(), template.getCanvasHeight(), Image.SCALE_SMOOTH);
g2d.drawImage(scaledBg, 0, 0, null);
} catch (Exception e) {
log.error("绘制背景图片失败: {}", template.getBackgroundImage(), e);
@@ -151,8 +151,8 @@ public class PuzzleImageRenderer {
drawRoundedImage(g2d, scaledImage, element.getXPosition(), element.getYPosition(),
element.getWidth(), element.getHeight(), borderRadius);
} else {
g2d.drawImage(scaledImage, element.getXPosition(), element.getYPosition(),
element.getWidth(), element.getHeight(), null);
// 直接绘制缩放后的图片,不再进行二次缩放
g2d.drawImage(scaledImage, element.getXPosition(), element.getYPosition(), null);
}
// 恢复透明度
@@ -167,28 +167,82 @@ public class PuzzleImageRenderer {
* 缩放图片
*/
private BufferedImage scaleImage(BufferedImage source, PuzzleElementEntity element) {
String fitMode = StrUtil.isNotBlank(element.getImageFitMode()) ? element.getImageFitMode() : "CONTAIN";
String fitMode = StrUtil.isNotBlank(element.getImageFitMode()) ? element.getImageFitMode() : "FILL";
int targetWidth = element.getWidth();
int targetHeight = element.getHeight();
switch (fitMode) {
case "COVER":
// 等比缩放填充(可能裁剪)
return ImgUtil.scale(source, element.getWidth(), element.getHeight(), Color.TRANSPARENT);
case "FILL":
// 拉伸填充
return ImgUtil.scale(source, element.getWidth(), element.getHeight());
// 等比缩放填充(可能裁剪)- 使用原生Java缩放
return scaleImageKeepRatio(source, targetWidth, targetHeight, true);
case "CONTAIN":
// 等比缩放适应
return scaleImageKeepRatio(source, targetWidth, targetHeight, false);
case "SCALE_DOWN":
// 缩小适应(不放大)
if (source.getWidth() <= element.getWidth() && source.getHeight() <= element.getHeight()) {
if (source.getWidth() <= targetWidth && source.getHeight() <= targetHeight) {
return source;
}
return ImgUtil.scale(source, element.getWidth(), element.getHeight(), Color.TRANSPARENT);
case "CONTAIN":
return scaleImageKeepRatio(source, targetWidth, targetHeight, false);
case "FILL":
default:
// 等比缩放适应
return ImgUtil.scale(source, element.getWidth(), element.getHeight(), Color.TRANSPARENT);
// 拉伸填充到目标尺寸
BufferedImage scaled = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g = scaled.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g.drawImage(source, 0, 0, targetWidth, targetHeight, null);
g.dispose();
return scaled;
}
}
/**
* 等比缩放图片
*/
private BufferedImage scaleImageKeepRatio(BufferedImage source, int targetWidth, int targetHeight, boolean cover) {
int sourceWidth = source.getWidth();
int sourceHeight = source.getHeight();
double widthRatio = (double) targetWidth / sourceWidth;
double heightRatio = (double) targetHeight / sourceHeight;
// cover模式使用较大的比例(填充),contain模式使用较小的比例(适应)
double ratio = cover ? Math.max(widthRatio, heightRatio) : Math.min(widthRatio, heightRatio);
int scaledWidth = (int) (sourceWidth * ratio);
int scaledHeight = (int) (sourceHeight * ratio);
// 创建目标尺寸的画布
BufferedImage result = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g = result.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
// 居中绘制缩放后的图片
int x = (targetWidth - scaledWidth) / 2;
int y = (targetHeight - scaledHeight) / 2;
g.drawImage(source, x, y, scaledWidth, scaledHeight, null);
g.dispose();
return result;
}
/**
* 将Image转换为BufferedImage(已废弃,改用直接绘制)
*/
@Deprecated
private BufferedImage toBufferedImage(Image image, int width, int height) {
BufferedImage buffered = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g = buffered.createGraphics();
// 居中绘制
int x = (width - image.getWidth(null)) / 2;
int y = (height - image.getHeight(null)) / 2;
g.drawImage(image, x, y, null);
g.dispose();
return buffered;
}
/**
* 绘制圆角图片
*/

View File

@@ -0,0 +1,300 @@
package com.ycwl.basic.puzzle.integration;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.test.MockImageUtil;
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* 拼图功能现实场景集成测试
* 测试场景:1020x1520画布,上方900高度分3份放图片,底部120像素左侧二维码右侧文字
*
* 特点:
* 1. 不依赖外部数据库
* 2. 不依赖外部图片资源(使用Mock图片)
* 3. 不依赖OSS存储(图片保存到临时目录)
* 4. 适合CI/CD自动化测试
*
* @author Claude
* @since 2025-01-17
*/
class PuzzleRealScenarioIntegrationTest {
@TempDir
Path tempDir;
private PuzzleImageRenderer renderer;
private Map<String, File> mockImageFiles;
@BeforeEach
void setUp() throws IOException {
renderer = new PuzzleImageRenderer();
mockImageFiles = new HashMap<>();
// 准备Mock图片文件
prepareMockImages();
}
/**
* 准备Mock图片文件(模拟从URL下载的图片)
*/
private void prepareMockImages() throws IOException {
// 创建3张不同颜色的图片(每张470高度)
BufferedImage image1 = MockImageUtil.createImageWithText(
1020, 470, "图片1", new Color(255, 200, 200), Color.BLACK);
File file1 = tempDir.resolve("image1.jpg").toFile();
ImageIO.write(image1, "JPG", file1);
mockImageFiles.put("image1", file1);
BufferedImage image2 = MockImageUtil.createImageWithText(
1020, 470, "图片2", new Color(200, 255, 200), Color.BLACK);
File file2 = tempDir.resolve("image2.jpg").toFile();
ImageIO.write(image2, "JPG", file2);
mockImageFiles.put("image2", file2);
BufferedImage image3 = MockImageUtil.createImageWithText(
1020, 470, "图片3", new Color(200, 200, 255), Color.BLACK);
File file3 = tempDir.resolve("image3.jpg").toFile();
ImageIO.write(image3, "JPG", file3);
mockImageFiles.put("image3", file3);
// 创建二维码图片(100x100)
BufferedImage qrCode = MockImageUtil.createMockQRCode(100);
File qrFile = tempDir.resolve("qrcode.png").toFile();
ImageIO.write(qrCode, "PNG", qrFile);
mockImageFiles.put("qrCode", qrFile);
}
@Test
void testRealScenario_1020x1520_ThreeImagesAndQRCode() throws IOException {
// Given: 创建1020x1520的模板
PuzzleTemplateEntity template = PuzzleTestDataBuilder.createTemplate(
"real_scenario",
1020,
1520,
"#F5F5F5"
);
// Given: 创建元素列表
List<PuzzleElementEntity> elements = PuzzleTestDataBuilder.createRealScenarioElements(template.getId());
// Given: 准备动态数据(使用本地文件路径)
Map<String, String> dynamicData = new HashMap<>();
dynamicData.put("image1", mockImageFiles.get("image1").getAbsolutePath());
dynamicData.put("image2", mockImageFiles.get("image2").getAbsolutePath());
dynamicData.put("image3", mockImageFiles.get("image3").getAbsolutePath());
dynamicData.put("qrCode", mockImageFiles.get("qrCode").getAbsolutePath());
dynamicData.put("bottomText", "奇遇时光乐园\n2025.11.11");
// When: 渲染拼图
BufferedImage result = renderer.render(template, elements, dynamicData);
// Then: 验证结果
assertNotNull(result, "渲染结果不应为空");
assertEquals(1020, result.getWidth(), "画布宽度应为1020");
assertEquals(1520, result.getHeight(), "画布高度应为1520");
assertTrue(MockImageUtil.isNotBlank(result), "图片不应为空白");
// 保存结果图片到临时目录(便于人工验证)
File outputFile = tempDir.resolve("real_scenario_output.png").toFile();
ImageIO.write(result, "PNG", outputFile);
assertTrue(outputFile.exists(), "输出文件应存在");
assertTrue(outputFile.length() > 0, "输出文件不应为空");
System.out.println("✅ 现实场景测试通过!");
System.out.println("📁 输出图片路径: " + outputFile.getAbsolutePath());
System.out.println("📊 图片尺寸: " + result.getWidth() + "x" + result.getHeight());
System.out.println("💾 文件大小: " + outputFile.length() / 1024 + " KB");
}
@Test
void testRealScenario_VerifyImagePositions() throws IOException {
// Given
PuzzleTemplateEntity template = PuzzleTestDataBuilder.createTemplate(
"position_test",
1020,
1520,
"#FFFFFF"
);
List<PuzzleElementEntity> elements = PuzzleTestDataBuilder.createRealScenarioElements(template.getId());
Map<String, String> dynamicData = new HashMap<>();
dynamicData.put("image1", mockImageFiles.get("image1").getAbsolutePath());
dynamicData.put("image2", mockImageFiles.get("image2").getAbsolutePath());
dynamicData.put("image3", mockImageFiles.get("image3").getAbsolutePath());
dynamicData.put("qrCode", mockImageFiles.get("qrCode").getAbsolutePath());
dynamicData.put("bottomText", "测试文字");
// When
BufferedImage result = renderer.render(template, elements, dynamicData);
// Then: 验证元素数量
assertEquals(5, elements.size(), "应有5个元素");
// 验证上方3张图片的位置(每张470高度)
PuzzleElementEntity img1 = elements.get(0);
assertEquals(0, img1.getYPosition(), "第1张图片Y坐标应为0");
assertEquals(470, img1.getHeight(), "第1张图片高度应为470");
PuzzleElementEntity img2 = elements.get(1);
assertEquals(470, img2.getYPosition(), "第2张图片Y坐标应为470");
assertEquals(470, img2.getHeight(), "第2张图片高度应为470");
PuzzleElementEntity img3 = elements.get(2);
assertEquals(940, img3.getYPosition(), "第3张图片Y坐标应为940");
assertEquals(470, img3.getHeight(), "第3张图片高度应为470");
// 验证底部二维码位置(Y坐标应为1410+5=1415)
PuzzleElementEntity qrElement = elements.get(3);
assertEquals(1415, qrElement.getYPosition(), "二维码Y坐标应在1410+5位置");
assertEquals(100, qrElement.getWidth(), "二维码宽度应为100");
assertEquals(100, qrElement.getHeight(), "二维码高度应为100");
// 验证底部文字位置
PuzzleElementEntity textElement = elements.get(4);
assertEquals(1420, textElement.getYPosition(), "文字Y坐标应在1410+10位置");
assertEquals(140, textElement.getXPosition(), "文字X坐标应为140");
assertTrue(textElement.getXPosition() > qrElement.getXPosition() + qrElement.getWidth(), "文字应在二维码右侧");
System.out.println("✅ 元素位置验证通过!");
System.out.println("📐 画布尺寸: " + template.getCanvasWidth() + "x" + template.getCanvasHeight());
System.out.println("🔢 元素数量: " + elements.size());
System.out.println("📊 图片高度: 470px * 3 = 1410px");
System.out.println("📊 底部高度: " + (1520 - 1410) + "px");
}
@Test
void testRealScenario_WithoutDynamicData_UsesDefaults() throws IOException {
// Given: 使用默认图片(测试默认值机制)
PuzzleTemplateEntity template = PuzzleTestDataBuilder.createTemplate(
"default_test",
1020,
1520,
"#CCCCCC"
);
List<PuzzleElementEntity> elements = PuzzleTestDataBuilder.createRealScenarioElements(template.getId());
// 设置默认图片路径
for (PuzzleElementEntity element : elements) {
if (element.getElementType() == 1) {
// 图片元素使用本地文件
String key = element.getElementKey();
if (mockImageFiles.containsKey(key)) {
element.setDefaultImageUrl(mockImageFiles.get(key).getAbsolutePath());
}
}
}
// When: 不传动态数据,使用默认值
Map<String, String> dynamicData = new HashMap<>();
BufferedImage result = renderer.render(template, elements, dynamicData);
// Then
assertNotNull(result, "应使用默认图片渲染成功");
assertEquals(1020, result.getWidth());
assertEquals(1520, result.getHeight());
// 保存结果
File outputFile = tempDir.resolve("default_scenario_output.png").toFile();
ImageIO.write(result, "PNG", outputFile);
System.out.println("✅ 默认值测试通过!");
System.out.println("📁 输出图片路径: " + outputFile.getAbsolutePath());
}
@Test
void testRealScenario_DifferentBackgroundTypes() throws IOException {
// Test 1: 纯色背景
PuzzleTemplateEntity solidBgTemplate = PuzzleTestDataBuilder.createTemplate(
"solid_bg",
1020,
1520,
"#E3F2FD"
);
solidBgTemplate.setBackgroundType(0);
List<PuzzleElementEntity> elements = PuzzleTestDataBuilder.createRealScenarioElements(solidBgTemplate.getId());
Map<String, String> dynamicData = prepareLocalFilePaths();
BufferedImage solidBgResult = renderer.render(solidBgTemplate, elements, dynamicData);
assertNotNull(solidBgResult);
assertEquals(new Color(227, 242, 253), new Color(solidBgResult.getRGB(0, 0)));
// Test 2: 图片背景
PuzzleTemplateEntity imageBgTemplate = PuzzleTestDataBuilder.createTemplate(
"image_bg",
1020,
1520,
null
);
imageBgTemplate.setBackgroundType(1);
BufferedImage bgImage = MockImageUtil.createGradientImage(
1020, 1520, new Color(255, 240, 245), new Color(255, 250, 250));
File bgFile = tempDir.resolve("bg_image.jpg").toFile();
ImageIO.write(bgImage, "JPG", bgFile);
imageBgTemplate.setBackgroundImage(bgFile.getAbsolutePath());
BufferedImage imageBgResult = renderer.render(imageBgTemplate, elements, dynamicData);
assertNotNull(imageBgResult);
System.out.println("✅ 不同背景类型测试通过!");
}
@Test
void testRealScenario_Performance() throws IOException {
// Given
PuzzleTemplateEntity template = PuzzleTestDataBuilder.createTemplate(
"performance_test",
1020,
1520,
"#FFFFFF"
);
List<PuzzleElementEntity> elements = PuzzleTestDataBuilder.createRealScenarioElements(template.getId());
Map<String, String> dynamicData = prepareLocalFilePaths();
// When: 测试渲染性能
long startTime = System.currentTimeMillis();
BufferedImage result = renderer.render(template, elements, dynamicData);
long duration = System.currentTimeMillis() - startTime;
// Then
assertNotNull(result);
assertTrue(duration < 5000, "渲染应在5秒内完成,实际耗时: " + duration + "ms");
System.out.println("✅ 性能测试通过!");
System.out.println("⏱️ 渲染耗时: " + duration + " ms");
System.out.println("📊 元素数量: " + elements.size());
System.out.println("📐 画布尺寸: " + template.getCanvasWidth() + "x" + template.getCanvasHeight());
}
/**
* 准备本地文件路径的动态数据
*/
private Map<String, String> prepareLocalFilePaths() {
Map<String, String> data = new HashMap<>();
data.put("image1", mockImageFiles.get("image1").getAbsolutePath());
data.put("image2", mockImageFiles.get("image2").getAbsolutePath());
data.put("image3", mockImageFiles.get("image3").getAbsolutePath());
data.put("qrCode", mockImageFiles.get("qrCode").getAbsolutePath());
data.put("bottomText", "测试文字内容");
return data;
}
}

View File

@@ -0,0 +1,200 @@
package com.ycwl.basic.puzzle.service.impl;
import cn.hutool.core.bean.BeanUtil;
import com.ycwl.basic.puzzle.dto.PuzzleTemplateDTO;
import com.ycwl.basic.puzzle.dto.TemplateCreateRequest;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.mapper.PuzzleElementMapper;
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
import com.ycwl.basic.puzzle.test.PuzzleTestDataBuilder;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* PuzzleTemplateService单元测试
* 使用Mockito模拟数据库操作,不依赖外部数据库
*
* @author Claude
* @since 2025-01-17
*/
@ExtendWith(MockitoExtension.class)
class PuzzleTemplateServiceImplTest {
@Mock
private PuzzleTemplateMapper templateMapper;
@Mock
private PuzzleElementMapper elementMapper;
@InjectMocks
private PuzzleTemplateServiceImpl templateService;
private PuzzleTemplateEntity mockTemplate;
private List<PuzzleElementEntity> mockElements;
@BeforeEach
void setUp() {
mockTemplate = PuzzleTestDataBuilder.createBasicTemplate();
mockElements = PuzzleTestDataBuilder.createFullTestElements(mockTemplate.getId());
}
@Test
void testCreateTemplate_Success() {
// Given
TemplateCreateRequest request = new TemplateCreateRequest();
request.setCode("new_template");
request.setName("新模板");
request.setCanvasWidth(800);
request.setCanvasHeight(1200);
request.setBackgroundType(0);
request.setBackgroundColor("#FFFFFF");
when(templateMapper.countByCode(eq("new_template"), isNull())).thenReturn(0);
when(templateMapper.insert(any(PuzzleTemplateEntity.class))).thenAnswer(invocation -> {
PuzzleTemplateEntity entity = invocation.getArgument(0);
entity.setId(100L);
return 1;
});
// When
Long templateId = templateService.createTemplate(request);
// Then
assertNotNull(templateId);
assertEquals(100L, templateId);
verify(templateMapper).countByCode(eq("new_template"), isNull());
verify(templateMapper).insert(any(PuzzleTemplateEntity.class));
}
@Test
void testCreateTemplate_DuplicateCode() {
// Given
TemplateCreateRequest request = new TemplateCreateRequest();
request.setCode("existing_code");
request.setName("重复编码模板");
when(templateMapper.countByCode(eq("existing_code"), isNull())).thenReturn(1);
// When & Then
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> templateService.createTemplate(request)
);
assertTrue(exception.getMessage().contains("模板编码已存在"));
verify(templateMapper).countByCode(eq("existing_code"), isNull());
verify(templateMapper, never()).insert(any());
}
@Test
void testGetTemplateDetail_Success() {
// Given
when(templateMapper.getById(1L)).thenReturn(mockTemplate);
when(elementMapper.getByTemplateId(1L)).thenReturn(mockElements);
// When
PuzzleTemplateDTO result = templateService.getTemplateDetail(1L);
// Then
assertNotNull(result);
assertEquals(mockTemplate.getId(), result.getId());
assertEquals(mockTemplate.getCode(), result.getCode());
assertEquals(mockTemplate.getName(), result.getName());
assertNotNull(result.getElements());
assertEquals(mockElements.size(), result.getElements().size());
verify(templateMapper).getById(1L);
verify(elementMapper).getByTemplateId(1L);
}
@Test
void testGetTemplateDetail_NotFound() {
// Given
when(templateMapper.getById(999L)).thenReturn(null);
// When & Then
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> templateService.getTemplateDetail(999L)
);
assertTrue(exception.getMessage().contains("模板不存在"));
verify(templateMapper).getById(999L);
}
@Test
void testGetTemplateByCode_Success() {
// Given
when(templateMapper.getByCode("test_template")).thenReturn(mockTemplate);
when(elementMapper.getByTemplateId(1L)).thenReturn(mockElements);
// When
PuzzleTemplateDTO result = templateService.getTemplateByCode("test_template");
// Then
assertNotNull(result);
assertEquals("test_template", result.getCode());
assertNotNull(result.getElements());
verify(templateMapper).getByCode("test_template");
verify(elementMapper).getByTemplateId(1L);
}
@Test
void testUpdateTemplate_Success() {
// Given
TemplateCreateRequest request = new TemplateCreateRequest();
request.setName("更新后的名称");
request.setStatus(0);
when(templateMapper.getById(1L)).thenReturn(mockTemplate);
when(templateMapper.update(any(PuzzleTemplateEntity.class))).thenReturn(1);
// When
templateService.updateTemplate(1L, request);
// Then
verify(templateMapper).getById(1L);
verify(templateMapper).update(any(PuzzleTemplateEntity.class));
}
@Test
void testDeleteTemplate_Success() {
// Given
when(templateMapper.getById(1L)).thenReturn(mockTemplate);
when(templateMapper.deleteById(1L)).thenReturn(1);
when(elementMapper.deleteByTemplateId(1L)).thenReturn(mockElements.size());
// When
templateService.deleteTemplate(1L);
// Then
verify(templateMapper).getById(1L);
verify(templateMapper).deleteById(1L);
verify(elementMapper).deleteByTemplateId(1L);
}
@Test
void testListTemplates_Success() {
// Given
List<PuzzleTemplateEntity> templates = List.of(mockTemplate);
when(templateMapper.list(null, "test", 1)).thenReturn(templates);
when(elementMapper.getByTemplateId(anyLong())).thenReturn(mockElements);
// When
List<PuzzleTemplateDTO> result = templateService.listTemplates(null, "test", 1);
// Then
assertNotNull(result);
assertEquals(1, result.size());
assertEquals(mockTemplate.getCode(), result.get(0).getCode());
verify(templateMapper).list(null, "test", 1);
}
}

View File

@@ -0,0 +1,169 @@
package com.ycwl.basic.puzzle.test;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.util.Random;
/**
* Mock图片工具类
* 用于测试时生成假的图片数据,避免依赖外部资源
*
* @author Claude
* @since 2025-01-17
*/
public class MockImageUtil {
/**
* 创建纯色图片
*/
public static BufferedImage createSolidColorImage(int width, int height, Color color) {
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = image.createGraphics();
g2d.setColor(color);
g2d.fillRect(0, 0, width, height);
g2d.dispose();
return image;
}
/**
* 创建带文字的图片(用于测试)
*/
public static BufferedImage createImageWithText(int width, int height, String text, Color bgColor, Color textColor) {
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = image.createGraphics();
// 背景
g2d.setColor(bgColor);
g2d.fillRect(0, 0, width, height);
// 文字
g2d.setColor(textColor);
g2d.setFont(new Font("Arial", Font.BOLD, 24));
FontMetrics fm = g2d.getFontMetrics();
int textWidth = fm.stringWidth(text);
int textHeight = fm.getHeight();
g2d.drawString(text, (width - textWidth) / 2, (height + textHeight) / 2 - fm.getDescent());
g2d.dispose();
return image;
}
/**
* 创建渐变图片
*/
public static BufferedImage createGradientImage(int width, int height, Color startColor, Color endColor) {
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = image.createGraphics();
GradientPaint gradient = new GradientPaint(0, 0, startColor, 0, height, endColor);
g2d.setPaint(gradient);
g2d.fillRect(0, 0, width, height);
g2d.dispose();
return image;
}
/**
* 创建二维码样式的图片(黑白方块)
*/
public static BufferedImage createMockQRCode(int size) {
BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = image.createGraphics();
Random random = new Random(42); // 固定种子保证一致性
// 白色背景
g2d.setColor(Color.WHITE);
g2d.fillRect(0, 0, size, size);
// 随机黑白方块
int blockSize = size / 20;
g2d.setColor(Color.BLACK);
for (int x = 0; x < size; x += blockSize) {
for (int y = 0; y < size; y += blockSize) {
if (random.nextBoolean()) {
g2d.fillRect(x, y, blockSize, blockSize);
}
}
}
g2d.dispose();
return image;
}
/**
* 保存图片到文件(用于测试验证)
*/
public static void saveImage(BufferedImage image, String filePath) throws IOException {
File outputFile = new File(filePath);
outputFile.getParentFile().mkdirs();
ImageIO.write(image, "PNG", outputFile);
}
/**
* 将图片转换为字节数组
*/
public static byte[] imageToBytes(BufferedImage image, String format) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, format, baos);
return baos.toByteArray();
}
/**
* 创建头像样式的图片(带首字母)
*/
public static BufferedImage createAvatarImage(int size, String initial, Color bgColor) {
BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = image.createGraphics();
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 圆形背景
g2d.setColor(bgColor);
g2d.fillOval(0, 0, size, size);
// 首字母
g2d.setColor(Color.WHITE);
g2d.setFont(new Font("Arial", Font.BOLD, size / 2));
FontMetrics fm = g2d.getFontMetrics();
int textWidth = fm.stringWidth(initial);
int textHeight = fm.getHeight();
g2d.drawString(initial, (size - textWidth) / 2, (size + textHeight) / 2 - fm.getDescent());
g2d.dispose();
return image;
}
/**
* 验证图片尺寸
*/
public static boolean validateImageSize(BufferedImage image, int expectedWidth, int expectedHeight) {
return image.getWidth() == expectedWidth && image.getHeight() == expectedHeight;
}
/**
* 验证图片不为空白
*/
public static boolean isNotBlank(BufferedImage image) {
int width = image.getWidth();
int height = image.getHeight();
// 采样检查几个点
int samplePoints = 10;
Color firstColor = new Color(image.getRGB(0, 0));
boolean hasVariation = false;
for (int i = 0; i < samplePoints && !hasVariation; i++) {
int x = (width * i) / samplePoints;
int y = (height * i) / samplePoints;
if (x < width && y < height) {
Color sampleColor = new Color(image.getRGB(x, y));
if (!sampleColor.equals(firstColor)) {
hasVariation = true;
}
}
}
return hasVariation;
}
}

View File

@@ -0,0 +1,207 @@
package com.ycwl.basic.puzzle.test;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
* 测试数据构建器
* 用于创建测试用的模板和元素数据
*
* @author Claude
* @since 2025-01-17
*/
public class PuzzleTestDataBuilder {
/**
* 创建基础模板
*/
public static PuzzleTemplateEntity createBasicTemplate() {
PuzzleTemplateEntity template = new PuzzleTemplateEntity();
template.setId(1L);
template.setName("测试模板");
template.setCode("test_template");
template.setCanvasWidth(750);
template.setCanvasHeight(1334);
template.setBackgroundType(0);
template.setBackgroundColor("#FFFFFF");
template.setDescription("用于测试的模板");
template.setCategory("test");
template.setStatus(1);
template.setScenicId(null);
template.setCreateTime(new Date());
template.setUpdateTime(new Date());
template.setDeleted(0);
return template;
}
/**
* 创建自定义尺寸的模板
*/
public static PuzzleTemplateEntity createTemplate(String code, int width, int height, String bgColor) {
PuzzleTemplateEntity template = createBasicTemplate();
template.setCode(code);
template.setCanvasWidth(width);
template.setCanvasHeight(height);
template.setBackgroundColor(bgColor);
return template;
}
/**
* 创建图片元素
*/
public static PuzzleElementEntity createImageElement(Long templateId, String elementKey,
int x, int y, int width, int height,
int zIndex, String imageUrl) {
PuzzleElementEntity element = new PuzzleElementEntity();
element.setId(System.currentTimeMillis());
element.setTemplateId(templateId);
element.setElementType(1); // 图片
element.setElementKey(elementKey);
element.setElementName(elementKey);
element.setXPosition(x);
element.setYPosition(y);
element.setWidth(width);
element.setHeight(height);
element.setZIndex(zIndex);
element.setRotation(0);
element.setOpacity(100);
element.setDefaultImageUrl(imageUrl);
element.setImageFitMode("FILL"); // 使用FILL模式确保图片完全填充区域
element.setBorderRadius(0);
element.setCreateTime(new Date());
element.setUpdateTime(new Date());
element.setDeleted(0);
return element;
}
/**
* 创建圆角图片元素
*/
public static PuzzleElementEntity createRoundedImageElement(Long templateId, String elementKey,
int x, int y, int width, int height,
int zIndex, String imageUrl, int borderRadius) {
PuzzleElementEntity element = createImageElement(templateId, elementKey, x, y, width, height, zIndex, imageUrl);
element.setBorderRadius(borderRadius);
return element;
}
/**
* 创建文字元素
*/
public static PuzzleElementEntity createTextElement(Long templateId, String elementKey,
int x, int y, int width, int height,
int zIndex, String defaultText,
int fontSize, String fontColor) {
PuzzleElementEntity element = new PuzzleElementEntity();
element.setId(System.currentTimeMillis());
element.setTemplateId(templateId);
element.setElementType(2); // 文字
element.setElementKey(elementKey);
element.setElementName(elementKey);
element.setXPosition(x);
element.setYPosition(y);
element.setWidth(width);
element.setHeight(height);
element.setZIndex(zIndex);
element.setRotation(0);
element.setOpacity(100);
element.setDefaultText(defaultText);
element.setFontFamily("微软雅黑");
element.setFontSize(fontSize);
element.setFontColor(fontColor);
element.setFontWeight("NORMAL");
element.setFontStyle("NORMAL");
element.setTextAlign("LEFT");
element.setLineHeight(BigDecimal.valueOf(1.5));
element.setMaxLines(null);
element.setTextDecoration("NONE");
element.setCreateTime(new Date());
element.setUpdateTime(new Date());
element.setDeleted(0);
return element;
}
/**
* 创建粗体文字元素
*/
public static PuzzleElementEntity createBoldTextElement(Long templateId, String elementKey,
int x, int y, int width, int height,
int zIndex, String defaultText,
int fontSize, String fontColor, String textAlign) {
PuzzleElementEntity element = createTextElement(templateId, elementKey, x, y, width, height, zIndex, defaultText, fontSize, fontColor);
element.setFontWeight("BOLD");
element.setTextAlign(textAlign);
return element;
}
/**
* 创建完整的测试模板(带元素)
*/
public static List<PuzzleElementEntity> createFullTestElements(Long templateId) {
List<PuzzleElementEntity> elements = new ArrayList<>();
// 背景图片
elements.add(createImageElement(templateId, "bgImage", 0, 0, 750, 400, 0,
"https://example.com/bg.jpg"));
// 用户头像(圆形)
elements.add(createRoundedImageElement(templateId, "userAvatar", 50, 100, 80, 80, 1,
"https://example.com/avatar.jpg", 40));
// 用户名
elements.add(createBoldTextElement(templateId, "userName", 150, 110, 200, 60, 1,
"用户昵称", 24, "#333333", "LEFT"));
// 订单号标题
elements.add(createTextElement(templateId, "orderTitle", 50, 200, 650, 40, 1,
"订单号:", 16, "#666666"));
// 订单号内容
elements.add(createBoldTextElement(templateId, "orderNumber", 50, 240, 650, 40, 1,
"ORDER123456", 18, "#333333", "LEFT"));
return elements;
}
/**
* 创建1020x1520的现实场景元素
* 上方3张图片,每张470高度(共1410),底下110像素左侧二维码右侧文字
*/
public static List<PuzzleElementEntity> createRealScenarioElements(Long templateId) {
List<PuzzleElementEntity> elements = new ArrayList<>();
// 画布总高度1520,每张图片460高度
int imageHeight = 460;
int bottomHeight = 140; // 剩余高度:1520 - 460*3 = 140
// 上方3张图片,每张470高度
elements.add(createImageElement(templateId, "image1", 0, 0, 1020, imageHeight, 1,
"https://example.com/image1.jpg"));
elements.add(createImageElement(templateId, "image2", 0, imageHeight, 1020, imageHeight, 1,
"https://example.com/image2.jpg"));
elements.add(createImageElement(templateId, "image3", 0, imageHeight * 2, 1020, imageHeight, 1,
"https://example.com/image3.jpg"));
// 底部区域起始Y坐标
int bottomY = imageHeight * 3; // 1380
// 底部二维码(左侧,占满底部高度)
elements.add(createImageElement(templateId, "qrCode", 20, bottomY + 10, 120, 120, 2,
"https://example.com/qrcode.png"));
// 底部文字(右侧,占满剩余宽度和高度)
int textX = 140; // 二维码右侧留20像素间距
int textWidth = 1020 - textX - 20; // 右侧留20像素边距
elements.add(createBoldTextElement(templateId, "bottomText", textX, bottomY + 20, textWidth, bottomHeight - 20, 2,
"扫码查看详情", 18, "#333333", "RIGHT"));
return elements;
}
}

View File

@@ -0,0 +1,248 @@
package com.ycwl.basic.puzzle.test;
import com.ycwl.basic.puzzle.entity.PuzzleElementEntity;
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 现实场景自动化测试助手
* 提供便捷方法快速创建和测试1020x1520拼图场景
*
* @author Claude
* @since 2025-01-17
*/
public class RealScenarioTestHelper {
private final PuzzleImageRenderer renderer;
private final Path outputDir;
private final Map<String, File> resourceFiles;
public RealScenarioTestHelper(Path outputDir) throws IOException {
this.renderer = new PuzzleImageRenderer();
this.outputDir = outputDir;
this.resourceFiles = new HashMap<>();
initializeMockResources();
}
/**
* 初始化Mock资源(图片、二维码等)
*/
private void initializeMockResources() throws IOException {
Files.createDirectories(outputDir);
// 创建3张场景图片(每张470高度)
createSceneImage("image1", "1", new Color(255, 200, 200));
createSceneImage("image2", "2", new Color(200, 255, 200));
createSceneImage("image3", "3", new Color(200, 200, 255));
// 创建二维码(100x100)
BufferedImage qrCode = MockImageUtil.createMockQRCode(100);
File qrFile = saveImage(qrCode, "qrcode.png");
resourceFiles.put("qrCode", qrFile);
}
/**
* 创建场景图片(1020x470)
*/
private void createSceneImage(String key, String text, Color bgColor) throws IOException {
BufferedImage image = MockImageUtil.createImageWithText(1020, 460, text, bgColor, Color.BLACK);
File file = saveImage(image, key + ".jpg");
resourceFiles.put(key, file);
}
/**
* 保存图片
*/
private File saveImage(BufferedImage image, String filename) throws IOException {
File file = outputDir.resolve(filename).toFile();
ImageIO.write(image, filename.endsWith(".png") ? "PNG" : "JPG", file);
return file;
}
/**
* 创建并测试完整的现实场景
*
* @param bottomText 底部显示的文字
* @return 生成的图片文件
*/
public TestResult createAndTestRealScenario(String bottomText) throws IOException {
long startTime = System.currentTimeMillis();
// 1. 创建模板
PuzzleTemplateEntity template = createTemplate();
// 2. 创建元素
List<PuzzleElementEntity> elements = createElements(template.getId());
// 3. 准备动态数据
Map<String, String> dynamicData = prepareDynamicData(bottomText);
// 4. 渲染图片
BufferedImage result = renderer.render(template, elements, dynamicData);
// 5. 保存结果
File outputFile = saveImage(result, "real_scenario_result.png");
long duration = System.currentTimeMillis() - startTime;
return new TestResult(
outputFile,
result.getWidth(),
result.getHeight(),
outputFile.length(),
duration,
true,
"测试成功"
);
}
/**
* 创建1020x1520模板
*/
private PuzzleTemplateEntity createTemplate() {
return PuzzleTestDataBuilder.createTemplate(
"real_scenario_auto",
1020,
1520,
"#F5F5F5"
);
}
/**
* 创建元素列表
*/
private List<PuzzleElementEntity> createElements(Long templateId) {
return PuzzleTestDataBuilder.createRealScenarioElements(templateId);
}
/**
* 准备动态数据
*/
private Map<String, String> prepareDynamicData(String bottomText) {
Map<String, String> data = new HashMap<>();
data.put("image1", resourceFiles.get("image1").getAbsolutePath());
data.put("image2", resourceFiles.get("image2").getAbsolutePath());
data.put("image3", resourceFiles.get("image3").getAbsolutePath());
data.put("qrCode", resourceFiles.get("qrCode").getAbsolutePath());
data.put("bottomText", bottomText != null ? bottomText : "扫码查看详情");
return data;
}
/**
* 验证生成的图片
*/
public boolean validateResult(BufferedImage image) {
// 验证尺寸
if (image.getWidth() != 1020 || image.getHeight() != 1520) {
return false;
}
// 验证不为空白
return MockImageUtil.isNotBlank(image);
}
/**
* 打印测试报告
*/
public void printTestReport(TestResult result) {
System.out.println("\n" + "=".repeat(60));
System.out.println("📊 拼图功能现实场景测试报告");
System.out.println("=".repeat(60));
System.out.println("✅ 测试状态: " + (result.isSuccess() ? "通过" : "失败"));
System.out.println("📁 输出文件: " + result.getOutputFile().getAbsolutePath());
System.out.println("📐 图片尺寸: " + result.getWidth() + " x " + result.getHeight() + " 像素");
System.out.println("💾 文件大小: " + result.getFileSize() / 1024 + " KB");
System.out.println("⏱️ 渲染耗时: " + result.getDuration() + " ms");
System.out.println("📝 测试消息: " + result.getMessage());
System.out.println("=".repeat(60) + "\n");
}
/**
* 测试结果类
*/
public static class TestResult {
private final File outputFile;
private final int width;
private final int height;
private final long fileSize;
private final long duration;
private final boolean success;
private final String message;
public TestResult(File outputFile, int width, int height, long fileSize,
long duration, boolean success, String message) {
this.outputFile = outputFile;
this.width = width;
this.height = height;
this.fileSize = fileSize;
this.duration = duration;
this.success = success;
this.message = message;
}
public File getOutputFile() {
return outputFile;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
public long getFileSize() {
return fileSize;
}
public long getDuration() {
return duration;
}
public boolean isSuccess() {
return success;
}
public String getMessage() {
return message;
}
}
/**
* 快速测试方法(主方法,可直接运行)
*/
public static void main(String[] args) throws IOException {
// 创建临时目录
Path tempDir = Files.createTempDirectory("puzzle_test_");
System.out.println("📂 测试目录: " + tempDir.toAbsolutePath());
// 创建测试助手
RealScenarioTestHelper helper = new RealScenarioTestHelper(tempDir);
// 执行测试
TestResult result = helper.createAndTestRealScenario("奇遇时光乐园\n2025.11.11");
// 打印报告
helper.printTestReport(result);
// 验证结果
if (result.isSuccess()) {
System.out.println("🎉 恭喜!现实场景测试全部通过!");
System.out.println("💡 你可以打开输出图片查看效果: " + result.getOutputFile().getAbsolutePath());
} else {
System.err.println("❌ 测试失败: " + result.getMessage());
}
}
}