// // TravelAlbumModelsTests.swift // suixinkanTests // import XCTest @testable import suixinkan /// 旅拍相册模型与展示工具测试。 final class TravelAlbumModelsTests: XCTestCase { func testBeforeAfterComparisonMotionMatchesShipSwiftInteractionRange() { XCTAssertEqual(BeforeAfterComparisonMotion.automaticFraction(elapsed: 0), 0.5, accuracy: 0.001) XCTAssertEqual( BeforeAfterComparisonMotion.automaticFraction( elapsed: .pi / (2 * BeforeAfterComparisonMotion.automaticSpeed) ), 0.8, accuracy: 0.001 ) XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(0), 0.05) XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(1), 0.95) XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(0.42), 0.42) } func testBeforeAfterComparisonLayoutFitsVariableRatiosWithoutCropping() { let landscape = BeforeAfterComparisonLayout.fittedSize( aspectRatio: 16.0 / 9.0, maximumSize: CGSize(width: 358, height: 580) ) let portrait = BeforeAfterComparisonLayout.fittedSize( aspectRatio: 2.0 / 3.0, maximumSize: CGSize(width: 358, height: 580) ) XCTAssertEqual(landscape.width, 358, accuracy: 0.001) XCTAssertEqual(landscape.height, 201.375, accuracy: 0.001) XCTAssertEqual(portrait.width, 358, accuracy: 0.001) XCTAssertEqual(portrait.height, 537, accuracy: 0.001) XCTAssertEqual( BeforeAfterComparisonLayout.aspectRatio(for: CGSize(width: 4000, height: 3000)), 4.0 / 3.0 ) } func testPreviewProjectMapsMaterialToOriginalAsset() { let material = TravelAlbumMaterial( id: 8, fileName: "IMG_0008.JPG", fileUrl: "https://cdn.example.com/original.jpg", fileSize: 4096, coverUrl: "https://cdn.example.com/cover.jpg" ) let project = TravelAlbumPreviewProject(material: material) XCTAssertEqual(project.originalMaterialId, 8) XCTAssertEqual(project.orderedAssets.map(\.kind), [.original]) XCTAssertEqual(project.orderedAssets.first?.displayURL, material.fileUrl) XCTAssertEqual(project.orderedAssets.first?.previewURL, material.coverUrl) XCTAssertFalse(project.hasVariants) } func testPreviewProjectMapsNonemptyAIResultURLsInCanonicalOrder() { let material = TravelAlbumMaterial( id: 8, fileName: "IMG_0008.JPG", fileUrl: "https://cdn.example.com/original.jpg", fileSize: 4096, coverUrl: "https://cdn.example.com/cover.jpg", aiRetouchBatchId: 55, aiRefinedURL: " https://cdn.example.com/refined.jpg ", aiAtmosphereURL: "https://cdn.example.com/atmosphere.jpg" ) let project = TravelAlbumPreviewProject(material: material) XCTAssertEqual(project.orderedAssets.map(\.kind), [.original, .retouched, .atmosphere]) XCTAssertEqual( project.asset(for: .retouched)?.displayURL, "https://cdn.example.com/refined.jpg" ) XCTAssertEqual( project.asset(for: .atmosphere)?.previewURL, "https://cdn.example.com/atmosphere.jpg" ) XCTAssertNil(project.asset(for: .cover)) XCTAssertTrue(project.hasVariants) XCTAssertEqual(project.aiRetouchBatchId, 55) } func testPreviewProjectIgnoresBlankAIResultURLs() { let material = TravelAlbumMaterial( id: 8, fileUrl: "https://cdn.example.com/original.jpg", aiRefinedURL: " \n ", aiAtmosphereURL: "\t" ) XCTAssertEqual( TravelAlbumPreviewProject(material: material).orderedAssets.map(\.kind), [.original] ) } func testPreviewProjectBuildsComparisonOnlyForValidAIResults() { let project = makePreviewProject(id: 8, kinds: [.original, .retouched, .atmosphere, .cover]) XCTAssertEqual( project.comparisonContent(for: .retouched), BeforeAfterComparisonContent( beforeURL: "https://cdn.example.com/8-0.jpg", afterURL: "https://cdn.example.com/8-1.jpg" ) ) XCTAssertNotNil(project.comparisonContent(for: .atmosphere)) XCTAssertNil(project.comparisonContent(for: .original)) XCTAssertNil(project.comparisonContent(for: .cover)) let missingResultURL = TravelAlbumPreviewProject( originalMaterialId: 9, assets: [ TravelAlbumPreviewAsset( id: "original-9", kind: .original, fileURL: "https://cdn.example.com/original.jpg", coverURL: "", fileName: "原图.jpg", fileSize: 1 ), TravelAlbumPreviewAsset( id: "retouched-9", kind: .retouched, fileURL: " ", coverURL: "", fileName: "精修.jpg", fileSize: 1 ), ] ) XCTAssertNil(missingResultURL.comparisonContent(for: .retouched)) } func testTemplateComparisonContentRequiresBothURLs() { let valid = TravelAlbumAIRetouchTemplate( id: 1, name: "清透", previewURL: "https://cdn.example.com/preview.jpg", beforeURL: " https://cdn.example.com/before.jpg ", afterURL: "https://cdn.example.com/after.jpg" ) let missingAfter = TravelAlbumAIRetouchTemplate( id: 2, name: "暖阳", previewURL: "https://cdn.example.com/preview.jpg", beforeURL: "https://cdn.example.com/before.jpg" ) XCTAssertEqual(valid.comparisonContent?.beforeURL, "https://cdn.example.com/before.jpg") XCTAssertEqual(valid.comparisonContent?.afterLabel, "效果图") XCTAssertNil(missingAfter.comparisonContent) } func testTemplateDecodingDefaultsMissingComparisonURLsToEmptyStrings() throws { let data = Data( #"{"id":3,"name":"旧模板","preview_url":"https://cdn.example.com/preview.jpg"}"#.utf8 ) let template = try JSONDecoder().decode(TravelAlbumAIRetouchTemplate.self, from: data) XCTAssertEqual(template.previewURL, "https://cdn.example.com/preview.jpg") XCTAssertEqual(template.beforeURL, "") XCTAssertEqual(template.afterURL, "") XCTAssertNil(template.comparisonContent) } func testPreviewAssetFallsBackToAvailableURLForBothQualityLevels() { let missingOriginal = TravelAlbumPreviewAsset( id: "cover-only", kind: .cover, fileURL: " ", coverURL: "https://cdn.example.com/cover.jpg", fileName: "cover.jpg", fileSize: 100 ) let missingCover = TravelAlbumPreviewAsset( id: "original-only", kind: .original, fileURL: "https://cdn.example.com/original.jpg", coverURL: "\n", fileName: "original.jpg", fileSize: 200 ) XCTAssertEqual(missingOriginal.displayURL, missingOriginal.coverURL) XCTAssertEqual(missingOriginal.previewURL, missingOriginal.coverURL) XCTAssertEqual(missingCover.displayURL, missingCover.fileURL) XCTAssertEqual(missingCover.previewURL, missingCover.fileURL) } func testPreviewProjectKeepsCanonicalExistingVariantOrder() { let project = makePreviewProject(kinds: [.cover, .original, .atmosphere]) XCTAssertEqual(project.orderedAssets.map(\.kind), [.original, .atmosphere, .cover]) XCTAssertTrue(project.hasVariants) } func testPreviewNavigatorBuildsNodesForBothModes() { let projects = [ makePreviewProject(id: 1, kinds: [.original, .retouched]), makePreviewProject(id: 2, kinds: [.original, .cover]), ] XCTAssertEqual( TravelAlbumPreviewNavigator.nodes(projects: projects, mode: .projectsOnly), [ TravelAlbumPreviewNode(projectIndex: 0, kind: .original), TravelAlbumPreviewNode(projectIndex: 1, kind: .original), ] ) XCTAssertEqual( TravelAlbumPreviewNavigator.nodes(projects: projects, mode: .includeVariants).map(\.kind), [.original, .retouched, .original, .cover] ) } func testPreviewNavigatorBackwardFromOriginalTargetsPreviousOriginal() { let projects = [ makePreviewProject(id: 1, kinds: [.original, .retouched, .cover]), makePreviewProject(id: 2, kinds: [.original, .atmosphere]), ] let nodes = TravelAlbumPreviewNavigator.nodes(projects: projects, mode: .includeVariants) let secondOriginal = nodes.firstIndex { $0.projectIndex == 1 && $0.kind == .original }! let target = TravelAlbumPreviewNavigator.backwardTargetIndex(nodes: nodes, currentIndex: secondOriginal) XCTAssertEqual(nodes[target], TravelAlbumPreviewNode(projectIndex: 0, kind: .original)) } func testPreviewDeletionPrefersNextThenFallsBackToPreviousAndClosesWhenEmpty() { XCTAssertEqual( TravelAlbumPreviewNavigator.projectIndexAfterDeletion( deletedProjectIndex: 0, remainingProjectCount: 2 ), 0 ) XCTAssertEqual( TravelAlbumPreviewNavigator.projectIndexAfterDeletion( deletedProjectIndex: 1, remainingProjectCount: 2 ), 1 ) XCTAssertEqual( TravelAlbumPreviewNavigator.projectIndexAfterDeletion( deletedProjectIndex: 2, remainingProjectCount: 2 ), 1 ) XCTAssertNil( TravelAlbumPreviewNavigator.projectIndexAfterDeletion( deletedProjectIndex: 0, remainingProjectCount: 0 ) ) } func testPreviewProjectMapsCurrentTabToMinimalAIRetouchWorkflow() { let originalOnly = makePreviewProject(id: 9, kinds: [.original]) let variants = TravelAlbumPreviewProject( originalMaterialId: 10, aiRetouchBatchId: 88, assets: makePreviewProject(id: 10, kinds: [.original, .retouched, .atmosphere]).assets ) XCTAssertEqual( originalOnly.aiRetouchWorkflow(albumId: 7, selectedKind: .original), .initial(albumId: 7, materialIds: [9]) ) XCTAssertEqual( variants.aiRetouchWorkflow(albumId: 7, selectedKind: .original), .reretouch(materialId: 10, batchId: 88, type: .all) ) XCTAssertEqual( variants.aiRetouchWorkflow(albumId: 7, selectedKind: .retouched), .reretouch(materialId: 10, batchId: 88, type: .refined) ) XCTAssertEqual( variants.aiRetouchWorkflow(albumId: 7, selectedKind: .atmosphere), .reretouch(materialId: 10, batchId: 88, type: .atmosphere) ) XCTAssertNil(variants.aiRetouchWorkflow(albumId: 7, selectedKind: .cover)) } func testPlaceholderPreviewDeleteReturnsUnavailableWithoutMutation() async { let handler = PlaceholderTravelAlbumPreviewActionHandler() let deleteResult = await handler.deleteProject(originalMaterialId: 9) XCTAssertEqual( deleteResult, .unavailable("项目删除接口待接入") ) } func testTravelAlbumDecodesSnakeCaseFields() throws { let json = """ { "id": 7, "store_user_id": 2, "name": "旅拍相册", "type": 1, "order_number": "NO001", "material_num": 3, "material_price": 1200, "material_package_price": 5000, "photo_price": 0, "cover_url": "https://cdn/cover.jpg", "user_id": 9, "status": 1, "created_at": "2026-07-07 10:00:00", "updated_at": "2026-07-07 10:10:00", "user": {"id": 4, "phone": "13800138000"} } """.data(using: .utf8)! let album = try JSONDecoder().decode(TravelAlbum.self, from: json) XCTAssertEqual(album.id, 7) XCTAssertEqual(album.storeUserId, 2) XCTAssertEqual(album.orderNumber, "NO001") XCTAssertEqual(album.displayPhone, "13800138000") } func testTravelAlbumMaterialDecodesSnakeCaseFields() throws { let json = """ { "id": 11, "user_equity_travel_id": 7, "status": 1, "order_number": "", "user_id": 9, "file_name": "IMG_0001.JPG", "file_type": 2, "file_url": "https://cdn/a.jpg", "file_size": 2048, "cover_url": "", "is_purchased": false, "ai_retouch_status": 3, "ai_retouch_status_name": "AI已修", "ai_retouch_batch_id": 66, "ai_refined_url": "https://cdn/refined.jpg", "ai_atmosphere_url": "https://cdn/atmosphere.jpg", "created_at": "", "updated_at": "" } """.data(using: .utf8)! let material = try JSONDecoder().decode(TravelAlbumMaterial.self, from: json) XCTAssertEqual(material.userEquityTravelId, 7) XCTAssertEqual(material.fileName, "IMG_0001.JPG") XCTAssertFalse(material.isPurchased) XCTAssertEqual(material.aiRetouchStatus, 3) XCTAssertEqual(material.aiRetouchStatusName, "AI已修") XCTAssertEqual(material.aiRetouchBatchId, 66) XCTAssertEqual(material.aiRefinedURL, "https://cdn/refined.jpg") XCTAssertEqual(material.aiAtmosphereURL, "https://cdn/atmosphere.jpg") } func testTravelAlbumMaterialDefaultsInvalidAIFields() throws { let json = """ { "id": 11, "user_equity_travel_id": 7, "status": 1, "order_number": "", "user_id": 9, "file_name": "IMG_0001.JPG", "file_type": 2, "file_url": "https://cdn/a.jpg", "file_size": 2048, "cover_url": "", "is_purchased": false, "ai_retouch_status": "invalid", "ai_retouch_status_name": null, "ai_retouch_batch_id": "invalid", "ai_refined_url": 123, "created_at": "", "updated_at": "" } """.data(using: .utf8)! let material = try JSONDecoder().decode(TravelAlbumMaterial.self, from: json) XCTAssertEqual(material.aiRetouchStatus, 0) XCTAssertEqual(material.aiRetouchStatusName, "") XCTAssertEqual(material.aiRetouchBatchId, 0) XCTAssertEqual(material.aiRefinedURL, "") XCTAssertEqual(material.aiAtmosphereURL, "") } func testTravelAlbumMaterialPurchaseAndAIRetouchBadgesAreIndependent() { XCTAssertNil(TravelAlbumMaterial().purchaseBadgePresentation) XCTAssertEqual( TravelAlbumMaterial(isPurchased: true).purchaseBadgePresentation, TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购") ) XCTAssertNil(TravelAlbumMaterial(isPurchased: true).aiRetouchBadgePresentation) let purchasedAndProcessing = TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 2) XCTAssertEqual( purchasedAndProcessing.purchaseBadgePresentation, TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购") ) XCTAssertEqual( purchasedAndProcessing.aiRetouchBadgePresentation, TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中") ) XCTAssertEqual( TravelAlbumMaterial(aiRetouchStatus: 1, aiRetouchStatusName: " 排队中 ") .aiRetouchBadgePresentation, TravelAlbumMaterialBadgePresentation(kind: .pending, text: "排队中") ) XCTAssertEqual( TravelAlbumMaterial(aiRetouchStatus: 2, aiRetouchStatusName: " ").aiRetouchBadgePresentation, TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中") ) XCTAssertEqual( TravelAlbumMaterial(aiRetouchStatus: 3).aiRetouchBadgePresentation, TravelAlbumMaterialBadgePresentation(kind: .retouched, text: "AI已修") ) XCTAssertEqual( TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面") .aiRetouchBadgePresentation, TravelAlbumMaterialBadgePresentation(kind: .cover, text: "AI封面") ) XCTAssertEqual( TravelAlbumMaterial(aiRetouchStatus: 4).aiRetouchBadgePresentation, TravelAlbumMaterialBadgePresentation(kind: .failed, text: "失败") ) XCTAssertNil( TravelAlbumMaterial(aiRetouchStatus: 99, aiRetouchStatusName: "未知") .aiRetouchBadgePresentation ) } func testDisplayFormatters() { XCTAssertEqual(TravelAlbumDisplayFormatter.maskPhone("13800138000"), "138****8000") XCTAssertEqual(TravelAlbumDisplayFormatter.maskPhone(""), "--") XCTAssertEqual(TravelAlbumDisplayFormatter.fileSizeText(2048), "2.0KB") XCTAssertEqual(TravelAlbumDisplayFormatter.fileSizeText(2 * 1024 * 1024), "2.0MB") } private func makePreviewProject( id: Int = 1, kinds: [TravelAlbumPreviewAssetKind] ) -> TravelAlbumPreviewProject { TravelAlbumPreviewProject( originalMaterialId: id, assets: kinds.map { kind in TravelAlbumPreviewAsset( id: "\(id)-\(kind.rawValue)", kind: kind, fileURL: "https://cdn.example.com/\(id)-\(kind.rawValue).jpg", coverURL: "", fileName: "\(kind.title).jpg", fileSize: 1024 ) } ) } @MainActor func testBuildTodayAlbumNameUsesExistingSameDayCount() { let date = Calendar(identifier: .gregorian).date(from: DateComponents(year: 2026, month: 7, day: 7))! let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 }, dateProvider: { date }) viewModel.onStateChange = {} let api = TravelAlbumMockAPI() api.listResponse = TravelAlbumListResponse( total: 2, list: [ TravelAlbum(id: 1, createdAt: "2026-07-07 09:00:00"), TravelAlbum(id: 2, createdAt: "2026-07-06 09:00:00"), ] ) let exp = expectation(description: "load") Task { await viewModel.loadAlbums(api: api) XCTAssertEqual(viewModel.buildTodayAlbumName(), "2026-07-07-002") exp.fulfill() } wait(for: [exp], timeout: 1) } }