diff --git a/src/main/java/com/ycwl/basic/image/pipeline/CLAUDE.md b/src/main/java/com/ycwl/basic/image/pipeline/CLAUDE.md new file mode 100644 index 00000000..0bb59703 --- /dev/null +++ b/src/main/java/com/ycwl/basic/image/pipeline/CLAUDE.md @@ -0,0 +1,595 @@ +# Image Pipeline 图片处理管线 + +## 概述 + +Image Pipeline 是一个通用的、可扩展的图片处理管线框架,用于组织和执行一系列图片处理操作(Stage)。 + +### 核心特性 + +- **责任链模式**: 将图片处理流程拆分为独立的 Stage,按顺序执行 +- **Builder 模式**: 灵活组装管线,支持条件性添加 Stage +- **动态 Stage 添加**: 支持在运行时根据条件动态添加后续 Stage +- **降级策略**: 支持多级降级执行,确保管线在异常情况下的稳定性 +- **配置驱动**: 支持通过外部配置控制 Stage 的启用/禁用 +- **类型安全**: 使用泛型和枚举确保类型安全 +- **解耦设计**: Context 独立于业务模型,支持多种使用场景 + +## 包结构 + +``` +com.ycwl.basic.image.pipeline +├── annotation/ # 注解定义 +│ └── StageConfig # Stage 配置注解 +├── core/ # 核心类 +│ ├── AbstractPipelineStage # Stage 抽象基类 +│ ├── PhotoProcessContext # 管线上下文 +│ ├── Pipeline # 管线执行器 +│ ├── PipelineBuilder # 管线构建器 +│ ├── PipelineStage # Stage 接口 +│ └── StageResult # Stage 执行结果 +├── enums/ # 枚举定义 +│ ├── ImageSource # 图片来源枚举 +│ ├── ImageType # 图片类型枚举 +│ ├── PipelineScene # 管线场景枚举 +│ └── StageOptionalMode # Stage 可选模式枚举 +├── exception/ # 异常类 +│ ├── PipelineException # 管线异常 +│ └── StageExecutionException # Stage 执行异常 +├── stages/ # 具体 Stage 实现 +│ ├── CleanupStage # 清理临时文件 +│ ├── ConditionalRotateStage # 条件性旋转 +│ ├── DownloadStage # 下载图片 +│ ├── ImageEnhanceStage # 图像增强(超分) +│ ├── ImageOrientationStage # 图像方向检测 +│ ├── ImageQualityCheckStage # 图像质量检测 +│ ├── PuzzleBorderStage # 拼图边框处理 +│ ├── RestoreOrientationStage # 恢复图片方向 +│ ├── SourcePhotoUpdateStage # 源图片更新 +│ ├── UploadStage # 上传图片 +│ └── WatermarkStage # 水印处理 +└── util/ # 工具类 + └── TempFileManager # 临时文件管理器 +``` + +## 核心组件 + +### 1. Pipeline - 管线执行器 + +**职责**: 按顺序执行一系列 Stage,管理执行流程和异常处理。 + +**关键特性**: +- 顺序执行所有 Stage +- 支持动态添加后续 Stage +- 循环检测(最大执行 100 个 Stage) +- 详细的日志输出(带状态图标) + +**使用示例**: +```java +Pipeline pipeline = new PipelineBuilder<>("MyPipeline") + .addStage(new DownloadStage()) + .addStage(new WatermarkStage()) + .addStage(new UploadStage()) + .addStage(new CleanupStage()) + .build(); + +boolean success = pipeline.execute(context); +``` + +### 2. PhotoProcessContext - 管线上下文 + +**职责**: 在各个 Stage 之间传递状态和数据,提供临时文件管理。 + +**核心字段**: +- `processId`: 处理过程唯一标识,用于隔离临时文件 +- `originalUrl`: 原图 URL +- `scenicId`: 景区 ID +- `imageType`: 图片类型(普通照片/拼图/手机上传) +- `tempFileManager`: 临时文件管理器 + +**静态工厂方法**: +```java +// 从打印订单创建(打印场景) +PhotoProcessContext context = PhotoProcessContext.fromPrinterOrderItem(orderItem, scenicId); + +// 为超分辨率场景创建 +PhotoProcessContext context = PhotoProcessContext.forSuperResolution(itemId, url, scenicId); + +// 使用 Builder 自定义创建 +PhotoProcessContext context = PhotoProcessContext.builder() + .processId("custom-id") + .originalUrl("https://example.com/image.jpg") + .scenicId(12345L) + .imageType(ImageType.NORMAL_PHOTO) + .source(ImageSource.IPC) + .scene(PipelineScene.IMAGE_PRINT) + .build(); +``` + +**重要方法**: +- `getCurrentFile()`: 获取当前处理中的文件 +- `updateProcessedFile(File)`: 更新处理后的文件 +- `setResultUrl(String)`: 设置最终结果 URL(会触发回调) +- `cleanup()`: 清理所有临时文件 +- `isStageEnabled(stageId, default)`: 判断 Stage 是否启用 + +### 3. AbstractPipelineStage - Stage 抽象基类 + +**职责**: 提供 Stage 的通用实现和模板方法。 + +**执行流程**: +``` +shouldExecute() → beforeExecute() → doExecute() → afterExecute() +``` + +**子类需要实现**: +- `getName()`: 返回 Stage 名称 +- `doExecute(context)`: 实现具体处理逻辑 +- `shouldExecuteByBusinessLogic(context)`: (可选)实现条件判断 + +**Stage 执行判断逻辑**: +1. 检查 `@StageConfig` 注解 +2. 根据 `optionalMode` 决定是否检查外部配置 + - `FORCE_ON`: 强制执行,不检查外部配置 + - `SUPPORT`: 检查外部配置(`context.isStageEnabled()`) + - `UNSUPPORT`: 不检查外部配置 +3. 执行业务逻辑判断(`shouldExecuteByBusinessLogic()`) + +### 4. StageResult - Stage 执行结果 + +**状态类型**: +- `SUCCESS`: 执行成功 +- `SKIPPED`: 跳过执行 +- `FAILED`: 执行失败(会终止管线) +- `DEGRADED`: 降级执行(继续管线但记录警告) + +**静态工厂方法**: +```java +// 成功 +StageResult.success(); +StageResult.success("处理完成"); + +// 成功并动态添加后续 Stage +StageResult.successWithNext("质量不佳,添加增强", new ImageEnhanceStage()); + +// 跳过 +StageResult.skipped("条件不满足"); + +// 失败 +StageResult.failed("下载失败"); +StageResult.failed("处理失败", exception); + +// 降级 +StageResult.degraded("使用备用方案"); +``` + +### 5. @StageConfig - Stage 配置注解 + +**字段**: +- `stageId`: Stage 唯一标识(用于外部配置控制) +- `optionalMode`: 可选模式 + - `FORCE_ON`: 强制执行(如 DownloadStage、CleanupStage) + - `SUPPORT`: 支持外部控制(如 WatermarkStage、ImageEnhanceStage) + - `UNSUPPORT`: 不支持外部控制(如 RestoreOrientationStage) +- `defaultEnabled`: 默认是否启用 +- `description`: 描述信息 + +**示例**: +```java +@StageConfig( + stageId = "watermark", + optionalMode = StageOptionalMode.SUPPORT, + description = "水印处理", + defaultEnabled = true +) +public class WatermarkStage extends AbstractPipelineStage { + // ... +} +``` + +## Stage 列表 + +### 核心 Stage + +| Stage | 职责 | Optional Mode | 执行条件 | +|-------|------|---------------|---------| +| DownloadStage | 从 URL 下载图片到本地 | FORCE_ON | 总是执行 | +| CleanupStage | 清理所有临时文件 | FORCE_ON | 总是执行(优先级 999) | + +### 图片处理 Stage + +| Stage | 职责 | Optional Mode | 执行条件 | +|-------|------|---------------|---------| +| ImageOrientationStage | 检测图片方向(横竖) | UNSUPPORT | 仅普通照片 | +| ConditionalRotateStage | 条件性旋转(竖图变横图) | UNSUPPORT | 仅竖图 | +| RestoreOrientationStage | 恢复图片方向(横图变回竖图) | UNSUPPORT | 需要旋转的照片 | +| WatermarkStage | 添加水印 | SUPPORT | 仅普通照片 | +| PuzzleBorderStage | 处理拼图边框 | UNSUPPORT | 仅拼图 | +| ImageEnhanceStage | 图像增强(超分) | SUPPORT | 可配置 | +| ImageQualityCheckStage | 图像质量检测 | SUPPORT | 仅普通照片 | + +### 存储 Stage + +| Stage | 职责 | Optional Mode | 执行条件 | +|-------|------|---------------|---------| +| UploadStage | 上传图片到存储服务 | FORCE_ON | 总是执行 | +| SourcePhotoUpdateStage | 更新源图片记录 | UNSUPPORT | 总是执行 | + +## 典型管线示例 + +### 1. 打印照片处理管线 + +```java +Pipeline pipeline = new PipelineBuilder<>("PrintPipeline") + .addStage(new DownloadStage()) // 1. 下载 + .addStage(new ImageOrientationStage()) // 2. 检测方向 + .addStage(new ConditionalRotateStage()) // 3. 旋转竖图 + .addStage(new WatermarkStage()) // 4. 添加水印 + .addStage(new RestoreOrientationStage()) // 5. 恢复方向 + .addStage(new UploadStage()) // 6. 上传 + .addStage(new CleanupStage()) // 7. 清理 + .build(); +``` + +### 2. 拼图处理管线 + +```java +Pipeline pipeline = new PipelineBuilder<>("PuzzlePipeline") + .addStage(new DownloadStage()) // 1. 下载 + .addStage(new PuzzleBorderStage()) // 2. 添加拼图边框 + .addStage(new UploadStage()) // 3. 上传 + .addStage(new CleanupStage()) // 4. 清理 + .build(); +``` + +### 3. 超分辨率增强管线 + +```java +Pipeline pipeline = new PipelineBuilder<>("SuperResolutionPipeline") + .addStage(new DownloadStage()) // 1. 下载 + .addStage(new ImageEnhanceStage(config)) // 2. 超分增强 + .addStage(new SourcePhotoUpdateStage(sourceService, sourceId)) // 3. 更新记录 + .addStage(new CleanupStage()) // 4. 清理 + .build(); +``` + +### 4. 带质量检测的管线(动态 Stage) + +```java +Pipeline pipeline = new PipelineBuilder<>("QualityCheckPipeline") + .addStage(new DownloadStage()) // 1. 下载 + .addStage(new ImageQualityCheckStage()) // 2. 质量检测(可能动态添加 ImageEnhanceStage) + .addStage(new WatermarkStage()) // 3. 水印 + .addStage(new UploadStage()) // 4. 上传 + .addStage(new CleanupStage()) // 5. 清理 + .build(); +``` + +## 扩展指南 + +### 如何创建新的 Stage + +1. **继承 AbstractPipelineStage**: +```java +@Slf4j +@StageConfig( + stageId = "my_stage", + optionalMode = StageOptionalMode.SUPPORT, + description = "我的自定义 Stage", + defaultEnabled = true +) +public class MyStage extends AbstractPipelineStage { + + @Override + public String getName() { + return "MyStage"; + } + + @Override + protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) { + // 实现条件判断 + return context.getImageType() == ImageType.NORMAL_PHOTO; + } + + @Override + protected StageResult doExecute(PhotoProcessContext context) { + File currentFile = context.getCurrentFile(); + + try { + // 1. 获取输入文件 + if (currentFile == null || !currentFile.exists()) { + return StageResult.failed("当前文件不存在"); + } + + // 2. 处理图片 + File outputFile = context.getTempFileManager() + .createTempFile("my_output", ".jpg"); + + // 执行具体处理逻辑 + doSomethingWithImage(currentFile, outputFile); + + // 3. 更新 Context + context.updateProcessedFile(outputFile); + + // 4. 返回成功结果 + return StageResult.success("处理完成"); + + } catch (Exception e) { + log.error("处理失败", e); + return StageResult.failed("处理失败: " + e.getMessage(), e); + } + } + + private void doSomethingWithImage(File input, File output) { + // 具体实现 + } +} +``` + +2. **添加到管线**: +```java +pipeline.addStage(new MyStage()); +``` + +### 如何实现降级策略 + +参考 `WatermarkStage` 的实现,使用循环尝试多种方案: + +```java +@Override +protected StageResult doExecute(PhotoProcessContext context) { + List strategies = Arrays.asList( + Strategy.ADVANCED, + Strategy.STANDARD, + Strategy.BASIC + ); + + for (int i = 0; i < strategies.size(); i++) { + Strategy strategy = strategies.get(i); + try { + StageResult result = tryStrategy(context, strategy); + + if (i > 0) { + // 使用了降级策略 + return StageResult.degraded("降级到: " + strategy); + } + + return result; + + } catch (Exception e) { + log.warn("策略 {} 失败: {}", strategy, e.getMessage()); + } + } + + // 所有策略都失败 + return StageResult.degraded("所有策略失败,跳过处理"); +} +``` + +### 如何动态添加后续 Stage + +参考 `ImageQualityCheckStage` 的实现: + +```java +@Override +protected StageResult doExecute(PhotoProcessContext context) { + boolean needsEnhancement = checkQuality(context.getCurrentFile()); + + if (needsEnhancement) { + ImageEnhanceStage enhanceStage = new ImageEnhanceStage(); + return StageResult.successWithNext("质量不佳,添加增强", enhanceStage); + } + + return StageResult.success("质量良好"); +} +``` + +## 最佳实践 + +### 1. Context 管理 + +- **总是使用静态工厂方法或 Builder**: 避免直接调用构造函数 +- **及时清理临时文件**: 在 finally 块或使用 CleanupStage +- **使用回调更新外部状态**: 通过 `resultUrlCallback` 而非直接操作业务对象 + +### 2. Stage 设计 + +- **单一职责**: 每个 Stage 只做一件事 +- **可组合**: Stage 应该可以灵活组合 +- **幂等性**: 相同输入应产生相同输出 +- **异常处理**: 捕获异常并返回 `StageResult.failed()` 或 `StageResult.degraded()` +- **日志记录**: 在关键操作处记录 debug/info 日志 + +### 3. 管线构建 + +- **CleanupStage 总是最后**: 确保临时文件总是被清理 +- **DownloadStage 总是最前**: 确保有本地文件可用 +- **合理使用 optionalMode**: + - 必需的 Stage 使用 `FORCE_ON` + - 可选的 Stage 使用 `SUPPORT` + - 内部逻辑控制的 Stage 使用 `UNSUPPORT` + +### 4. 性能优化 + +- **复用 TempFileManager**: 自动管理临时文件生命周期 +- **避免重复下载**: 使用 `context.getCurrentFile()` 获取最新文件 +- **及时更新 processedFile**: 使用 `context.updateProcessedFile()` 通知下一个 Stage + +### 5. 错误处理 + +- **失败即停止**: 使用 `StageResult.failed()` 终止管线 +- **降级继续执行**: 使用 `StageResult.degraded()` 记录问题但继续执行 +- **跳过非关键 Stage**: 使用 `StageResult.skipped()` 表示条件不满足 +- **携带异常信息**: `StageResult.failed(message, exception)` 便于排查问题 + +## 配置控制 + +### 1. 景区级配置 + +通过 `ScenicConfigManager` 加载景区配置: + +```java +context.setScenicConfigManager(scenicConfigManager); +``` + +Stage 内部可以获取配置: + +```java +ScenicConfigManager config = context.getScenicConfigManager(); +Boolean enabled = config.getBoolean("my_feature_enabled"); +String value = config.getString("my_setting"); +``` + +### 2. 请求级配置 + +通过 `loadStageConfig()` 加载请求参数: + +```java +Map stageConfig = new HashMap<>(); +stageConfig.put("watermark", false); // 禁用水印 +stageConfig.put("image_enhance", true); // 启用增强 + +context.loadStageConfig(scenicConfigManager, stageConfig); +``` + +### 3. Stage 启用判断 + +在 `AbstractPipelineStage.shouldExecute()` 中自动处理: + +```java +// 对于 optionalMode = SUPPORT 的 Stage +@StageConfig( + stageId = "watermark", + optionalMode = StageOptionalMode.SUPPORT, + defaultEnabled = true +) +public class WatermarkStage extends AbstractPipelineStage { + // 如果外部配置禁用了 watermark,则不执行 +} +``` + +## 测试指南 + +### 单元测试结构 + +```java +@Test +public void testStageSuccess() { + // 1. 准备 Context + PhotoProcessContext context = PhotoProcessContext.builder() + .processId("test-1") + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .build(); + + // 2. 创建 Stage + MyStage stage = new MyStage(); + + // 3. 执行 + StageResult result = stage.execute(context); + + // 4. 断言 + assertTrue(result.isSuccess()); + assertNotNull(context.getCurrentFile()); +} + +@Test +public void testStageSkipped() { + // 测试条件不满足时跳过 +} + +@Test +public void testStageFailed() { + // 测试异常情况 +} + +@Test +public void testStageDegraded() { + // 测试降级情况 +} +``` + +### 管线集成测试 + +```java +@Test +public void testPipelineExecution() { + Pipeline pipeline = new PipelineBuilder<>("TestPipeline") + .addStage(new DownloadStage()) + .addStage(new WatermarkStage()) + .addStage(new UploadStage()) + .addStage(new CleanupStage()) + .build(); + + PhotoProcessContext context = createTestContext(); + + boolean success = pipeline.execute(context); + + assertTrue(success); + assertNotNull(context.getResultUrl()); +} +``` + +## 常见问题 + +### Q: 如何跳过某个 Stage? + +A: 有三种方式: +1. 使用外部配置(适用于 `optionalMode = SUPPORT` 的 Stage) +2. 在 `shouldExecuteByBusinessLogic()` 中返回 false +3. 构建管线时不添加该 Stage + +### Q: 如何在运行时决定是否添加某个 Stage? + +A: 使用 `PipelineBuilder.addStageIf()`: +```java +builder.addStageIf(needWatermark, new WatermarkStage()); +``` + +或者使用动态 Stage 添加(`StageResult.successWithNext()`)。 + +### Q: 如何处理 Stage 执行失败? + +A: 返回 `StageResult.failed()`,管线会立即终止。如果希望继续执行,使用 `StageResult.degraded()`。 + +### Q: 临时文件什么时候被清理? + +A: 由 `CleanupStage` 负责,通常放在管线最后。也可以手动调用 `context.cleanup()`。 + +### Q: 如何获取最终处理结果? + +A: 使用 `context.getResultUrl()`,或者在构建 Context 时提供 `resultUrlCallback`。 + +### Q: 如何支持新的图片来源或场景? + +A: 扩展 `ImageSource` 或 `PipelineScene` 枚举,然后在 Stage 中添加相应的判断逻辑。 + +## 架构演进 + +### 已实现的特性 + +- ✅ 责任链模式的基础管线框架 +- ✅ Builder 模式的管线构建 +- ✅ 动态 Stage 添加 +- ✅ 多级降级策略 +- ✅ 配置驱动的 Stage 控制 +- ✅ Context 与业务模型解耦 +- ✅ 类型安全的图片分类 + +### 未来可能的改进 + +- 🔄 支持并行执行某些 Stage +- 🔄 支持 Stage 执行超时控制 +- 🔄 支持管线执行的暂停/恢复 +- 🔄 支持更细粒度的性能监控 +- 🔄 支持 Stage 执行的重试机制 +- 🔄 支持管线执行的可视化追踪 + +## 相关文档 + +- [ImageUtils 工具类](../utils/ImageUtils.java) +- [StorageFactory 存储工厂](../storage/StorageFactory.java) +- [WatermarkFactory 水印工厂](../image/watermark/ImageWatermarkFactory.java) + +## 维护者 + +- 图片处理管线 - 基础架构团队 diff --git a/src/main/java/com/ycwl/basic/image/pipeline/core/StageResult.java b/src/main/java/com/ycwl/basic/image/pipeline/core/StageResult.java index 5fef69a4..7b25de5d 100644 --- a/src/main/java/com/ycwl/basic/image/pipeline/core/StageResult.java +++ b/src/main/java/com/ycwl/basic/image/pipeline/core/StageResult.java @@ -29,7 +29,9 @@ public class StageResult { this.status = status; this.message = message; this.exception = exception; - this.nextStages = nextStages != null ? new ArrayList<>(nextStages) : Collections.emptyList(); + this.nextStages = nextStages != null + ? Collections.unmodifiableList(new ArrayList<>(nextStages)) + : Collections.emptyList(); } public static StageResult success() { diff --git a/src/test/java/com/ycwl/basic/image/pipeline/core/PhotoProcessContextTest.java b/src/test/java/com/ycwl/basic/image/pipeline/core/PhotoProcessContextTest.java new file mode 100644 index 00000000..44955c61 --- /dev/null +++ b/src/test/java/com/ycwl/basic/image/pipeline/core/PhotoProcessContextTest.java @@ -0,0 +1,232 @@ +package com.ycwl.basic.image.pipeline.core; + +import com.ycwl.basic.image.pipeline.enums.ImageSource; +import com.ycwl.basic.image.pipeline.enums.ImageType; +import com.ycwl.basic.image.pipeline.enums.PipelineScene; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * PhotoProcessContext 单元测试 + */ +class PhotoProcessContextTest { + + @Test + void testBuilder_WithRequiredFields() { + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .build(); + + assertNotNull(context); + assertEquals("https://example.com/test.jpg", context.getOriginalUrl()); + assertEquals(123L, context.getScenicId()); + assertNotNull(context.getProcessId()); + assertEquals(ImageType.NORMAL_PHOTO, context.getImageType()); + } + + @Test + void testBuilder_WithAllFields() { + PhotoProcessContext context = PhotoProcessContext.builder() + .processId("test-123") + .originalUrl("https://example.com/test.jpg") + .scenicId(456L) + .imageType(ImageType.PUZZLE) + .source(ImageSource.IPC) + .scene(PipelineScene.IMAGE_PRINT) + .build(); + + assertEquals("test-123", context.getProcessId()); + assertEquals("https://example.com/test.jpg", context.getOriginalUrl()); + assertEquals(456L, context.getScenicId()); + assertEquals(ImageType.PUZZLE, context.getImageType()); + assertEquals(ImageSource.IPC, context.getSource()); + assertEquals(PipelineScene.IMAGE_PRINT, context.getScene()); + } + + @Test + void testBuilder_MissingOriginalUrl_ShouldThrow() { + PhotoProcessContext.Builder builder = PhotoProcessContext.builder() + .scenicId(123L); + + Exception exception = assertThrows(IllegalArgumentException.class, builder::build); + + assertEquals("originalUrl is required", exception.getMessage()); + } + + @Test + void testBuilder_MissingScenicId_ShouldThrow() { + PhotoProcessContext.Builder builder = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg"); + + Exception exception = assertThrows(IllegalArgumentException.class, builder::build); + + assertEquals("scenicId is required", exception.getMessage()); + } + + @Test + void testBuilder_AutoGenerateProcessId() { + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .build(); + + assertNotNull(context.getProcessId()); + assertFalse(context.getProcessId().isEmpty()); + } + + @Test + void testForSuperResolution() { + PhotoProcessContext context = PhotoProcessContext.forSuperResolution( + 12345L, + "https://example.com/photo.jpg", + 678L + ); + + assertEquals("12345", context.getProcessId()); + assertEquals("https://example.com/photo.jpg", context.getOriginalUrl()); + assertEquals(678L, context.getScenicId()); + assertEquals(ImageType.NORMAL_PHOTO, context.getImageType()); + assertEquals(ImageSource.IPC, context.getSource()); + assertEquals(PipelineScene.SOURCE_PHOTO_SUPER_RESOLUTION, context.getScene()); + } + + @Test + void testResultUrlCallback() { + AtomicReference capturedUrl = new AtomicReference<>(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .resultUrlCallback(capturedUrl::set) + .build(); + + context.setResultUrl("https://cdn.example.com/result.jpg"); + + assertEquals("https://cdn.example.com/result.jpg", context.getResultUrl()); + assertEquals("https://cdn.example.com/result.jpg", capturedUrl.get()); + } + + @Test + void testResultUrlCallback_WithoutCallback() { + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .build(); + + assertDoesNotThrow(() -> { + context.setResultUrl("https://cdn.example.com/result.jpg"); + }); + + assertEquals("https://cdn.example.com/result.jpg", context.getResultUrl()); + } + + @Test + void testGetCurrentFile_NoProcessedFile() { + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .build(); + + File originalFile = new File("original.jpg"); + context.setOriginalFile(originalFile); + + assertEquals(originalFile, context.getCurrentFile()); + } + + @Test + void testGetCurrentFile_WithProcessedFile() { + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .build(); + + File originalFile = new File("original.jpg"); + File processedFile = new File("processed.jpg"); + + context.setOriginalFile(originalFile); + context.setProcessedFile(processedFile); + + assertEquals(processedFile, context.getCurrentFile()); + } + + @Test + void testLoadStageConfig() { + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .build(); + + Map stageConfig = new HashMap<>(); + stageConfig.put("watermark", false); + stageConfig.put("enhance", true); + + context.loadStageConfig(null, stageConfig); + + assertFalse(context.isStageEnabled("watermark", true)); + assertTrue(context.isStageEnabled("enhance", false)); + } + + @Test + void testIsStageEnabled_WithDefault() { + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .build(); + + assertTrue(context.isStageEnabled("unknown_stage", true)); + assertFalse(context.isStageEnabled("unknown_stage", false)); + } + + @Test + void testIsStageEnabled_WithConfig() { + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .build(); + + Map config = new HashMap<>(); + config.put("watermark", false); + context.loadStageConfig(null, config); + + assertFalse(context.isStageEnabled("watermark", true)); + } + + @Test + void testTempFileManager() { + PhotoProcessContext context = PhotoProcessContext.builder() + .processId("test-temp") + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .build(); + + assertNotNull(context.getTempFileManager()); + } + + @Test + void testDefaultImageType() { + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .build(); + + assertEquals(ImageType.NORMAL_PHOTO, context.getImageType()); + } + + @Test + void testSetImageType() { + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .imageType(ImageType.PUZZLE) + .build(); + + assertEquals(ImageType.PUZZLE, context.getImageType()); + } +} diff --git a/src/test/java/com/ycwl/basic/image/pipeline/core/PipelineBuilderTest.java b/src/test/java/com/ycwl/basic/image/pipeline/core/PipelineBuilderTest.java new file mode 100644 index 00000000..11a96018 --- /dev/null +++ b/src/test/java/com/ycwl/basic/image/pipeline/core/PipelineBuilderTest.java @@ -0,0 +1,120 @@ +package com.ycwl.basic.image.pipeline.core; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * PipelineBuilder 单元测试 + */ +class PipelineBuilderTest { + + @Test + void testBuildEmptyPipeline_ShouldThrowException() { + PipelineBuilder builder = new PipelineBuilder<>(); + + Exception exception = assertThrows(IllegalStateException.class, builder::build); + + assertEquals("管线至少需要一个Stage", exception.getMessage()); + } + + @Test + void testBuildWithDefaultName() { + PipelineBuilder builder = new PipelineBuilder<>(); + builder.addStage(new MockStage("stage1")); + + Pipeline pipeline = builder.build(); + + assertEquals("DefaultPipeline", pipeline.getName()); + assertEquals(1, pipeline.getStageCount()); + } + + @Test + void testBuildWithCustomName() { + Pipeline pipeline = new PipelineBuilder("CustomPipeline") + .addStage(new MockStage("stage1")) + .build(); + + assertEquals("CustomPipeline", pipeline.getName()); + } + + @Test + void testAddStage() { + PipelineBuilder builder = new PipelineBuilder<>(); + builder.addStage(new MockStage("stage1")); + builder.addStage(new MockStage("stage2")); + + Pipeline pipeline = builder.build(); + + assertEquals(2, pipeline.getStageCount()); + } + + @Test + void testAddStageWithNull_ShouldIgnore() { + PipelineBuilder builder = new PipelineBuilder<>(); + builder.addStage(new MockStage("stage1")); + builder.addStage(null); + builder.addStage(new MockStage("stage2")); + + Pipeline pipeline = builder.build(); + + assertEquals(2, pipeline.getStageCount()); + } + + @Test + void testAddStageIf_WhenTrue() { + PipelineBuilder builder = new PipelineBuilder<>(); + builder.addStageIf(true, new MockStage("stage1")); + + Pipeline pipeline = builder.build(); + + assertEquals(1, pipeline.getStageCount()); + } + + @Test + void testAddStageIf_WhenFalse() { + PipelineBuilder builder = new PipelineBuilder<>(); + builder.addStage(new MockStage("required")); + builder.addStageIf(false, new MockStage("optional")); + + Pipeline pipeline = builder.build(); + + assertEquals(1, pipeline.getStageCount()); + assertTrue(pipeline.getStageNames().contains("MockStage-required")); + assertFalse(pipeline.getStageNames().contains("MockStage-optional")); + } + + @Test + void testFluentAPI() { + Pipeline pipeline = new PipelineBuilder() + .name("FluentPipeline") + .addStage(new MockStage("stage1")) + .addStage(new MockStage("stage2")) + .addStageIf(true, new MockStage("stage3")) + .build(); + + assertEquals("FluentPipeline", pipeline.getName()); + assertEquals(3, pipeline.getStageCount()); + } + + /** + * Mock Stage for testing + */ + private static class MockStage extends AbstractPipelineStage { + private final String id; + + MockStage(String id) { + this.id = id; + } + + @Override + public String getName() { + return "MockStage-" + id; + } + + @Override + protected StageResult doExecute(PhotoProcessContext context) { + return StageResult.success(); + } + } +} diff --git a/src/test/java/com/ycwl/basic/image/pipeline/core/PipelineTest.java b/src/test/java/com/ycwl/basic/image/pipeline/core/PipelineTest.java new file mode 100644 index 00000000..8e9a454c --- /dev/null +++ b/src/test/java/com/ycwl/basic/image/pipeline/core/PipelineTest.java @@ -0,0 +1,185 @@ +package com.ycwl.basic.image.pipeline.core; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Pipeline 单元测试 + */ +class PipelineTest { + + @Test + void testPipelineExecution_AllSuccess() { + Pipeline pipeline = new PipelineBuilder("TestPipeline") + .addStage(new MockStage("stage1", StageResult.success("Stage1完成"))) + .addStage(new MockStage("stage2", StageResult.success("Stage2完成"))) + .addStage(new MockStage("stage3", StageResult.success("Stage3完成"))) + .build(); + + PhotoProcessContext context = createTestContext(); + + boolean success = pipeline.execute(context); + + assertTrue(success); + } + + @Test + void testPipelineExecution_WithSkippedStage() { + Pipeline pipeline = new PipelineBuilder("TestPipeline") + .addStage(new MockStage("stage1", StageResult.success())) + .addStage(new ConditionalStage("stage2", false)) + .addStage(new MockStage("stage3", StageResult.success())) + .build(); + + PhotoProcessContext context = createTestContext(); + + boolean success = pipeline.execute(context); + + assertTrue(success); + } + + @Test + void testPipelineExecution_WithDegradedStage() { + Pipeline pipeline = new PipelineBuilder("TestPipeline") + .addStage(new MockStage("stage1", StageResult.success())) + .addStage(new MockStage("stage2", StageResult.degraded("降级执行"))) + .addStage(new MockStage("stage3", StageResult.success())) + .build(); + + PhotoProcessContext context = createTestContext(); + + boolean success = pipeline.execute(context); + + assertTrue(success); + } + + @Test + void testPipelineExecution_WithFailedStage() { + Pipeline pipeline = new PipelineBuilder("TestPipeline") + .addStage(new MockStage("stage1", StageResult.success())) + .addStage(new MockStage("stage2", StageResult.failed("执行失败"))) + .addStage(new MockStage("stage3", StageResult.success())) + .build(); + + PhotoProcessContext context = createTestContext(); + + boolean success = pipeline.execute(context); + + assertFalse(success); + } + + @Test + void testPipelineExecution_WithDynamicStageAddition() { + MockStage dynamicStage = new MockStage("dynamic", StageResult.success("动态Stage完成")); + + Pipeline pipeline = new PipelineBuilder("TestPipeline") + .addStage(new MockStage("stage1", StageResult.success())) + .addStage(new MockStage("stage2", StageResult.successWithNext("添加动态Stage", dynamicStage))) + .addStage(new MockStage("stage3", StageResult.success())) + .build(); + + PhotoProcessContext context = createTestContext(); + + boolean success = pipeline.execute(context); + + assertTrue(success); + assertEquals(4, pipeline.getStageCount()); // 原3个 + 动态添加1个 + } + + @Test + void testPipelineName() { + Pipeline pipeline = new PipelineBuilder("CustomPipeline") + .addStage(new MockStage("stage1", StageResult.success())) + .build(); + + assertEquals("CustomPipeline", pipeline.getName()); + } + + @Test + void testGetStageCount() { + Pipeline pipeline = new PipelineBuilder() + .addStage(new MockStage("stage1", StageResult.success())) + .addStage(new MockStage("stage2", StageResult.success())) + .addStage(new MockStage("stage3", StageResult.success())) + .build(); + + assertEquals(3, pipeline.getStageCount()); + } + + @Test + void testGetStageNames() { + Pipeline pipeline = new PipelineBuilder() + .addStage(new MockStage("stage1", StageResult.success())) + .addStage(new MockStage("stage2", StageResult.success())) + .build(); + + var names = pipeline.getStageNames(); + + assertEquals(2, names.size()); + assertTrue(names.contains("MockStage-stage1")); + assertTrue(names.contains("MockStage-stage2")); + } + + /** + * 创建测试用的 Context + */ + private PhotoProcessContext createTestContext() { + return PhotoProcessContext.builder() + .processId("test-pipeline") + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .build(); + } + + /** + * Mock Stage for testing + */ + private static class MockStage extends AbstractPipelineStage { + private final String id; + private final StageResult result; + + MockStage(String id, StageResult result) { + this.id = id; + this.result = result; + } + + @Override + public String getName() { + return "MockStage-" + id; + } + + @Override + protected StageResult doExecute(PhotoProcessContext context) { + return result; + } + } + + /** + * Conditional Stage for testing + */ + private static class ConditionalStage extends AbstractPipelineStage { + private final String id; + private final boolean shouldExecute; + + ConditionalStage(String id, boolean shouldExecute) { + this.id = id; + this.shouldExecute = shouldExecute; + } + + @Override + public String getName() { + return "ConditionalStage-" + id; + } + + @Override + protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) { + return shouldExecute; + } + + @Override + protected StageResult doExecute(PhotoProcessContext context) { + return StageResult.success(); + } + } +} diff --git a/src/test/java/com/ycwl/basic/image/pipeline/core/StageResultTest.java b/src/test/java/com/ycwl/basic/image/pipeline/core/StageResultTest.java new file mode 100644 index 00000000..d9ff1796 --- /dev/null +++ b/src/test/java/com/ycwl/basic/image/pipeline/core/StageResultTest.java @@ -0,0 +1,160 @@ +package com.ycwl.basic.image.pipeline.core; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * StageResult 单元测试 + */ +class StageResultTest { + + @Test + void testSuccess() { + StageResult result = StageResult.success(); + + assertEquals(StageResult.Status.SUCCESS, result.getStatus()); + assertTrue(result.isSuccess()); + assertFalse(result.isFailed()); + assertFalse(result.isSkipped()); + assertFalse(result.isDegraded()); + assertTrue(result.canContinue()); + } + + @Test + void testSuccessWithMessage() { + StageResult result = StageResult.success("处理完成"); + + assertTrue(result.isSuccess()); + assertEquals("处理完成", result.getMessage()); + } + + @Test + void testSuccessWithNextStages() { + MockStage nextStage1 = new MockStage("next1"); + MockStage nextStage2 = new MockStage("next2"); + + StageResult result = StageResult.successWithNext("添加后续Stage", nextStage1, nextStage2); + + assertTrue(result.isSuccess()); + assertEquals("添加后续Stage", result.getMessage()); + assertEquals(2, result.getNextStages().size()); + assertEquals("MockStage-next1", result.getNextStages().get(0).getName()); + assertEquals("MockStage-next2", result.getNextStages().get(1).getName()); + } + + @Test + void testSuccessWithNextStagesList() { + List> stages = List.of( + new MockStage("next1"), + new MockStage("next2") + ); + + StageResult result = StageResult.successWithNext("添加列表", stages); + + assertEquals(2, result.getNextStages().size()); + } + + @Test + void testSkipped() { + StageResult result = StageResult.skipped(); + + assertEquals(StageResult.Status.SKIPPED, result.getStatus()); + assertTrue(result.isSkipped()); + assertTrue(result.canContinue()); + assertEquals("条件不满足,跳过执行", result.getMessage()); + } + + @Test + void testSkippedWithReason() { + StageResult result = StageResult.skipped("图片类型不匹配"); + + assertTrue(result.isSkipped()); + assertEquals("图片类型不匹配", result.getMessage()); + } + + @Test + void testFailed() { + StageResult result = StageResult.failed("下载失败"); + + assertEquals(StageResult.Status.FAILED, result.getStatus()); + assertTrue(result.isFailed()); + assertFalse(result.canContinue()); + assertEquals("下载失败", result.getMessage()); + } + + @Test + void testFailedWithException() { + Exception exception = new RuntimeException("网络错误"); + StageResult result = StageResult.failed("下载失败", exception); + + assertTrue(result.isFailed()); + assertEquals("下载失败", result.getMessage()); + assertEquals(exception, result.getException()); + } + + @Test + void testDegraded() { + StageResult result = StageResult.degraded("使用备用方案"); + + assertEquals(StageResult.Status.DEGRADED, result.getStatus()); + assertTrue(result.isDegraded()); + assertTrue(result.canContinue()); + assertEquals("使用备用方案", result.getMessage()); + } + + @Test + void testCanContinue_Success() { + assertTrue(StageResult.success().canContinue()); + } + + @Test + void testCanContinue_Skipped() { + assertTrue(StageResult.skipped().canContinue()); + } + + @Test + void testCanContinue_Degraded() { + assertTrue(StageResult.degraded("降级").canContinue()); + } + + @Test + void testCanContinue_Failed() { + assertFalse(StageResult.failed("失败").canContinue()); + } + + @Test + void testNextStagesImmutable() { + MockStage stage1 = new MockStage("stage1"); + StageResult result = StageResult.successWithNext("test", stage1); + + List> nextStages = result.getNextStages(); + + assertThrows(UnsupportedOperationException.class, () -> { + nextStages.add(new MockStage("stage2")); + }); + } + + /** + * Mock Stage for testing + */ + private static class MockStage extends AbstractPipelineStage { + private final String id; + + MockStage(String id) { + this.id = id; + } + + @Override + public String getName() { + return "MockStage-" + id; + } + + @Override + protected StageResult doExecute(PhotoProcessContext context) { + return StageResult.success(); + } + } +} diff --git a/src/test/java/com/ycwl/basic/image/pipeline/stages/CleanupStageTest.java b/src/test/java/com/ycwl/basic/image/pipeline/stages/CleanupStageTest.java new file mode 100644 index 00000000..9a3a4247 --- /dev/null +++ b/src/test/java/com/ycwl/basic/image/pipeline/stages/CleanupStageTest.java @@ -0,0 +1,86 @@ +package com.ycwl.basic.image.pipeline.stages; + +import com.ycwl.basic.image.pipeline.core.PhotoProcessContext; +import com.ycwl.basic.image.pipeline.core.StageResult; +import com.ycwl.basic.image.pipeline.enums.ImageType; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * CleanupStage 单元测试 + */ +class CleanupStageTest { + + @Test + void testGetName() { + CleanupStage stage = new CleanupStage(); + + assertEquals("CleanupStage", stage.getName()); + } + + @Test + void testGetPriority() { + CleanupStage stage = new CleanupStage(); + + assertEquals(999, stage.getPriority()); + } + + @Test + void testShouldExecute_AlwaysTrue() { + CleanupStage stage = new CleanupStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .build(); + + assertTrue(stage.shouldExecute(context)); + } + + @Test + void testExecute_Success() { + CleanupStage stage = new CleanupStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .processId("test-cleanup") + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .build(); + + StageResult result = stage.execute(context); + + assertTrue(result.isSuccess()); + assertNotNull(result.getMessage()); + assertTrue(result.getMessage().contains("已清理")); + } + + @Test + void testExecute_ForAllImageTypes() { + CleanupStage stage = new CleanupStage(); + + // 测试普通照片 + PhotoProcessContext normalContext = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .imageType(ImageType.NORMAL_PHOTO) + .build(); + assertTrue(stage.execute(normalContext).isSuccess()); + + // 测试拼图 + PhotoProcessContext puzzleContext = PhotoProcessContext.builder() + .originalUrl("https://example.com/puzzle.png") + .scenicId(123L) + .imageType(ImageType.PUZZLE) + .build(); + assertTrue(stage.execute(puzzleContext).isSuccess()); + + // 测试手机上传 + PhotoProcessContext mobileContext = PhotoProcessContext.builder() + .originalUrl("https://example.com/mobile.jpg") + .scenicId(123L) + .imageType(ImageType.MOBILE_UPLOAD) + .build(); + assertTrue(stage.execute(mobileContext).isSuccess()); + } +} diff --git a/src/test/java/com/ycwl/basic/image/pipeline/stages/ConditionalRotateStageTest.java b/src/test/java/com/ycwl/basic/image/pipeline/stages/ConditionalRotateStageTest.java new file mode 100644 index 00000000..d62fe892 --- /dev/null +++ b/src/test/java/com/ycwl/basic/image/pipeline/stages/ConditionalRotateStageTest.java @@ -0,0 +1,84 @@ +package com.ycwl.basic.image.pipeline.stages; + +import com.ycwl.basic.image.pipeline.core.PhotoProcessContext; +import com.ycwl.basic.image.pipeline.core.StageResult; +import com.ycwl.basic.image.pipeline.enums.ImageType; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * ConditionalRotateStage 单元测试 + */ +class ConditionalRotateStageTest { + + @Test + void testGetName() { + ConditionalRotateStage stage = new ConditionalRotateStage(); + + assertEquals("ConditionalRotateStage", stage.getName()); + } + + @Test + void testShouldExecute_NormalPhotoAndPortrait() { + ConditionalRotateStage stage = new ConditionalRotateStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .imageType(ImageType.NORMAL_PHOTO) + .build(); + + context.setLandscape(false); // 竖图 + + assertTrue(stage.shouldExecute(context)); + } + + @Test + void testShouldExecute_NormalPhotoButLandscape_ShouldSkip() { + ConditionalRotateStage stage = new ConditionalRotateStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .imageType(ImageType.NORMAL_PHOTO) + .build(); + + context.setLandscape(true); // 横图 + + assertFalse(stage.shouldExecute(context)); + } + + @Test + void testShouldExecute_Puzzle_ShouldSkip() { + ConditionalRotateStage stage = new ConditionalRotateStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/puzzle.png") + .scenicId(123L) + .imageType(ImageType.PUZZLE) + .build(); + + context.setLandscape(false); + + assertFalse(stage.shouldExecute(context)); + } + + @Test + void testExecute_NoCurrentFile_ShouldFail() { + ConditionalRotateStage stage = new ConditionalRotateStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .imageType(ImageType.NORMAL_PHOTO) + .build(); + + context.setLandscape(false); + + StageResult result = stage.execute(context); + + assertTrue(result.isFailed()); + assertEquals("当前文件不存在", result.getMessage()); + } +} diff --git a/src/test/java/com/ycwl/basic/image/pipeline/stages/ImageEnhanceStageTest.java b/src/test/java/com/ycwl/basic/image/pipeline/stages/ImageEnhanceStageTest.java new file mode 100644 index 00000000..ac7e4e68 --- /dev/null +++ b/src/test/java/com/ycwl/basic/image/pipeline/stages/ImageEnhanceStageTest.java @@ -0,0 +1,420 @@ +package com.ycwl.basic.image.pipeline.stages; + +import com.ycwl.basic.image.enhancer.entity.BceEnhancerConfig; +import com.ycwl.basic.image.pipeline.core.PhotoProcessContext; +import com.ycwl.basic.image.pipeline.core.StageResult; +import com.ycwl.basic.image.pipeline.enums.ImageSource; +import com.ycwl.basic.image.pipeline.enums.ImageType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * ImageEnhanceStage 单元测试 + */ +class ImageEnhanceStageTest { + + @Test + void testGetName() { + ImageEnhanceStage stage = new ImageEnhanceStage(); + + assertEquals("ImageEnhanceStage", stage.getName()); + } + + @Test + void testDefaultConstructor_ShouldSetDefaultConfig() { + ImageEnhanceStage stage = new ImageEnhanceStage(); + + BceEnhancerConfig config = stage.getEnhancerConfig(); + + assertNotNull(config); + assertEquals("119554288", config.getAppId()); + assertEquals("OX6QoijgKio3eVtA0PiUVf7f", config.getApiKey()); + assertEquals("dYatXReVriPeiktTjUblhfubpcmYfuMk", config.getSecretKey()); + assertEquals(1.0f, config.getQps()); + } + + @Test + void testConstructorWithConfig() { + BceEnhancerConfig customConfig = new BceEnhancerConfig(); + customConfig.setAppId("custom-app-id"); + customConfig.setApiKey("custom-api-key"); + customConfig.setSecretKey("custom-secret-key"); + customConfig.setQps(2.0f); + + ImageEnhanceStage stage = new ImageEnhanceStage(customConfig); + + BceEnhancerConfig config = stage.getEnhancerConfig(); + assertEquals("custom-app-id", config.getAppId()); + assertEquals("custom-api-key", config.getApiKey()); + assertEquals("custom-secret-key", config.getSecretKey()); + assertEquals(2.0f, config.getQps()); + } + + @Test + void testShouldExecute_IPCSource() { + ImageEnhanceStage stage = new ImageEnhanceStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .source(ImageSource.IPC) + .build(); + + // 需要显式启用 image_enhance + context.loadStageConfig(null, java.util.Map.of("image_enhance", true)); + + assertTrue(stage.shouldExecute(context)); + } + + @Test + void testShouldExecute_PhoneSource_ShouldSkip() { + ImageEnhanceStage stage = new ImageEnhanceStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .source(ImageSource.PHONE) + .build(); + + context.loadStageConfig(null, java.util.Map.of("image_enhance", true)); + + assertFalse(stage.shouldExecute(context)); + } + + @Test + void testShouldExecute_CameraSource_ShouldSkip() { + ImageEnhanceStage stage = new ImageEnhanceStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .source(ImageSource.CAMERA) + .build(); + + context.loadStageConfig(null, java.util.Map.of("image_enhance", true)); + + assertFalse(stage.shouldExecute(context)); + } + + @Test + void testShouldExecute_UnknownSource_ShouldSkip() { + ImageEnhanceStage stage = new ImageEnhanceStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .source(ImageSource.UNKNOWN) + .build(); + + context.loadStageConfig(null, java.util.Map.of("image_enhance", true)); + + assertFalse(stage.shouldExecute(context)); + } + + @Test + void testShouldExecute_NullSource_ShouldSkip() { + ImageEnhanceStage stage = new ImageEnhanceStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .build(); + + context.loadStageConfig(null, java.util.Map.of("image_enhance", true)); + + assertFalse(stage.shouldExecute(context)); + } + + @Test + void testShouldExecute_DisabledByConfig_ShouldSkip() { + ImageEnhanceStage stage = new ImageEnhanceStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .source(ImageSource.IPC) + .build(); + + // 默认不启用,不加载配置 + assertFalse(stage.shouldExecute(context)); + } + + @Test + void testExecute_NoCurrentFile_ShouldFail() { + ImageEnhanceStage stage = new ImageEnhanceStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .source(ImageSource.IPC) + .build(); + + StageResult result = stage.execute(context); + + assertTrue(result.isFailed()); + assertEquals("当前文件不存在", result.getMessage()); + } + + @Test + void testExecute_InvalidConfig_ShouldSkip() { + // 创建无效配置(使用 TODO 占位符) + BceEnhancerConfig invalidConfig = new BceEnhancerConfig(); + invalidConfig.setAppId("TODO_YOUR_APP_ID"); + invalidConfig.setApiKey("TODO_YOUR_API_KEY"); + invalidConfig.setSecretKey("TODO_YOUR_SECRET_KEY"); + + ImageEnhanceStage stage = new ImageEnhanceStage(invalidConfig); + + PhotoProcessContext context = PhotoProcessContext.builder() + .processId("test-invalid-config") + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .source(ImageSource.IPC) + .build(); + + // 创建一个临时文件 + File tempFile = context.getTempFileManager().createTempFile("test", ".jpg"); + context.setOriginalFile(tempFile); + + StageResult result = stage.execute(context); + + assertTrue(result.isSkipped()); + assertEquals("配置不完整,跳过增强", result.getMessage()); + } + + @Test + void testExecute_NullConfig_ShouldSkip(@TempDir Path tempDir) throws Exception { + ImageEnhanceStage stage = new ImageEnhanceStage(null); + + PhotoProcessContext context = PhotoProcessContext.builder() + .processId("test-null-config") + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .source(ImageSource.IPC) + .build(); + + // 创建一个临时文件 + File testFile = tempDir.resolve("test.jpg").toFile(); + try (FileOutputStream fos = new FileOutputStream(testFile)) { + fos.write(new byte[]{1, 2, 3, 4, 5}); + } + context.setOriginalFile(testFile); + + StageResult result = stage.execute(context); + + assertTrue(result.isSkipped()); + assertEquals("配置不完整,跳过增强", result.getMessage()); + } + + @Test + void testExecute_NullAppId_ShouldSkip(@TempDir Path tempDir) throws Exception { + BceEnhancerConfig config = new BceEnhancerConfig(); + config.setAppId(null); + config.setApiKey("valid-api-key"); + config.setSecretKey("valid-secret-key"); + + ImageEnhanceStage stage = new ImageEnhanceStage(config); + + PhotoProcessContext context = PhotoProcessContext.builder() + .processId("test-null-appid") + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .source(ImageSource.IPC) + .build(); + + File testFile = tempDir.resolve("test.jpg").toFile(); + try (FileOutputStream fos = new FileOutputStream(testFile)) { + fos.write(new byte[]{1, 2, 3, 4, 5}); + } + context.setOriginalFile(testFile); + + StageResult result = stage.execute(context); + + assertTrue(result.isSkipped()); + assertEquals("配置不完整,跳过增强", result.getMessage()); + } + + @Test + void testExecute_TodoPlaceholderAppId_ShouldSkip(@TempDir Path tempDir) throws Exception { + BceEnhancerConfig config = new BceEnhancerConfig(); + config.setAppId("TODO_APP_ID"); + config.setApiKey("valid-api-key"); + config.setSecretKey("valid-secret-key"); + + ImageEnhanceStage stage = new ImageEnhanceStage(config); + + PhotoProcessContext context = PhotoProcessContext.builder() + .processId("test-todo-appid") + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .source(ImageSource.IPC) + .build(); + + File testFile = tempDir.resolve("test.jpg").toFile(); + try (FileOutputStream fos = new FileOutputStream(testFile)) { + fos.write(new byte[]{1, 2, 3, 4, 5}); + } + context.setOriginalFile(testFile); + + StageResult result = stage.execute(context); + + assertTrue(result.isSkipped()); + assertEquals("配置不完整,跳过增强", result.getMessage()); + } + + @Test + void testSetAndGetEnhancerConfig() { + ImageEnhanceStage stage = new ImageEnhanceStage(); + + BceEnhancerConfig newConfig = new BceEnhancerConfig(); + newConfig.setAppId("new-app-id"); + newConfig.setApiKey("new-api-key"); + newConfig.setSecretKey("new-secret-key"); + newConfig.setQps(3.0f); + + stage.setEnhancerConfig(newConfig); + + BceEnhancerConfig retrieved = stage.getEnhancerConfig(); + assertEquals("new-app-id", retrieved.getAppId()); + assertEquals("new-api-key", retrieved.getApiKey()); + assertEquals("new-secret-key", retrieved.getSecretKey()); + assertEquals(3.0f, retrieved.getQps()); + } + + @Test + void testExecute_AllImageTypes_WithIPCSource(@TempDir Path tempDir) throws Exception { + ImageEnhanceStage stage = new ImageEnhanceStage(); + + // 测试普通照片 + testImageType(stage, tempDir, ImageType.NORMAL_PHOTO); + + // 测试拼图 + testImageType(stage, tempDir, ImageType.PUZZLE); + + // 测试手机上传 + testImageType(stage, tempDir, ImageType.MOBILE_UPLOAD); + } + + private void testImageType(ImageEnhanceStage stage, Path tempDir, ImageType imageType) throws Exception { + PhotoProcessContext context = PhotoProcessContext.builder() + .processId("test-" + imageType.getCode()) + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .imageType(imageType) + .source(ImageSource.IPC) + .build(); + + File testFile = tempDir.resolve("test-" + imageType.getCode() + ".jpg").toFile(); + try (FileOutputStream fos = new FileOutputStream(testFile)) { + fos.write(new byte[]{1, 2, 3, 4, 5}); + } + context.setOriginalFile(testFile); + + context.loadStageConfig(null, java.util.Map.of("image_enhance", true)); + + // 应该可以执行(因为 source 是 IPC) + assertTrue(stage.shouldExecute(context)); + } + + @Test + void testConfigValidation_AllTodoFields() { + BceEnhancerConfig config = new BceEnhancerConfig(); + config.setAppId("TODO_APP_ID"); + config.setApiKey("TODO_API_KEY"); + config.setSecretKey("TODO_SECRET_KEY"); + + ImageEnhanceStage stage = new ImageEnhanceStage(config); + + PhotoProcessContext context = PhotoProcessContext.builder() + .processId("test-all-todo") + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .source(ImageSource.IPC) + .build(); + + File tempFile = context.getTempFileManager().createTempFile("test", ".jpg"); + context.setOriginalFile(tempFile); + + StageResult result = stage.execute(context); + + assertTrue(result.isSkipped()); + } + + @Test + void testConfigValidation_MixedTodoAndValid() { + BceEnhancerConfig config = new BceEnhancerConfig(); + config.setAppId("valid-app-id"); + config.setApiKey("TODO_API_KEY"); + config.setSecretKey("valid-secret-key"); + + ImageEnhanceStage stage = new ImageEnhanceStage(config); + + PhotoProcessContext context = PhotoProcessContext.builder() + .processId("test-mixed-todo") + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .source(ImageSource.IPC) + .build(); + + File tempFile = context.getTempFileManager().createTempFile("test", ".jpg"); + context.setOriginalFile(tempFile); + + StageResult result = stage.execute(context); + + assertTrue(result.isSkipped()); + } + + /** + * 测试说明: + * + * 由于 ImageEnhanceStage 依赖外部 API (BceImageEnhancer), + * 以下测试场景需要 Mock 或集成测试环境: + * + * 1. testExecute_Success_ValidConfig() + * - 模拟成功的图像增强调用 + * - 验证增强后文件大小变化 + * - 验证 context.updateProcessedFile() 被调用 + * + * 2. testExecute_ApiReturnsNull_ShouldDegrade() + * - 模拟 API 返回 null + * - 验证返回 StageResult.degraded() + * + * 3. testExecute_ApiReturnsEmptyFile_ShouldDegrade() + * - 模拟 API 返回空文件 + * - 验证返回 StageResult.degraded() + * + * 4. testExecute_ApiThrowsException_ShouldDegrade() + * - 模拟 API 抛出异常 + * - 验证返回 StageResult.degraded() 而不是 failed() + * + * 5. testExecute_SaveFileFails_ShouldDegrade() + * - 模拟文件保存失败 + * - 验证返回 StageResult.degraded() + * + * 这些测试需要使用 Mockito 或类似的 Mock 框架来模拟 BceImageEnhancer 的行为。 + * + * 示例(使用 Mockito): + * + * @Test + * void testExecute_Success_ValidConfig() { + * // 创建 Mock 的 BceImageEnhancer + * BceImageEnhancer mockEnhancer = mock(BceImageEnhancer.class); + * + * // 创建 Mock 的 MultipartFile + * MultipartFile mockEnhancedImage = mock(MultipartFile.class); + * when(mockEnhancedImage.isEmpty()).thenReturn(false); + * when(mockEnhancedImage.getBytes()).thenReturn(new byte[]{1, 2, 3, 4, 5, 6, 7, 8}); + * + * // 配置 Mock 行为 + * when(mockEnhancer.enhance(anyString())).thenReturn(mockEnhancedImage); + * + * // 测试逻辑... + * } + */ +} diff --git a/src/test/java/com/ycwl/basic/image/pipeline/stages/ImageOrientationStageTest.java b/src/test/java/com/ycwl/basic/image/pipeline/stages/ImageOrientationStageTest.java new file mode 100644 index 00000000..57e751e3 --- /dev/null +++ b/src/test/java/com/ycwl/basic/image/pipeline/stages/ImageOrientationStageTest.java @@ -0,0 +1,75 @@ +package com.ycwl.basic.image.pipeline.stages; + +import com.ycwl.basic.image.pipeline.core.PhotoProcessContext; +import com.ycwl.basic.image.pipeline.core.StageResult; +import com.ycwl.basic.image.pipeline.enums.ImageType; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * ImageOrientationStage 单元测试 + */ +class ImageOrientationStageTest { + + @Test + void testGetName() { + ImageOrientationStage stage = new ImageOrientationStage(); + + assertEquals("ImageOrientationStage", stage.getName()); + } + + @Test + void testShouldExecute_NormalPhoto() { + ImageOrientationStage stage = new ImageOrientationStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .imageType(ImageType.NORMAL_PHOTO) + .build(); + + assertTrue(stage.shouldExecute(context)); + } + + @Test + void testShouldExecute_Puzzle_ShouldSkip() { + ImageOrientationStage stage = new ImageOrientationStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/puzzle.png") + .scenicId(123L) + .imageType(ImageType.PUZZLE) + .build(); + + assertFalse(stage.shouldExecute(context)); + } + + @Test + void testShouldExecute_MobileUpload_ShouldSkip() { + ImageOrientationStage stage = new ImageOrientationStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/mobile.jpg") + .scenicId(123L) + .imageType(ImageType.MOBILE_UPLOAD) + .build(); + + assertFalse(stage.shouldExecute(context)); + } + + @Test + void testExecute_NoCurrentFile_ShouldFail() { + ImageOrientationStage stage = new ImageOrientationStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .build(); + + StageResult result = stage.execute(context); + + assertTrue(result.isFailed()); + assertEquals("当前文件不存在", result.getMessage()); + } +} diff --git a/src/test/java/com/ycwl/basic/image/pipeline/stages/ImageQualityCheckStageTest.java b/src/test/java/com/ycwl/basic/image/pipeline/stages/ImageQualityCheckStageTest.java new file mode 100644 index 00000000..1b2ce759 --- /dev/null +++ b/src/test/java/com/ycwl/basic/image/pipeline/stages/ImageQualityCheckStageTest.java @@ -0,0 +1,79 @@ +package com.ycwl.basic.image.pipeline.stages; + +import com.ycwl.basic.image.pipeline.core.PhotoProcessContext; +import com.ycwl.basic.image.pipeline.core.StageResult; +import com.ycwl.basic.image.pipeline.enums.ImageType; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * ImageQualityCheckStage 单元测试 + */ +class ImageQualityCheckStageTest { + + @Test + void testGetName() { + ImageQualityCheckStage stage = new ImageQualityCheckStage(); + + assertEquals("ImageQualityCheckStage", stage.getName()); + } + + @Test + void testShouldExecute_NormalPhoto() { + ImageQualityCheckStage stage = new ImageQualityCheckStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .imageType(ImageType.NORMAL_PHOTO) + .build(); + + // ImageQualityCheckStage 默认不启用,需要显式启用 + context.loadStageConfig(null, java.util.Map.of("quality_check", true)); + + assertTrue(stage.shouldExecute(context)); + } + + @Test + void testShouldExecute_Puzzle_ShouldSkip() { + ImageQualityCheckStage stage = new ImageQualityCheckStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/puzzle.png") + .scenicId(123L) + .imageType(ImageType.PUZZLE) + .build(); + + assertFalse(stage.shouldExecute(context)); + } + + @Test + void testShouldExecute_MobileUpload_ShouldSkip() { + ImageQualityCheckStage stage = new ImageQualityCheckStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/mobile.jpg") + .scenicId(123L) + .imageType(ImageType.MOBILE_UPLOAD) + .build(); + + assertFalse(stage.shouldExecute(context)); + } + + @Test + void testExecute_NoCurrentFile_ShouldFail() { + ImageQualityCheckStage stage = new ImageQualityCheckStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .imageType(ImageType.NORMAL_PHOTO) + .build(); + + StageResult result = stage.execute(context); + + assertTrue(result.isFailed()); + assertEquals("当前文件不存在", result.getMessage()); + } +} diff --git a/src/test/java/com/ycwl/basic/image/pipeline/stages/PuzzleBorderStageTest.java b/src/test/java/com/ycwl/basic/image/pipeline/stages/PuzzleBorderStageTest.java new file mode 100644 index 00000000..58369e6d --- /dev/null +++ b/src/test/java/com/ycwl/basic/image/pipeline/stages/PuzzleBorderStageTest.java @@ -0,0 +1,76 @@ +package com.ycwl.basic.image.pipeline.stages; + +import com.ycwl.basic.image.pipeline.core.PhotoProcessContext; +import com.ycwl.basic.image.pipeline.core.StageResult; +import com.ycwl.basic.image.pipeline.enums.ImageType; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * PuzzleBorderStage 单元测试 + */ +class PuzzleBorderStageTest { + + @Test + void testGetName() { + PuzzleBorderStage stage = new PuzzleBorderStage(); + + assertEquals("PuzzleBorderStage", stage.getName()); + } + + @Test + void testShouldExecute_Puzzle() { + PuzzleBorderStage stage = new PuzzleBorderStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/puzzle.png") + .scenicId(123L) + .imageType(ImageType.PUZZLE) + .build(); + + assertTrue(stage.shouldExecute(context)); + } + + @Test + void testShouldExecute_NormalPhoto_ShouldSkip() { + PuzzleBorderStage stage = new PuzzleBorderStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .imageType(ImageType.NORMAL_PHOTO) + .build(); + + assertFalse(stage.shouldExecute(context)); + } + + @Test + void testShouldExecute_MobileUpload_ShouldSkip() { + PuzzleBorderStage stage = new PuzzleBorderStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/mobile.jpg") + .scenicId(123L) + .imageType(ImageType.MOBILE_UPLOAD) + .build(); + + assertFalse(stage.shouldExecute(context)); + } + + @Test + void testExecute_NoCurrentFile_ShouldFail() { + PuzzleBorderStage stage = new PuzzleBorderStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/puzzle.png") + .scenicId(123L) + .imageType(ImageType.PUZZLE) + .build(); + + StageResult result = stage.execute(context); + + assertTrue(result.isFailed()); + assertEquals("当前文件不存在", result.getMessage()); + } +} diff --git a/src/test/java/com/ycwl/basic/image/pipeline/stages/RestoreOrientationStageTest.java b/src/test/java/com/ycwl/basic/image/pipeline/stages/RestoreOrientationStageTest.java new file mode 100644 index 00000000..98d68b1b --- /dev/null +++ b/src/test/java/com/ycwl/basic/image/pipeline/stages/RestoreOrientationStageTest.java @@ -0,0 +1,84 @@ +package com.ycwl.basic.image.pipeline.stages; + +import com.ycwl.basic.image.pipeline.core.PhotoProcessContext; +import com.ycwl.basic.image.pipeline.core.StageResult; +import com.ycwl.basic.image.pipeline.enums.ImageType; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * RestoreOrientationStage 单元测试 + */ +class RestoreOrientationStageTest { + + @Test + void testGetName() { + RestoreOrientationStage stage = new RestoreOrientationStage(); + + assertEquals("RestoreOrientationStage", stage.getName()); + } + + @Test + void testShouldExecute_NormalPhotoAndNeedRotation() { + RestoreOrientationStage stage = new RestoreOrientationStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .imageType(ImageType.NORMAL_PHOTO) + .build(); + + context.setNeedRotation(true); + + assertTrue(stage.shouldExecute(context)); + } + + @Test + void testShouldExecute_NormalPhotoButNoNeedRotation_ShouldSkip() { + RestoreOrientationStage stage = new RestoreOrientationStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .imageType(ImageType.NORMAL_PHOTO) + .build(); + + context.setNeedRotation(false); + + assertFalse(stage.shouldExecute(context)); + } + + @Test + void testShouldExecute_Puzzle_ShouldSkip() { + RestoreOrientationStage stage = new RestoreOrientationStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/puzzle.png") + .scenicId(123L) + .imageType(ImageType.PUZZLE) + .build(); + + context.setNeedRotation(true); + + assertFalse(stage.shouldExecute(context)); + } + + @Test + void testExecute_NoCurrentFile_ShouldFail() { + RestoreOrientationStage stage = new RestoreOrientationStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .imageType(ImageType.NORMAL_PHOTO) + .build(); + + context.setNeedRotation(true); + + StageResult result = stage.execute(context); + + assertTrue(result.isFailed()); + assertEquals("当前文件不存在", result.getMessage()); + } +} diff --git a/src/test/java/com/ycwl/basic/image/pipeline/stages/WatermarkStageTest.java b/src/test/java/com/ycwl/basic/image/pipeline/stages/WatermarkStageTest.java new file mode 100644 index 00000000..b5f73476 --- /dev/null +++ b/src/test/java/com/ycwl/basic/image/pipeline/stages/WatermarkStageTest.java @@ -0,0 +1,76 @@ +package com.ycwl.basic.image.pipeline.stages; + +import com.ycwl.basic.image.pipeline.core.PhotoProcessContext; +import com.ycwl.basic.image.pipeline.core.StageResult; +import com.ycwl.basic.image.pipeline.enums.ImageType; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * WatermarkStage 单元测试 + */ +class WatermarkStageTest { + + @Test + void testGetName() { + WatermarkStage stage = new WatermarkStage(); + + assertEquals("WatermarkStage", stage.getName()); + } + + @Test + void testShouldExecute_NormalPhoto() { + WatermarkStage stage = new WatermarkStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .imageType(ImageType.NORMAL_PHOTO) + .build(); + + assertTrue(stage.shouldExecute(context)); + } + + @Test + void testShouldExecute_Puzzle_ShouldSkip() { + WatermarkStage stage = new WatermarkStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/puzzle.png") + .scenicId(123L) + .imageType(ImageType.PUZZLE) + .build(); + + assertFalse(stage.shouldExecute(context)); + } + + @Test + void testShouldExecute_MobileUpload_ShouldSkip() { + WatermarkStage stage = new WatermarkStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/mobile.jpg") + .scenicId(123L) + .imageType(ImageType.MOBILE_UPLOAD) + .build(); + + assertFalse(stage.shouldExecute(context)); + } + + @Test + void testExecute_NoWatermarkType_ShouldSkip() { + WatermarkStage stage = new WatermarkStage(); + + PhotoProcessContext context = PhotoProcessContext.builder() + .originalUrl("https://example.com/test.jpg") + .scenicId(123L) + .imageType(ImageType.NORMAL_PHOTO) + .build(); + + StageResult result = stage.execute(context); + + assertTrue(result.isSkipped()); + assertEquals("未配置水印", result.getMessage()); + } +}