// // TravelAlbumModelsTests.swift // suixinkanTests // import XCTest @testable import suixinkan /// 旅拍相册模型与展示工具测试。 final class TravelAlbumModelsTests: XCTestCase { 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 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 testTravelAlbumMaterialBadgePriorityAndFallbacks() { XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 0).badgePresentation) XCTAssertEqual( TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 0, aiRetouchStatusName: "已上传") .badgePresentation, TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购") ) XCTAssertEqual( TravelAlbumMaterial(aiRetouchStatus: 1, aiRetouchStatusName: " 排队中 ").badgePresentation, TravelAlbumMaterialBadgePresentation(kind: .pending, text: "排队中") ) XCTAssertEqual( TravelAlbumMaterial(aiRetouchStatus: 2, aiRetouchStatusName: " ").badgePresentation, TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中") ) XCTAssertEqual( TravelAlbumMaterial(aiRetouchStatus: 3).badgePresentation, TravelAlbumMaterialBadgePresentation(kind: .retouched, text: "AI已修") ) XCTAssertEqual( TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面").badgePresentation, TravelAlbumMaterialBadgePresentation(kind: .cover, text: "AI封面") ) XCTAssertEqual( TravelAlbumMaterial(aiRetouchStatus: 4).badgePresentation, TravelAlbumMaterialBadgePresentation(kind: .failed, text: "失败") ) XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 99, aiRetouchStatusName: "未知").badgePresentation) } 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) } }