新增ai修图

This commit is contained in:
XHYourName
2026-07-31 16:48:28 +08:00
parent 7ccbc5568d
commit 6129a1632f
42 changed files with 3696 additions and 54 deletions

View File

@ -4,6 +4,7 @@
//
import XCTest
import UIKit
@testable import suixinkan
@MainActor
@ -75,6 +76,208 @@ final class TravelAlbumEntryViewModelTests: XCTestCase {
@MainActor
/// ViewModel
final class TravelAlbumDetailViewModelTests: XCTestCase {
func testAIEditResultDisablesDuringProcessingAndOverwritesPreviousResult() {
var state = TravelAlbumAIEditResultState()
XCTAssertTrue(state.canStartEditing)
XCTAssertFalse(state.hasEditedResult)
XCTAssertEqual(state.thumbnailStatus, .none)
state.startProcessing()
XCTAssertFalse(state.canStartEditing)
XCTAssertEqual(state.thumbnailStatus, .processing)
state.complete(presetID: "portrait")
XCTAssertTrue(state.canStartEditing)
XCTAssertEqual(state.appliedPresetID, "portrait")
XCTAssertEqual(state.displayMode, .edited)
XCTAssertEqual(state.thumbnailStatus, .edited)
state.selectDisplayMode(.original)
XCTAssertEqual(state.displayMode, .original)
state.startProcessing()
XCTAssertEqual(state.appliedPresetID, "portrait")
state.complete(presetID: "mist")
XCTAssertEqual(state.appliedPresetID, "mist")
XCTAssertEqual(state.displayMode, .edited)
}
func testAIEditFailureShowsFailureStatusAndKeepsPreviousResult() {
var state = TravelAlbumAIEditResultState()
state.complete(presetID: "portrait")
state.startProcessing()
state.failProcessing()
XCTAssertTrue(state.canStartEditing)
XCTAssertTrue(state.hasEditedResult)
XCTAssertEqual(state.appliedPresetID, "portrait")
XCTAssertEqual(state.thumbnailStatus, .failed)
state.startProcessing()
XCTAssertEqual(state.thumbnailStatus, .processing)
}
func testRestoreOriginalClearsEditedThumbnailStatus() {
var state = TravelAlbumAIEditResultState()
state.complete(presetID: "portrait")
state.startProcessing()
state.restoreOriginal()
XCTAssertTrue(state.canStartEditing)
XCTAssertFalse(state.hasEditedResult)
XCTAssertEqual(state.displayMode, .original)
XCTAssertEqual(state.thumbnailStatus, .none)
}
func testCoverResultUsesDistinctThumbnailStatusAndCanBeOverwritten() {
var state = TravelAlbumAIEditResultState()
state.complete(presetID: "portrait", coverTemplateID: "travel_memoir")
XCTAssertTrue(state.isCover)
XCTAssertEqual(state.appliedCoverTemplateID, "travel_memoir")
XCTAssertEqual(state.displayMode, .cover)
XCTAssertEqual(state.thumbnailStatus, .cover)
state.complete(presetID: "mist")
XCTAssertFalse(state.isCover)
XCTAssertNil(state.appliedCoverTemplateID)
XCTAssertEqual(state.thumbnailStatus, .edited)
}
func testAtmosphereResultCanBeSelectedAndIsClearedByNextSingleResult() {
var state = TravelAlbumAIEditResultState()
state.complete(presetID: "portrait", atmosphereOptionID: "atmosphere")
XCTAssertTrue(state.hasAtmosphereResult)
state.selectDisplayMode(.atmosphere)
XCTAssertEqual(state.displayMode, .atmosphere)
state.complete(presetID: "mist")
XCTAssertFalse(state.hasAtmosphereResult)
XCTAssertNil(state.appliedAtmosphereOptionID)
state.selectDisplayMode(.atmosphere)
XCTAssertEqual(state.displayMode, .edited)
}
func testAIEditResultStoreSharesAndOverwritesLatestImage() {
let materialID = 990_071
let store = TravelAlbumAIEditResultStore.shared
store.remove(materialID: materialID)
store.startProcessing(materialIDs: [materialID])
XCTAssertFalse(store.state(for: materialID).canStartEditing)
let firstImage = UIImage()
store.complete(materialID: materialID, presetID: "portrait", editedImage: firstImage)
XCTAssertTrue(store.editedImage(for: materialID) === firstImage)
let replacementImage = UIImage()
let atmosphereImage = UIImage()
let coverImage = UIImage()
store.startProcessing(materialIDs: [materialID])
store.complete(
materialID: materialID,
presetID: "mist",
atmosphereOptionID: "atmosphere",
coverTemplateID: "travel_memoir",
editedImage: replacementImage,
atmosphereImage: atmosphereImage,
coverImage: coverImage
)
XCTAssertEqual(store.state(for: materialID).appliedPresetID, "mist")
XCTAssertTrue(store.editedImage(for: materialID) === replacementImage)
XCTAssertTrue(store.atmosphereImage(for: materialID) === atmosphereImage)
XCTAssertTrue(store.coverImage(for: materialID) === coverImage)
store.remove(materialID: materialID)
}
func testSelectionToolbarReplacesUploadWithAIEditAndDelete() {
let normalState = TravelAlbumSelectionToolbarState(isSelectionMode: false, selectedCount: 0)
XCTAssertTrue(normalState.showsUpload)
XCTAssertFalse(normalState.showsSelectionActions)
let emptySelectionState = TravelAlbumSelectionToolbarState(isSelectionMode: true, selectedCount: 0)
XCTAssertFalse(emptySelectionState.showsUpload)
XCTAssertTrue(emptySelectionState.showsSelectionActions)
XCTAssertFalse(emptySelectionState.selectionActionsEnabled)
let selectedState = TravelAlbumSelectionToolbarState(isSelectionMode: true, selectedCount: 2)
XCTAssertTrue(selectedState.showsSelectionActions)
XCTAssertTrue(selectedState.selectionActionsEnabled)
}
func testPhotoPreviewCanSwipeAndKeepsValidIndexAfterDelete() {
let materials = [
TravelAlbumMaterial(id: 1, fileName: "A.JPG"),
TravelAlbumMaterial(id: 2, fileName: "B.JPG"),
TravelAlbumMaterial(id: 3, fileName: "C.JPG"),
]
var state = TravelAlbumPhotoPreviewState(materials: materials, initialIndex: 1)
XCTAssertEqual(state.currentMaterial?.id, 2)
XCTAssertTrue(state.moveNext())
XCTAssertEqual(state.currentMaterial?.id, 3)
XCTAssertFalse(state.moveNext())
XCTAssertTrue(state.removeCurrent())
XCTAssertEqual(state.currentMaterial?.id, 2)
XCTAssertTrue(state.movePrevious())
XCTAssertEqual(state.currentMaterial?.id, 1)
}
func testAIEditPresetsContainScrollableChoices() {
let presets = TravelAlbumEditPreset.defaultOptions
XCTAssertEqual(presets.count, 8)
XCTAssertEqual(Set(presets.map(\.id)).count, presets.count)
XCTAssertEqual(presets.first?.effect, .original)
XCTAssertTrue(presets.contains { $0.effect == .mist })
}
func testAtmosphereOptionsContainScrollableSingleChoiceStyles() {
let options = TravelAlbumAtmosphereEditOption.defaultOptions
XCTAssertGreaterThanOrEqual(options.count, 4)
XCTAssertEqual(Set(options.map(\.id)).count, options.count)
XCTAssertTrue(options.allSatisfy { $0.effect != .original })
XCTAssertEqual(TravelAlbumAtmosphereEditOption.defaultOption, options.first)
}
func testAIEditSelectionCountsOptionalAtmosphereAndCoverStyles() {
let refinedPreset = TravelAlbumEditPreset.defaultOptions[1]
let baseSelection = TravelAlbumAIEditSelection(
refinedPreset: refinedPreset,
atmosphereOption: nil,
coverTemplate: nil
)
XCTAssertEqual(baseSelection.resultCountPerPhoto, 1)
XCTAssertEqual(baseSelection.selectedStyleCount, 1)
let fullSelection = TravelAlbumAIEditSelection(
refinedPreset: refinedPreset,
atmosphereOption: .defaultOption,
coverTemplate: TravelAlbumCoverTemplate.defaultOptions[0]
)
XCTAssertEqual(fullSelection.resultCountPerPhoto, 2)
XCTAssertEqual(fullSelection.selectedStyleCount, 3)
}
func testCoverTemplatesAppearFromFourSelectedPhotos() {
XCTAssertFalse(TravelAlbumCoverTemplate.shouldShow(for: 3))
XCTAssertTrue(TravelAlbumCoverTemplate.shouldShow(for: 4))
XCTAssertTrue(TravelAlbumCoverTemplate.shouldShow(for: 8))
}
func testCoverTemplatesAreFreeAndReadyForFutureQuotaPricing() {
let templates = TravelAlbumCoverTemplate.defaultOptions
XCTAssertEqual(templates.count, 4)
XCTAssertEqual(Set(templates.map(\.id)).count, templates.count)
XCTAssertEqual(templates.first?.id, "travel_memoir")
XCTAssertTrue(templates.allSatisfy(\.isFreeGift))
XCTAssertTrue(templates.allSatisfy { $0.quotaCost == 0 })
}
func testRefreshLoadsInfoAndMaterials() async {
let api = TravelAlbumMockAPI()
api.infoResponse = TravelAlbum(id: 2, name: "详情")
@ -106,6 +309,53 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
XCTAssertEqual(api.materialRequests.last?.orderBy, 4)
}
func testPurchasedTabUsesLocalDemoMaterialsWhenServerIsEmpty() async {
let api = TravelAlbumMockAPI()
api.materialListResponses = [TravelAlbumListResponse(total: 0, list: [])]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.selectTab(.purchased, api: api)
XCTAssertTrue(viewModel.isUsingPurchasedDemoData)
XCTAssertEqual(viewModel.materials.count, 4)
XCTAssertTrue(viewModel.materials.allSatisfy(\.isPurchased))
XCTAssertTrue(viewModel.materials.allSatisfy { $0.id < 0 })
XCTAssertTrue(viewModel.materials.allSatisfy { $0.bundledImageAssetName != nil })
}
func testPurchasedTabKeepsRealServerMaterialsWhenAvailable() async {
let api = TravelAlbumMockAPI()
let serverMaterial = TravelAlbumMaterial(
id: 88,
fileName: "SERVER_0088.JPG",
fileUrl: "https://cdn.example.com/server-88.jpg",
isPurchased: true
)
api.materialListResponses = [
TravelAlbumListResponse(total: 1, list: [serverMaterial]),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.selectTab(.purchased, api: api)
XCTAssertFalse(viewModel.isUsingPurchasedDemoData)
XCTAssertEqual(viewModel.materials, [serverMaterial])
}
func testPurchasedDemoMaterialsFollowCurrentSortOption() async {
let api = TravelAlbumMockAPI()
api.materialListResponses = [
TravelAlbumListResponse(total: 0, list: []),
TravelAlbumListResponse(total: 0, list: []),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.selectTab(.purchased, api: api)
await viewModel.setSortOption(.fileNameAsc, api: api)
XCTAssertEqual(viewModel.materials.map(\.fileName), viewModel.materials.map(\.fileName).sorted())
}
func testOnlyUnpurchasedMaterialCanBeSelected() {
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
viewModel.toggleSelectionMode()
@ -170,6 +420,29 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertEqual(secondViewModel.transferModeOption, "拍后传输")
}
func testInitialTransferModeOverridesPersistedModeAndBecomesLatestSelection() {
let defaults = makeOTGTestDefaults()
defaults.set(TravelAlbumOTGTransferMode.liveUpload.rawValue, forKey: "travelAlbum.otg.transferMode")
let firstViewModel = WiredCameraTransferViewModel(
albumId: 9,
albumTitle: "相册",
headerPhone: "13800138000",
initialTransferMode: .postTransfer,
userDefaults: defaults
)
XCTAssertEqual(firstViewModel.transferMode, .postTransfer)
XCTAssertEqual(firstViewModel.transferModeOption, "拍后传输")
let secondViewModel = WiredCameraTransferViewModel(
albumId: 18,
albumTitle: "另一个相册",
headerPhone: "13800138000",
userDefaults: defaults
)
XCTAssertEqual(secondViewModel.transferMode, .postTransfer)
}
func testConnectedStatusIncludesCameraModelName() {
let context = makeOTGTestContext()
let viewModel = makeWiredViewModel(
@ -210,6 +483,31 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertEqual(groups.first?.slots.last?.photoCount, 2)
}
func testPhotoPreviewContextPreservesListOrderAndStartsAtTappedPhoto() {
let photos = [
makeOTGPhotoItem(
id: "a",
capturedAt: "2026-07-08 12:30:00",
thumbnailURL: URL(fileURLWithPath: "/tmp/a.jpg")
),
makeOTGPhotoItem(
id: "unavailable",
capturedAt: "2026-07-08 12:20:00"
),
makeOTGPhotoItem(
id: "c",
capturedAt: "2026-07-08 12:10:00",
thumbnailURL: URL(string: "https://cdn.example.com/c.jpg")
),
]
let context = photos.previewContext(startingWith: "c")
XCTAssertEqual(context?.photos.map(\.id), ["a", "c"])
XCTAssertEqual(context?.initialIndex, 1)
XCTAssertNil(photos.previewContext(startingWith: "unavailable"))
}
func testSelectedTimeSlotFallsBackWhenTabChanges() throws {
let context = makeOTGTestContext()
context.store.save([
@ -741,13 +1039,14 @@ private extension TravelAlbumOTGPhotoRecord {
private func makeOTGPhotoItem(
id: String,
capturedAt: String,
status: TravelAlbumOTGUploadStatus = .pending
status: TravelAlbumOTGUploadStatus = .pending,
thumbnailURL: URL? = nil
) -> TravelAlbumOTGPhotoItem {
TravelAlbumOTGPhotoItem(
id: id,
sourceId: id,
fileName: "\(id).JPG",
thumbnailURL: nil,
thumbnailURL: thumbnailURL,
capturedAt: capturedAt,
fileSizeText: "1 KB",
fileSizeBytes: 1024,