From 0016bf385787e56ca2222247480fa496e4eaec20 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 12 Nov 2026 22:50:16 +0800 Subject: [PATCH 1/3] refactor(ui): centralize model copies and auto-include unit tests - Express / Marketplace models now expose one with(...) helper that enumerates every stored field; six duplicated full memberwise constructions are gone. - MockMarketplaceRepository.updateItem silently dropped typeID (edited items kept showing the old category) and ownedByCurrentUser. The helper preserves both and now applies the draft's typeID; RemoteMarketplaceRepository.fetchItemDetail uses the same helpers. - New guard tests fail when a stored field is added without updating with(...), and cover the edit / state-change regressions. - Convert the unit-test target to a PBXFileSystemSynchronizedRootGroup. Six existing test files were on disk but never compiled by CI; adding a test file can no longer be silently ignored. Verification: swiftc -parse / -typecheck on changed sources and tests, a compiled executable exercising both helpers, plutil -lint plus reference-integrity check on the project file, check_localization.py (0 errors), check_hygiene.py (0 errors), and an equivalent of check_style.sh over all 363 tracked Swift files (0 violations). Xcode is unavailable locally (CommandLineTools only), so the simulator unit-test run remains for CI. --- GdeiAssistant-iOS.xcodeproj/project.pbxproj | 119 +------------ .../Express/Models/ExpressModels.swift | 29 ++++ .../Repositories/MockExpressRepository.swift | 49 +----- .../Express/ViewModels/ExpressViewModel.swift | 49 +----- .../Models/MarketplaceModels.swift | 65 ++++++++ .../MockMarketplaceRepository.swift | 55 ++---- .../RemoteMarketplaceRepository.swift | 29 +--- .../Express/ExpressPostCopyHelperTests.swift | 80 +++++++++ .../MarketplaceCopyHelperTests.swift | 156 ++++++++++++++++++ 9 files changed, 359 insertions(+), 272 deletions(-) create mode 100644 GdeiAssistant-iOSTests/Express/ExpressPostCopyHelperTests.swift create mode 100644 GdeiAssistant-iOSTests/Marketplace/MarketplaceCopyHelperTests.swift diff --git a/GdeiAssistant-iOS.xcodeproj/project.pbxproj b/GdeiAssistant-iOS.xcodeproj/project.pbxproj index c6d4229..b4f02f4 100644 --- a/GdeiAssistant-iOS.xcodeproj/project.pbxproj +++ b/GdeiAssistant-iOS.xcodeproj/project.pbxproj @@ -7,35 +7,9 @@ objects = { /* Begin PBXBuildFile section */ - 0A177ECCDC40F719D4D9F46F /* AuthTestDoubles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373B47CAADB5B3CE84F0601F /* AuthTestDoubles.swift */; }; - 1C7A3D8E4F5B6A7091827364 /* AppLanguageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8C4D9E5F6A7B8091C27364 /* AppLanguageTests.swift */; }; - 23FC56E702BCF7B97FD9012D /* RemoteMapperSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 346C4708A3C31E844BBF2625 /* RemoteMapperSupportTests.swift */; }; 3E24033623B018F07571CC25 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452569A4EA1E4E426F6F3E6D /* Foundation.framework */; }; - 44DC59BFABA1496DA3AE9B6D /* RemoteLostFoundRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FD844E948974FF8A18A0A3D /* RemoteLostFoundRepositoryTests.swift */; }; - 45CD090CF159FFACA7D91AD4 /* ProfileFormSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6DA9CD705BD14A1B1E9BD1C /* ProfileFormSupportTests.swift */; }; - 5081118454BD4B94AC5EBB51 /* APIClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C5CC023B26463B8EAE2B9A /* APIClientTests.swift */; }; - 5C8E7A3D1B2F4C6D8E9A0B1C /* MockFeatureSmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8E7A3D1B2F4C6D8E9A0B1B /* MockFeatureSmokeTests.swift */; }; - 6F8A9B0C1D2E3F4051627384 /* CertificatePinningDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B9C0D1E2F30415263748596 /* CertificatePinningDelegateTests.swift */; }; - 7A8B9C0D1E2F304152637485 /* SettingsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C0D1E2F30415263748596A7 /* SettingsViewModelTests.swift */; }; - C8F4A2B6D9E14035A7C2F912 /* ChargeRemoteMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8F4A2B6D9E14035A7C2F911 /* ChargeRemoteMapperTests.swift */; }; - A2B3C4D5E6F708192A3B4C5D /* TestLifetimeRetainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F60718293A4B5C6D /* TestLifetimeRetainer.swift */; }; A7F100000000000000000001 /* MockUISmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F100000000000000000003 /* MockUISmokeTests.swift */; }; A7F100000000000000000010 /* MockRuntimePerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F100000000000000000011 /* MockRuntimePerformanceTests.swift */; }; - 8219886478DFDC9042AE3AA9 /* RequestBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD63BDDF16EB5C5E56111AC4 /* RequestBuilderTests.swift */; }; - A1B2C3D4E5F60718A9B0D1E1 /* SecretDetailNoDuplicateFetchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718A9B0D1E2 /* SecretDetailNoDuplicateFetchTests.swift */; }; - B15D1954479F46BEB294065B /* RequestIDTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 359A61E15F1A4C11A7429225 /* RequestIDTests.swift */; }; - B1A2C3D4E5F60718A9B0C1D2 /* AppContainerBootstrapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D4E5F60718A9B0C1D3 /* AppContainerBootstrapTests.swift */; }; - C64B15DF5C6F7CCC0E32B37B /* DatingRemoteMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AF4619A3864805033BE080 /* DatingRemoteMapperTests.swift */; }; - E21B05F43CAE6846A9EC26D1 /* AuthManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB26C35E7E8F8EDFD881C69F /* AuthManagerTests.swift */; }; - E9FC3B2241EB8C322C48A5EF /* MultipartFormDataBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3112842602259B0422B1CBD1 /* MultipartFormDataBuilderTests.swift */; }; - EC5349E40F96411FB2E8629A /* RemoteMarketplaceRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 082E49E4E09F453AB6EF6772 /* RemoteMarketplaceRepositoryTests.swift */; }; - F90E561DD371D29335422B59 /* LibraryRemoteMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17F3CF56F76606E1257E386 /* LibraryRemoteMapperTests.swift */; }; - FFDDE89AE80431D0303BCEB4 /* MessagesRemoteMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D03C8887D96BBA87D07972B /* MessagesRemoteMapperTests.swift */; }; - F1A2B3C4D5E60718293A4B5C /* SocialRemoteMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E60718293A4B5D /* SocialRemoteMapperTests.swift */; }; - F2A3B4C5D6E708192A3B4C5D /* MockSocialRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2A3B4C5D6E708192A3B4C5E /* MockSocialRepositoryTests.swift */; }; - F3A4B5C6D7E8091A2B3C4D5E /* AuthenticatedImageLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A4B5C6D7E8091A2B3C4D5F /* AuthenticatedImageLoaderTests.swift */; }; - F4B5C6D7E8F90A2B3C4D5E6F /* ChatMessageMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4B5C6D7E8F90A2B3C4D5E70 /* ChatMessageMergeTests.swift */; }; - F5C6D7E8F901A2B3C4D5E6F7 /* PublicAuthorIdMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5C6D7E8F901A2B3C4D5E6F8 /* PublicAuthorIdMapperTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -56,44 +30,23 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 082E49E4E09F453AB6EF6772 /* RemoteMarketplaceRepositoryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RemoteMarketplaceRepositoryTests.swift; path = "GdeiAssistant-iOSTests/Marketplace/RemoteMarketplaceRepositoryTests.swift"; sourceTree = ""; }; - 2D03C8887D96BBA87D07972B /* MessagesRemoteMapperTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MessagesRemoteMapperTests.swift; path = "GdeiAssistant-iOSTests/Messages/MessagesRemoteMapperTests.swift"; sourceTree = ""; }; - 2B8C4D9E5F6A7B8091C27364 /* AppLanguageTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AppLanguageTests.swift; path = "GdeiAssistant-iOSTests/Localization/AppLanguageTests.swift"; sourceTree = ""; }; - 3112842602259B0422B1CBD1 /* MultipartFormDataBuilderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MultipartFormDataBuilderTests.swift; path = "GdeiAssistant-iOSTests/Networking/MultipartFormDataBuilderTests.swift"; sourceTree = ""; }; - 346C4708A3C31E844BBF2625 /* RemoteMapperSupportTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RemoteMapperSupportTests.swift; path = "GdeiAssistant-iOSTests/Utils/RemoteMapperSupportTests.swift"; sourceTree = ""; }; - 359A61E15F1A4C11A7429225 /* RequestIDTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RequestIDTests.swift; path = "GdeiAssistant-iOSTests/Networking/RequestIDTests.swift"; sourceTree = ""; }; - 373B47CAADB5B3CE84F0601F /* AuthTestDoubles.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AuthTestDoubles.swift; path = "GdeiAssistant-iOSTests/Support/AuthTestDoubles.swift"; sourceTree = ""; }; - B2C3D4E5F60718293A4B5C6D /* TestLifetimeRetainer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TestLifetimeRetainer.swift; path = "GdeiAssistant-iOSTests/Support/TestLifetimeRetainer.swift"; sourceTree = ""; }; 452569A4EA1E4E426F6F3E6D /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 5F0A1B2C3D4E5F6071829301 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; 5F0A1B2C3D4E5F6071829302 /* Staging.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Staging.xcconfig; sourceTree = ""; }; 5F0A1B2C3D4E5F6071829303 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 5C8E7A3D1B2F4C6D8E9A0B1B /* MockFeatureSmokeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MockFeatureSmokeTests.swift; path = "GdeiAssistant-iOSTests/Contract/MockFeatureSmokeTests.swift"; sourceTree = ""; }; 69DC5B58D2579C0F23D05428 /* GdeiAssistant-iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "GdeiAssistant-iOSTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - 7FD844E948974FF8A18A0A3D /* RemoteLostFoundRepositoryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RemoteLostFoundRepositoryTests.swift; path = "GdeiAssistant-iOSTests/LostFound/RemoteLostFoundRepositoryTests.swift"; sourceTree = ""; }; - 8B9C0D1E2F30415263748596 /* CertificatePinningDelegateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CertificatePinningDelegateTests.swift; path = "GdeiAssistant-iOSTests/Networking/CertificatePinningDelegateTests.swift"; sourceTree = ""; }; - 98C5CC023B26463B8EAE2B9A /* APIClientTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = APIClientTests.swift; path = "GdeiAssistant-iOSTests/Networking/APIClientTests.swift"; sourceTree = ""; }; - 9C0D1E2F30415263748596A7 /* SettingsViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SettingsViewModelTests.swift; path = "GdeiAssistant-iOSTests/Profile/SettingsViewModelTests.swift"; sourceTree = ""; }; A7F100000000000000000003 /* MockUISmokeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MockUISmokeTests.swift; path = "GdeiAssistant-iOSUITests/MockUISmokeTests.swift"; sourceTree = ""; }; A7F100000000000000000011 /* MockRuntimePerformanceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MockRuntimePerformanceTests.swift; path = "GdeiAssistant-iOSUITests/MockRuntimePerformanceTests.swift"; sourceTree = ""; }; A7F100000000000000000004 /* GdeiAssistant-iOSUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "GdeiAssistant-iOSUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - A17F3CF56F76606E1257E386 /* LibraryRemoteMapperTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LibraryRemoteMapperTests.swift; path = "GdeiAssistant-iOSTests/Library/LibraryRemoteMapperTests.swift"; sourceTree = ""; }; - A1B2C3D4E5F60718A9B0D1E2 /* SecretDetailNoDuplicateFetchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SecretDetailNoDuplicateFetchTests.swift; path = "GdeiAssistant-iOSTests/Contract/SecretDetailNoDuplicateFetchTests.swift"; sourceTree = ""; }; - A6DA9CD705BD14A1B1E9BD1C /* ProfileFormSupportTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ProfileFormSupportTests.swift; path = "GdeiAssistant-iOSTests/Profile/ProfileFormSupportTests.swift"; sourceTree = ""; }; - B1A2C3D4E5F60718A9B0C1D3 /* AppContainerBootstrapTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AppContainerBootstrapTests.swift; path = "GdeiAssistant-iOSTests/Container/AppContainerBootstrapTests.swift"; sourceTree = ""; }; - CD63BDDF16EB5C5E56111AC4 /* RequestBuilderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RequestBuilderTests.swift; path = "GdeiAssistant-iOSTests/Networking/RequestBuilderTests.swift"; sourceTree = ""; }; - C8F4A2B6D9E14035A7C2F911 /* ChargeRemoteMapperTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ChargeRemoteMapperTests.swift; path = "GdeiAssistant-iOSTests/Charge/ChargeRemoteMapperTests.swift"; sourceTree = ""; }; - D0AF4619A3864805033BE080 /* DatingRemoteMapperTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DatingRemoteMapperTests.swift; path = "GdeiAssistant-iOSTests/Dating/DatingRemoteMapperTests.swift"; sourceTree = ""; }; D72127102F5D506E00AE6A6F /* GdeiAssistant-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "GdeiAssistant-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - EB26C35E7E8F8EDFD881C69F /* AuthManagerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AuthManagerTests.swift; path = "GdeiAssistant-iOSTests/Auth/AuthManagerTests.swift"; sourceTree = ""; }; - F1A2B3C4D5E60718293A4B5D /* SocialRemoteMapperTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SocialRemoteMapperTests.swift; path = "GdeiAssistant-iOSTests/Social/SocialRemoteMapperTests.swift"; sourceTree = ""; }; - F2A3B4C5D6E708192A3B4C5E /* MockSocialRepositoryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MockSocialRepositoryTests.swift; path = "GdeiAssistant-iOSTests/Social/MockSocialRepositoryTests.swift"; sourceTree = ""; }; - F3A4B5C6D7E8091A2B3C4D5F /* AuthenticatedImageLoaderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AuthenticatedImageLoaderTests.swift; path = "GdeiAssistant-iOSTests/Social/AuthenticatedImageLoaderTests.swift"; sourceTree = ""; }; - F4B5C6D7E8F90A2B3C4D5E70 /* ChatMessageMergeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ChatMessageMergeTests.swift; path = "GdeiAssistant-iOSTests/Social/ChatMessageMergeTests.swift"; sourceTree = ""; }; - F5C6D7E8F901A2B3C4D5E6F8 /* PublicAuthorIdMapperTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PublicAuthorIdMapperTests.swift; path = "GdeiAssistant-iOSTests/Social/PublicAuthorIdMapperTests.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + DEECEB8674DE18A2B5A46637 /* GdeiAssistant-iOSTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "GdeiAssistant-iOSTests"; + sourceTree = ""; + }; D72127122F5D506E00AE6A6F /* GdeiAssistant-iOS */ = { isa = PBXFileSystemSynchronizedRootGroup; path = "GdeiAssistant-iOS"; @@ -184,39 +137,6 @@ name = "GdeiAssistant-iOSUITests"; sourceTree = ""; }; - DEECEB8674DE18A2B5A46637 /* GdeiAssistant-iOSTests */ = { - isa = PBXGroup; - children = ( - B1A2C3D4E5F60718A9B0C1D3 /* AppContainerBootstrapTests.swift */, - 2B8C4D9E5F6A7B8091C27364 /* AppLanguageTests.swift */, - EB26C35E7E8F8EDFD881C69F /* AuthManagerTests.swift */, - 373B47CAADB5B3CE84F0601F /* AuthTestDoubles.swift */, - C8F4A2B6D9E14035A7C2F911 /* ChargeRemoteMapperTests.swift */, - B2C3D4E5F60718293A4B5C6D /* TestLifetimeRetainer.swift */, - D0AF4619A3864805033BE080 /* DatingRemoteMapperTests.swift */, - A17F3CF56F76606E1257E386 /* LibraryRemoteMapperTests.swift */, - 2D03C8887D96BBA87D07972B /* MessagesRemoteMapperTests.swift */, - 5C8E7A3D1B2F4C6D8E9A0B1B /* MockFeatureSmokeTests.swift */, - 3112842602259B0422B1CBD1 /* MultipartFormDataBuilderTests.swift */, - A6DA9CD705BD14A1B1E9BD1C /* ProfileFormSupportTests.swift */, - 9C0D1E2F30415263748596A7 /* SettingsViewModelTests.swift */, - 346C4708A3C31E844BBF2625 /* RemoteMapperSupportTests.swift */, - CD63BDDF16EB5C5E56111AC4 /* RequestBuilderTests.swift */, - 359A61E15F1A4C11A7429225 /* RequestIDTests.swift */, - 8B9C0D1E2F30415263748596 /* CertificatePinningDelegateTests.swift */, - 98C5CC023B26463B8EAE2B9A /* APIClientTests.swift */, - 082E49E4E09F453AB6EF6772 /* RemoteMarketplaceRepositoryTests.swift */, - 7FD844E948974FF8A18A0A3D /* RemoteLostFoundRepositoryTests.swift */, - A1B2C3D4E5F60718A9B0D1E2 /* SecretDetailNoDuplicateFetchTests.swift */, - F1A2B3C4D5E60718293A4B5D /* SocialRemoteMapperTests.swift */, - F2A3B4C5D6E708192A3B4C5E /* MockSocialRepositoryTests.swift */, - F3A4B5C6D7E8091A2B3C4D5F /* AuthenticatedImageLoaderTests.swift */, - F4B5C6D7E8F90A2B3C4D5E70 /* ChatMessageMergeTests.swift */, - F5C6D7E8F901A2B3C4D5E6F8 /* PublicAuthorIdMapperTests.swift */, - ); - name = "GdeiAssistant-iOSTests"; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -251,6 +171,9 @@ dependencies = ( 2382F97AB1D9F89C03F48BC2 /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + DEECEB8674DE18A2B5A46637 /* GdeiAssistant-iOSTests */, + ); name = "GdeiAssistant-iOSTests"; productName = "GdeiAssistant-iOSTests"; productReference = 69DC5B58D2579C0F23D05428 /* GdeiAssistant-iOSTests.xctest */; @@ -363,32 +286,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - B1A2C3D4E5F60718A9B0C1D2 /* AppContainerBootstrapTests.swift in Sources */, - 1C7A3D8E4F5B6A7091827364 /* AppLanguageTests.swift in Sources */, - E21B05F43CAE6846A9EC26D1 /* AuthManagerTests.swift in Sources */, - 0A177ECCDC40F719D4D9F46F /* AuthTestDoubles.swift in Sources */, - C8F4A2B6D9E14035A7C2F912 /* ChargeRemoteMapperTests.swift in Sources */, - A2B3C4D5E6F708192A3B4C5D /* TestLifetimeRetainer.swift in Sources */, - C64B15DF5C6F7CCC0E32B37B /* DatingRemoteMapperTests.swift in Sources */, - F90E561DD371D29335422B59 /* LibraryRemoteMapperTests.swift in Sources */, - FFDDE89AE80431D0303BCEB4 /* MessagesRemoteMapperTests.swift in Sources */, - E9FC3B2241EB8C322C48A5EF /* MultipartFormDataBuilderTests.swift in Sources */, - 45CD090CF159FFACA7D91AD4 /* ProfileFormSupportTests.swift in Sources */, - 23FC56E702BCF7B97FD9012D /* RemoteMapperSupportTests.swift in Sources */, - 8219886478DFDC9042AE3AA9 /* RequestBuilderTests.swift in Sources */, - B15D1954479F46BEB294065B /* RequestIDTests.swift in Sources */, - 5081118454BD4B94AC5EBB51 /* APIClientTests.swift in Sources */, - 5C8E7A3D1B2F4C6D8E9A0B1C /* MockFeatureSmokeTests.swift in Sources */, - 6F8A9B0C1D2E3F4051627384 /* CertificatePinningDelegateTests.swift in Sources */, - EC5349E40F96411FB2E8629A /* RemoteMarketplaceRepositoryTests.swift in Sources */, - 44DC59BFABA1496DA3AE9B6D /* RemoteLostFoundRepositoryTests.swift in Sources */, - A1B2C3D4E5F60718A9B0D1E1 /* SecretDetailNoDuplicateFetchTests.swift in Sources */, - 7A8B9C0D1E2F304152637485 /* SettingsViewModelTests.swift in Sources */, - F1A2B3C4D5E60718293A4B5C /* SocialRemoteMapperTests.swift in Sources */, - F2A3B4C5D6E708192A3B4C5D /* MockSocialRepositoryTests.swift in Sources */, - F3A4B5C6D7E8091A2B3C4D5E /* AuthenticatedImageLoaderTests.swift in Sources */, - F4B5C6D7E8F90A2B3C4D5E6F /* ChatMessageMergeTests.swift in Sources */, - F5C6D7E8F901A2B3C4D5E6F7 /* PublicAuthorIdMapperTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/GdeiAssistant-iOS/Features/Express/Models/ExpressModels.swift b/GdeiAssistant-iOS/Features/Express/Models/ExpressModels.swift index 008f603..ddfbdc9 100644 --- a/GdeiAssistant-iOS/Features/Express/Models/ExpressModels.swift +++ b/GdeiAssistant-iOS/Features/Express/Models/ExpressModels.swift @@ -58,3 +58,32 @@ struct ExpressDraft: Codable { let content: String let targetGender: ExpressGender } + +extension ExpressPost { + /// A copy with only the given counters changed. Both the mock repository and the view + /// model apply the same optimistic updates, so adding a field must not require editing + /// six full memberwise constructions. + func with( + likeCount: Int? = nil, + commentCount: Int? = nil, + guessCount: Int? = nil, + correctGuessCount: Int? = nil, + isLiked: Bool? = nil + ) -> ExpressPost { + ExpressPost( + id: id, + nickname: nickname, + targetName: targetName, + contentPreview: contentPreview, + publishTime: publishTime, + likeCount: likeCount ?? self.likeCount, + commentCount: commentCount ?? self.commentCount, + guessCount: guessCount ?? self.guessCount, + correctGuessCount: correctGuessCount ?? self.correctGuessCount, + isLiked: isLiked ?? self.isLiked, + canGuess: canGuess, + selfGender: selfGender, + targetGender: targetGender + ) + } +} diff --git a/GdeiAssistant-iOS/Features/Express/Repositories/MockExpressRepository.swift b/GdeiAssistant-iOS/Features/Express/Repositories/MockExpressRepository.swift index 42fd578..2f242ff 100644 --- a/GdeiAssistant-iOS/Features/Express/Repositories/MockExpressRepository.swift +++ b/GdeiAssistant-iOS/Features/Express/Repositories/MockExpressRepository.swift @@ -102,63 +102,22 @@ final class MockExpressRepository: ExpressRepository { comments[postID, default: []].insert(item, at: 0) if let index = posts.firstIndex(where: { $0.id == postID }) { let post = posts[index] - posts[index] = ExpressPost( - id: post.id, - nickname: post.nickname, - targetName: post.targetName, - contentPreview: post.contentPreview, - publishTime: post.publishTime, - likeCount: post.likeCount, - commentCount: post.commentCount + 1, - guessCount: post.guessCount, - correctGuessCount: post.correctGuessCount, - isLiked: post.isLiked, - canGuess: post.canGuess, - selfGender: post.selfGender, - targetGender: post.targetGender - ) + posts[index] = post.with(commentCount: post.commentCount + 1) } } func like(postID: String) async throws { guard let index = posts.firstIndex(where: { $0.id == postID }) else { return } let post = posts[index] - posts[index] = ExpressPost( - id: post.id, - nickname: post.nickname, - targetName: post.targetName, - contentPreview: post.contentPreview, - publishTime: post.publishTime, - likeCount: post.likeCount + (post.isLiked ? 0 : 1), - commentCount: post.commentCount, - guessCount: post.guessCount, - correctGuessCount: post.correctGuessCount, - isLiked: true, - canGuess: post.canGuess, - selfGender: post.selfGender, - targetGender: post.targetGender - ) + posts[index] = post.with(likeCount: post.likeCount + (post.isLiked ? 0 : 1), isLiked: true) } func guess(postID: String, name: String) async throws -> Bool { let matched = name.trimmingCharacters(in: .whitespacesAndNewlines) == localizedString("mock.express.detail.realName") if let index = posts.firstIndex(where: { $0.id == postID }) { let post = posts[index] - posts[index] = ExpressPost( - id: post.id, - nickname: post.nickname, - targetName: post.targetName, - contentPreview: post.contentPreview, - publishTime: post.publishTime, - likeCount: post.likeCount, - commentCount: post.commentCount, - guessCount: post.guessCount + 1, - correctGuessCount: post.correctGuessCount + (matched ? 1 : 0), - isLiked: post.isLiked, - canGuess: post.canGuess, - selfGender: post.selfGender, - targetGender: post.targetGender - ) + posts[index] = post.with(guessCount: post.guessCount + 1, + correctGuessCount: post.correctGuessCount + (matched ? 1 : 0)) } return matched } diff --git a/GdeiAssistant-iOS/Features/Express/ViewModels/ExpressViewModel.swift b/GdeiAssistant-iOS/Features/Express/ViewModels/ExpressViewModel.swift index da5d65c..7870cec 100644 --- a/GdeiAssistant-iOS/Features/Express/ViewModels/ExpressViewModel.swift +++ b/GdeiAssistant-iOS/Features/Express/ViewModels/ExpressViewModel.swift @@ -46,21 +46,7 @@ final class ExpressViewModel: ObservableObject { func submitComment(postID: String, content: String) async throws { try await repository.submitComment(postID: postID, content: content) updatePost(postID: postID) { post in - ExpressPost( - id: post.id, - nickname: post.nickname, - targetName: post.targetName, - contentPreview: post.contentPreview, - publishTime: post.publishTime, - likeCount: post.likeCount, - commentCount: post.commentCount + 1, - guessCount: post.guessCount, - correctGuessCount: post.correctGuessCount, - isLiked: post.isLiked, - canGuess: post.canGuess, - selfGender: post.selfGender, - targetGender: post.targetGender - ) + post.with(commentCount: post.commentCount + 1) } } @@ -68,42 +54,15 @@ final class ExpressViewModel: ObservableObject { try await repository.like(postID: postID) if let index = posts.firstIndex(where: { $0.id == postID }) { let post = posts[index] - posts[index] = ExpressPost( - id: post.id, - nickname: post.nickname, - targetName: post.targetName, - contentPreview: post.contentPreview, - publishTime: post.publishTime, - likeCount: post.likeCount + (post.isLiked ? 0 : 1), - commentCount: post.commentCount, - guessCount: post.guessCount, - correctGuessCount: post.correctGuessCount, - isLiked: true, - canGuess: post.canGuess, - selfGender: post.selfGender, - targetGender: post.targetGender - ) + posts[index] = post.with(likeCount: post.likeCount + (post.isLiked ? 0 : 1), isLiked: true) } } func guess(postID: String, name: String) async throws -> Bool { let matched = try await repository.guess(postID: postID, name: name) updatePost(postID: postID) { post in - ExpressPost( - id: post.id, - nickname: post.nickname, - targetName: post.targetName, - contentPreview: post.contentPreview, - publishTime: post.publishTime, - likeCount: post.likeCount, - commentCount: post.commentCount, - guessCount: post.guessCount + 1, - correctGuessCount: post.correctGuessCount + (matched ? 1 : 0), - isLiked: post.isLiked, - canGuess: post.canGuess, - selfGender: post.selfGender, - targetGender: post.targetGender - ) + post.with(guessCount: post.guessCount + 1, + correctGuessCount: post.correctGuessCount + (matched ? 1 : 0)) } return matched } diff --git a/GdeiAssistant-iOS/Features/Marketplace/Models/MarketplaceModels.swift b/GdeiAssistant-iOS/Features/Marketplace/Models/MarketplaceModels.swift index 9086c83..c3f955a 100644 --- a/GdeiAssistant-iOS/Features/Marketplace/Models/MarketplaceModels.swift +++ b/GdeiAssistant-iOS/Features/Marketplace/Models/MarketplaceModels.swift @@ -101,3 +101,68 @@ struct MarketplaceUpdateDraft: Codable { let qq: String let phone: String? } + +extension MarketplaceItem { + /// The single place that enumerates every stored field when copying an item. + /// `nil` means "keep the current value"; optional fields can only be replaced with a + /// non-nil value here. Reconstructing the struct by hand silently dropped `typeID`, + /// so an edited item kept showing its old category. + func with( + title: String? = nil, + price: Double? = nil, + summary: String? = nil, + sellerName: String? = nil, + sellerAvatarURL: String? = nil, + authorId: String? = nil, + postedAt: String? = nil, + location: String? = nil, + state: MarketplaceItemState? = nil, + tags: [String]? = nil, + previewImageURL: String? = nil, + typeID: Int? = nil + ) -> MarketplaceItem { + MarketplaceItem( + id: id, + title: title ?? self.title, + price: price ?? self.price, + summary: summary ?? self.summary, + sellerName: sellerName ?? self.sellerName, + sellerAvatarURL: sellerAvatarURL ?? self.sellerAvatarURL, + authorId: authorId ?? self.authorId, + postedAt: postedAt ?? self.postedAt, + location: location ?? self.location, + state: state ?? self.state, + tags: tags ?? self.tags, + previewImageURL: previewImageURL ?? self.previewImageURL, + typeID: typeID ?? self.typeID + ) + } +} + +extension MarketplaceDetail { + /// The single place that enumerates every stored field when copying a detail. + /// `nil` means "keep the current value". Reconstructing by hand silently reset + /// `ownedByCurrentUser`, which controls whether the owner sees the edit actions. + func with( + item: MarketplaceItem? = nil, + condition: String? = nil, + description: String? = nil, + contactHint: String? = nil, + imageURLs: [String]? = nil, + ownedByCurrentUser: Bool? = nil + ) -> MarketplaceDetail { + MarketplaceDetail( + item: item ?? self.item, + condition: condition ?? self.condition, + description: description ?? self.description, + contactHint: contactHint ?? self.contactHint, + sellerUsername: sellerUsername, + sellerNickname: sellerNickname, + sellerCollege: sellerCollege, + sellerMajor: sellerMajor, + sellerGrade: sellerGrade, + imageURLs: imageURLs ?? self.imageURLs, + ownedByCurrentUser: ownedByCurrentUser ?? self.ownedByCurrentUser + ) + } +} diff --git a/GdeiAssistant-iOS/Features/Marketplace/Repositories/MockMarketplaceRepository.swift b/GdeiAssistant-iOS/Features/Marketplace/Repositories/MockMarketplaceRepository.swift index 0f74530..f910ebb 100644 --- a/GdeiAssistant-iOS/Features/Marketplace/Repositories/MockMarketplaceRepository.swift +++ b/GdeiAssistant-iOS/Features/Marketplace/Repositories/MockMarketplaceRepository.swift @@ -107,67 +107,32 @@ final class MockMarketplaceRepository: MarketplaceRepository { throw NetworkError.server(code: 404, message: localizedString("marketplace.itemNotFound")) } let contactHintParts = contactHintParts(qq: draft.qq, phone: draft.phone) - let updatedItem = MarketplaceItem( - id: detail.item.id, + let category = MarketplaceRemoteMapper.displayName(forType: draft.typeID) + let updatedItem = detail.item.with( title: draft.title, price: draft.price, summary: RemoteMapperSupport.truncated(draft.description, limit: 28), - sellerName: detail.item.sellerName, - sellerAvatarURL: detail.item.sellerAvatarURL, - authorId: detail.item.authorId, postedAt: "刚刚修改", location: draft.location, - state: detail.item.state, - tags: [MarketplaceRemoteMapper.displayName(forType: draft.typeID)], - previewImageURL: detail.item.previewImageURL + tags: [category], + typeID: draft.typeID ) - detailsByID[itemID] = MarketplaceDetail( + detailsByID[itemID] = detail.with( item: updatedItem, - condition: MarketplaceRemoteMapper.displayName(forType: draft.typeID), + condition: category, description: draft.description, - contactHint: contactHintParts.joined(separator: " / "), - sellerUsername: detail.sellerUsername, - sellerNickname: detail.sellerNickname, - sellerCollege: detail.sellerCollege, - sellerMajor: detail.sellerMajor, - sellerGrade: detail.sellerGrade, - imageURLs: detail.imageURLs + contactHint: contactHintParts.joined(separator: " / ") ) syncItem(updatedItem) } func updateItemState(itemID: String, state: MarketplaceItemState) async throws { - guard var detail = detailsByID[itemID] else { + guard let detail = detailsByID[itemID] else { throw NetworkError.server(code: 404, message: localizedString("marketplace.itemNotFound")) } - let updatedItem = MarketplaceItem( - id: detail.item.id, - title: detail.item.title, - price: detail.item.price, - summary: detail.item.summary, - sellerName: detail.item.sellerName, - sellerAvatarURL: detail.item.sellerAvatarURL, - authorId: detail.item.authorId, - postedAt: detail.item.postedAt, - location: detail.item.location, - state: state, - tags: detail.item.tags, - previewImageURL: detail.item.previewImageURL - ) - detail = MarketplaceDetail( - item: updatedItem, - condition: detail.condition, - description: detail.description, - contactHint: detail.contactHint, - sellerUsername: detail.sellerUsername, - sellerNickname: detail.sellerNickname, - sellerCollege: detail.sellerCollege, - sellerMajor: detail.sellerMajor, - sellerGrade: detail.sellerGrade, - imageURLs: detail.imageURLs - ) - detailsByID[itemID] = detail + let updatedItem = detail.item.with(state: state) + detailsByID[itemID] = detail.with(item: updatedItem) syncItem(updatedItem) } diff --git a/GdeiAssistant-iOS/Features/Marketplace/Repositories/RemoteMarketplaceRepository.swift b/GdeiAssistant-iOS/Features/Marketplace/Repositories/RemoteMarketplaceRepository.swift index 7eb6275..3d8e0f9 100644 --- a/GdeiAssistant-iOS/Features/Marketplace/Repositories/RemoteMarketplaceRepository.swift +++ b/GdeiAssistant-iOS/Features/Marketplace/Repositories/RemoteMarketplaceRepository.swift @@ -38,32 +38,9 @@ final class RemoteMarketplaceRepository: MarketplaceRepository { return detail } - return MarketplaceDetail( - item: MarketplaceItem( - id: detail.item.id, - title: detail.item.title, - price: detail.item.price, - summary: detail.item.summary, - sellerName: detail.item.sellerName, - sellerAvatarURL: detail.item.sellerAvatarURL, - authorId: detail.item.authorId, - postedAt: detail.item.postedAt, - location: detail.item.location, - state: detail.item.state, - tags: detail.item.tags, - previewImageURL: detail.item.previewImageURL ?? previewURL, - typeID: detail.item.typeID - ), - condition: detail.condition, - description: detail.description, - contactHint: detail.contactHint, - sellerUsername: detail.sellerUsername, - sellerNickname: detail.sellerNickname, - sellerCollege: detail.sellerCollege, - sellerMajor: detail.sellerMajor, - sellerGrade: detail.sellerGrade, - imageURLs: [previewURL], - ownedByCurrentUser: detail.ownedByCurrentUser + return detail.with( + item: detail.item.with(previewImageURL: detail.item.previewImageURL ?? previewURL), + imageURLs: [previewURL] ) } diff --git a/GdeiAssistant-iOSTests/Express/ExpressPostCopyHelperTests.swift b/GdeiAssistant-iOSTests/Express/ExpressPostCopyHelperTests.swift new file mode 100644 index 0000000..028b513 --- /dev/null +++ b/GdeiAssistant-iOSTests/Express/ExpressPostCopyHelperTests.swift @@ -0,0 +1,80 @@ +import XCTest +@testable import GdeiAssistant_iOS + +/// The optimistic like / comment / guess counters used to be rebuilt with six full +/// memberwise constructions. This test keeps the single `with(...)` helper honest: +/// a new stored field changes the Mirror labels and fails the first assertion. +final class ExpressPostCopyHelperTests: XCTestCase { + + private func labels(_ value: Any) -> [String] { + Mirror(reflecting: value).children.compactMap { $0.label } + } + + private func fields(_ value: Any) -> [String: String] { + Dictionary(uniqueKeysWithValues: Mirror(reflecting: value).children.compactMap { child in + child.label.map { ($0, String(describing: child.value)) } + }) + } + + private func makePost() -> ExpressPost { + ExpressPost( + id: "express_test_001", + nickname: "小明", + targetName: "小红", + contentPreview: "内容预览", + publishTime: "昨天 20:00", + likeCount: 5, + commentCount: 2, + guessCount: 3, + correctGuessCount: 1, + isLiked: false, + canGuess: true, + selfGender: .male, + targetGender: .female + ) + } + + func testHelperNamesEveryStoredField() { + XCTAssertEqual(labels(makePost()), [ + "id", "nickname", "targetName", "contentPreview", "publishTime", + "likeCount", "commentCount", "guessCount", "correctGuessCount", + "isLiked", "canGuess", "selfGender", "targetGender" + ], "A new stored field must also be added to ExpressPost.with(...)") + } + + func testCopyKeepsEveryFieldItDoesNotChange() { + let original = makePost() + let copy = original.with(likeCount: 6, isLiked: true) + let before = fields(original) + let after = fields(copy) + let changed: Set = ["likeCount", "isLiked"] + + for (label, value) in before where !changed.contains(label) { + XCTAssertEqual(after[label], value, "\(label) was dropped by with(...)") + } + XCTAssertEqual(copy.likeCount, 6) + XCTAssertTrue(copy.isLiked) + XCTAssertEqual(original.likeCount, 5, "with(...) must not mutate the original") + XCTAssertFalse(original.isLiked) + } + + func testCounterUpdatesComposeWithoutLosingOtherCounters() { + let original = makePost() + let commented = original.with(commentCount: original.commentCount + 1) + let liked = commented.with(likeCount: commented.likeCount + 1, isLiked: true) + let guessed = liked.with( + guessCount: liked.guessCount + 1, + correctGuessCount: liked.correctGuessCount + 1 + ) + + XCTAssertEqual(guessed.commentCount, 3) + XCTAssertEqual(guessed.likeCount, 6) + XCTAssertEqual(guessed.guessCount, 4) + XCTAssertEqual(guessed.correctGuessCount, 2) + XCTAssertTrue(guessed.isLiked) + XCTAssertTrue(guessed.canGuess) + XCTAssertEqual(guessed.selfGender, .male) + XCTAssertEqual(guessed.targetGender, .female) + XCTAssertEqual(guessed.id, original.id) + } +} diff --git a/GdeiAssistant-iOSTests/Marketplace/MarketplaceCopyHelperTests.swift b/GdeiAssistant-iOSTests/Marketplace/MarketplaceCopyHelperTests.swift new file mode 100644 index 0000000..9e9ca7c --- /dev/null +++ b/GdeiAssistant-iOSTests/Marketplace/MarketplaceCopyHelperTests.swift @@ -0,0 +1,156 @@ +import XCTest +@testable import GdeiAssistant_iOS + +/// These tests pin the hidden link between a model's stored fields and its copy helper. +/// Adding a stored field changes the Mirror labels, so the label test fails until +/// `with(...)` is extended to carry the new field across. +final class MarketplaceCopyHelperTests: XCTestCase { + + private func labels(_ value: Any) -> [String] { + Mirror(reflecting: value).children.compactMap { $0.label } + } + + private func fields(_ value: Any) -> [String: String] { + Dictionary(uniqueKeysWithValues: Mirror(reflecting: value).children.compactMap { child in + child.label.map { ($0, String(describing: child.value)) } + }) + } + + private func makeItem(typeID: Int? = 4) -> MarketplaceItem { + MarketplaceItem( + id: "market_test_001", + title: "二手车", + price: 42.5, + summary: "九成新", + sellerName: "小明", + sellerAvatarURL: "https://example.com/avatar.png", + authorId: "user-demo-0001", + postedAt: "昨天", + location: "海珠校区", + state: .selling, + tags: ["交通工具"], + previewImageURL: "https://example.com/preview.png", + typeID: typeID + ) + } + + private func makeDetail() -> MarketplaceDetail { + MarketplaceDetail( + item: makeItem(), + condition: "交通工具", + description: "描述", + contactHint: "QQ 123456", + sellerUsername: "demo", + sellerNickname: "小明", + sellerCollege: "学院", + sellerMajor: "专业", + sellerGrade: "2023", + imageURLs: ["https://example.com/1.png"], + ownedByCurrentUser: true + ) + } + + func testItemHelperNamesEveryStoredField() { + XCTAssertEqual(labels(makeItem()), [ + "id", "title", "price", "summary", "sellerName", "sellerAvatarURL", + "authorId", "postedAt", "location", "state", "tags", "previewImageURL", "typeID" + ], "A new stored field must also be added to MarketplaceItem.with(...)") + } + + func testItemCopyKeepsEveryFieldItDoesNotChange() { + let original = makeItem() + let copy = original.with(state: .sold) + let before = fields(original) + let after = fields(copy) + + for (label, value) in before where label != "state" { + XCTAssertEqual(after[label], value, "\(label) was dropped by with(...)") + } + XCTAssertEqual(copy.state, .sold) + XCTAssertEqual(original.state, .selling, "with(...) must not mutate the original") + } + + func testItemCopyCanChangeTheCategory() { + let item = makeItem(typeID: 4) + + XCTAssertEqual(item.with(typeID: 9).typeID, 9) + XCTAssertEqual(item.typeID, 4) + } + + func testDetailHelperNamesEveryStoredField() { + XCTAssertEqual(labels(makeDetail()), [ + "item", "condition", "description", "contactHint", "sellerUsername", + "sellerNickname", "sellerCollege", "sellerMajor", "sellerGrade", + "imageURLs", "ownedByCurrentUser" + ], "A new stored field must also be added to MarketplaceDetail.with(...)") + } + + func testDetailCopyKeepsOwnershipAndSellerFields() { + let detail = makeDetail() + let copy = detail.with( + item: detail.item.with(state: .sold), + condition: "已售出", + imageURLs: [] + ) + + XCTAssertTrue(copy.ownedByCurrentUser) + XCTAssertEqual(copy.sellerUsername, "demo") + XCTAssertEqual(copy.sellerGrade, "2023") + XCTAssertEqual(copy.description, detail.description) + XCTAssertEqual(copy.contactHint, detail.contactHint) + XCTAssertEqual(copy.item.typeID, 4) + XCTAssertEqual(copy.item.state, .sold) + XCTAssertEqual(copy.condition, "已售出") + XCTAssertEqual(copy.imageURLs, []) + XCTAssertTrue(detail.ownedByCurrentUser, "with(...) must not mutate the original") + } +} + +@MainActor +final class MarketplaceCopyIntegrationTests: XCTestCase { + + private func editDraft(typeID: Int) -> MarketplaceUpdateDraft { + MarketplaceUpdateDraft( + title: "改过的标题", + price: 99, + description: "改过的描述", + location: "新地点", + typeID: typeID, + qq: "123456", + phone: nil + ) + } + + func testEditingAnItemAppliesTheNewCategory() async throws { + let repository = MockMarketplaceRepository() + let items = try await repository.fetchItems(typeID: nil) + guard let target = items.first(where: { $0.state == .selling }) else { + XCTFail("Expected a selling seed item") + return + } + + try await repository.updateItem(itemID: target.id, draft: editDraft(typeID: 3)) + + let updated = try await repository.fetchItems(typeID: nil).first { $0.id == target.id } + XCTAssertEqual(updated?.typeID, 3, "Editing must not silently drop the item category") + XCTAssertEqual(updated?.title, "改过的标题") + XCTAssertEqual(updated?.previewImageURL, target.previewImageURL) + } + + func testChangingStateKeepsTheEditedCategory() async throws { + let repository = MockMarketplaceRepository() + let items = try await repository.fetchItems(typeID: nil) + guard let target = items.first(where: { $0.state == .selling }) else { + XCTFail("Expected a selling seed item") + return + } + + try await repository.updateItem(itemID: target.id, draft: editDraft(typeID: 5)) + try await repository.updateItemState(itemID: target.id, state: .sold) + + let detail = try await repository.fetchItemDetail(itemID: target.id) + XCTAssertEqual(detail.item.typeID, 5) + XCTAssertEqual(detail.item.state, .sold) + XCTAssertEqual(detail.item.previewImageURL, target.previewImageURL) + } +} From c80ad2c2ae70eb197456c6ef941bb2fe52c75684 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:01:48 +0800 Subject: [PATCH 2/3] fix(tests): await the save result before asserting ProfileViewModelTests passed an async call into XCTAssertTrue's autoclosure, which does not support concurrency and fails to compile. The error was latent: this file was on disk but never part of the test target, so it had never been compiled. Making the target a synchronized group surfaced it, which is the point of that change. --- GdeiAssistant-iOSTests/Profile/ProfileViewModelTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/GdeiAssistant-iOSTests/Profile/ProfileViewModelTests.swift b/GdeiAssistant-iOSTests/Profile/ProfileViewModelTests.swift index 1f45351..1e1b13f 100644 --- a/GdeiAssistant-iOSTests/Profile/ProfileViewModelTests.swift +++ b/GdeiAssistant-iOSTests/Profile/ProfileViewModelTests.swift @@ -277,7 +277,8 @@ final class ProfileViewModelTests: XCTestCase { XCTAssertEqual(viewModel.major, "软件工程") XCTAssertEqual(viewModel.location, "中国 广东 广州") - XCTAssertTrue(await viewModel.saveProfile()) + let saved = await viewModel.saveProfile() + XCTAssertTrue(saved) XCTAssertEqual(repository.updateRequests.last?.location?.cityCode, "1") XCTAssertEqual(repository.updateRequests.last?.hometown?.cityCode, "5") } From 338e56d2cdbc673b38d65ffc18661697b7db802c Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:36:27 +0800 Subject: [PATCH 3/3] fix(tests): run the marketplace initial-state test on the main actor The only synchronous test in this @MainActor class crashed the runner with "malloc: pointer being freed was not allocated" while constructing the view model, then passed after the automatic restart. Its async siblings build the same view model and mock repository without incident, so the sync invocation is what differs. Like the rest of the class it tests main-actor state, so declaring it async keeps it on the actor's executor instead of relying on XCTest's synchronous main-actor hop. The file had never been compiled before this branch, so the crash was latent rather than a regression from the model-copy change. --- .../Marketplace/MarketplaceViewModelTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GdeiAssistant-iOSTests/Marketplace/MarketplaceViewModelTests.swift b/GdeiAssistant-iOSTests/Marketplace/MarketplaceViewModelTests.swift index 6c6fe00..e334cec 100644 --- a/GdeiAssistant-iOSTests/Marketplace/MarketplaceViewModelTests.swift +++ b/GdeiAssistant-iOSTests/Marketplace/MarketplaceViewModelTests.swift @@ -4,7 +4,7 @@ import XCTest @MainActor final class MarketplaceViewModelTests: XCTestCase { - func testInitialStateIsEmpty() { + func testInitialStateIsEmpty() async { let vm = MarketplaceViewModel(repository: MockMarketplaceRepository()) XCTAssertTrue(vm.items.isEmpty)