Files
suixinkan_uikit/suixinkanTests/TravelAlbumModelsTests.swift

494 lines
19 KiB
Swift

//
// 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 testPreviewProjectUsesAllReretouchWorkflowForEveryVisibleVariantTab() {
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: .all)
)
XCTAssertEqual(
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .atmosphere),
.reretouch(materialId: 10, batchId: 88, type: .all)
)
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)
}
}