@@ -87,7 +87,7 @@ final class AppMigrationServiceTests: XCTestCase {
8787 }
8888 }
8989
90- func testFolderMoveAndRestoreUsesSingleFolderSymlink ( ) async throws {
90+ func testFolderMoveAndRestoreUsesFolderMirror ( ) async throws {
9191 let workspace = try makeWorkspace ( )
9292 defer { cleanupWorkspace ( workspace. rootURL) }
9393
@@ -96,6 +96,10 @@ final class AppMigrationServiceTests: XCTestCase {
9696 try fileManager. createDirectory ( at: localSuiteURL, withIntermediateDirectories: true )
9797 try createAppBundle ( at: localSuiteURL. appendingPathComponent ( " Word.app " ) )
9898 try createAppBundle ( at: localSuiteURL. appendingPathComponent ( " Excel.app " ) )
99+ // 非 app 条目:单文件 + 子目录
100+ try " manual " . write ( to: localSuiteURL. appendingPathComponent ( " Manual.pdf " ) , atomically: true , encoding: . utf8)
101+ try fileManager. createDirectory ( at: localSuiteURL. appendingPathComponent ( " Documents " ) , withIntermediateDirectories: true )
102+ try " license " . write ( to: localSuiteURL. appendingPathComponent ( " Documents/License.txt " ) , atomically: true , encoding: . utf8)
99103
100104 let service = AppMigrationService ( )
101105 let suiteItem = AppItem (
@@ -113,9 +117,16 @@ final class AppMigrationServiceTests: XCTestCase {
113117 progressHandler: nil
114118 )
115119
116- try assertWholeAppSymlink ( localSuiteURL, pointsTo: externalSuiteURL)
120+ // 本地是真实文件夹(非 symlink),内部 app 为 Stub,非 app 为符号链接,含标记文件
121+ try assertFolderMirror ( localSuiteURL, stubAppNames: [ " Word.app " , " Excel.app " ] , externalURL: externalSuiteURL)
122+ try assertSymlink ( localSuiteURL. appendingPathComponent ( " Manual.pdf " ) , pointsTo: externalSuiteURL. appendingPathComponent ( " Manual.pdf " ) )
123+ try assertSymlink ( localSuiteURL. appendingPathComponent ( " Documents " ) , pointsTo: externalSuiteURL. appendingPathComponent ( " Documents " ) )
124+ // 内部 app 未被展开到 /Applications 顶层
117125 XCTAssertFalse ( fileManager. fileExists ( atPath: workspace. localAppsURL. appendingPathComponent ( " Word.app " ) . path) )
118126 XCTAssertFalse ( fileManager. fileExists ( atPath: workspace. localAppsURL. appendingPathComponent ( " Excel.app " ) . path) )
127+ // 外部为真实套件副本,且不含本地标记文件
128+ try assertRealAppBundle ( externalSuiteURL. appendingPathComponent ( " Word.app " ) )
129+ XCTAssertFalse ( fileManager. fileExists ( atPath: externalSuiteURL. appendingPathComponent ( AppMigrationService . folderPortalMarkerName) . path) )
119130
120131 try await service. moveBack (
121132 app: AppItem (
@@ -129,13 +140,90 @@ final class AppMigrationServiceTests: XCTestCase {
129140 progressHandler: nil
130141 )
131142
132- XCTAssertFalse ( fileManager. fileExists ( atPath: workspace. localAppsURL. appendingPathComponent ( " Word.app " ) . path) )
133- XCTAssertFalse ( fileManager. fileExists ( atPath: workspace. localAppsURL. appendingPathComponent ( " Excel.app " ) . path) )
143+ // 还原后本地为真实套件,标记消失,外部已删除
134144 try assertRealAppBundle ( localSuiteURL. appendingPathComponent ( " Word.app " ) )
135145 try assertRealAppBundle ( localSuiteURL. appendingPathComponent ( " Excel.app " ) )
146+ XCTAssertFalse ( fileManager. fileExists ( atPath: localSuiteURL. appendingPathComponent ( AppMigrationService . folderPortalMarkerName) . path) )
147+ XCTAssertEqual ( try String ( contentsOf: localSuiteURL. appendingPathComponent ( " Manual.pdf " ) , encoding: . utf8) , " manual " )
136148 XCTAssertFalse ( fileManager. fileExists ( atPath: externalSuiteURL. path) )
137149 }
138150
151+ func testFolderMirrorDeleteLinkRemovesMirrorKeepsExternal( ) async throws {
152+ let workspace = try makeWorkspace ( )
153+ defer { cleanupWorkspace ( workspace. rootURL) }
154+
155+ let localSuiteURL = workspace. localAppsURL. appendingPathComponent ( " Office " )
156+ let externalSuiteURL = workspace. externalRootURL. appendingPathComponent ( " Office " )
157+ try fileManager. createDirectory ( at: localSuiteURL, withIntermediateDirectories: true )
158+ try createAppBundle ( at: localSuiteURL. appendingPathComponent ( " Word.app " ) )
159+ try createAppBundle ( at: localSuiteURL. appendingPathComponent ( " Excel.app " ) )
160+
161+ let service = AppMigrationService ( )
162+ try await service. moveAndLink (
163+ appToMove: AppItem ( name: " Office " , path: localSuiteURL, status: " 本地 " , isFolder: true , appCount: 2 ) ,
164+ destinationURL: externalSuiteURL,
165+ isRunning: false ,
166+ progressHandler: nil
167+ )
168+ try assertFolderMirror ( localSuiteURL, stubAppNames: [ " Word.app " , " Excel.app " ] , externalURL: externalSuiteURL)
169+
170+ // 解链:删除本地镜像,外部真实套件保持完好
171+ try service. deleteLink ( app: AppItem ( name: " Office " , path: localSuiteURL, status: " 已链接 " , isFolder: true , appCount: 2 ) )
172+ XCTAssertFalse ( fileManager. fileExists ( atPath: localSuiteURL. path) )
173+ try assertRealAppBundle ( externalSuiteURL. appendingPathComponent ( " Word.app " ) )
174+ try assertRealAppBundle ( externalSuiteURL. appendingPathComponent ( " Excel.app " ) )
175+ }
176+
177+ func testRefreshFolderMirrorSyncsAddedAndRemovedEntries( ) async throws {
178+ let workspace = try makeWorkspace ( )
179+ defer { cleanupWorkspace ( workspace. rootURL) }
180+
181+ let localSuiteURL = workspace. localAppsURL. appendingPathComponent ( " Office " )
182+ let externalSuiteURL = workspace. externalRootURL. appendingPathComponent ( " Office " )
183+ try fileManager. createDirectory ( at: localSuiteURL, withIntermediateDirectories: true )
184+ try createAppBundle ( at: localSuiteURL. appendingPathComponent ( " Word.app " ) )
185+ try createAppBundle ( at: localSuiteURL. appendingPathComponent ( " Excel.app " ) )
186+
187+ let service = AppMigrationService ( )
188+ try await service. moveAndLink (
189+ appToMove: AppItem ( name: " Office " , path: localSuiteURL, status: " 本地 " , isFolder: true , appCount: 2 ) ,
190+ destinationURL: externalSuiteURL,
191+ isRunning: false ,
192+ progressHandler: nil
193+ )
194+ try assertFolderMirror ( localSuiteURL, stubAppNames: [ " Word.app " , " Excel.app " ] , externalURL: externalSuiteURL)
195+
196+ // 模拟外部套件被更新:新增 PowerPoint.app 与 ReadMe.txt,删除 Excel.app
197+ try createAppBundle ( at: externalSuiteURL. appendingPathComponent ( " PowerPoint.app " ) )
198+ try " read me " . write ( to: externalSuiteURL. appendingPathComponent ( " ReadMe.txt " ) , atomically: true , encoding: . utf8)
199+ try fileManager. removeItem ( at: externalSuiteURL. appendingPathComponent ( " Excel.app " ) )
200+
201+ service. refreshFolderMirror ( at: localSuiteURL, from: externalSuiteURL)
202+
203+ // 新增项被镜像,删除项被清理,保留项不变,标记仍在
204+ try assertStubPortal ( localSuiteURL. appendingPathComponent ( " Word.app " ) , pointsTo: externalSuiteURL. appendingPathComponent ( " Word.app " ) )
205+ try assertStubPortal ( localSuiteURL. appendingPathComponent ( " PowerPoint.app " ) , pointsTo: externalSuiteURL. appendingPathComponent ( " PowerPoint.app " ) )
206+ try assertSymlink ( localSuiteURL. appendingPathComponent ( " ReadMe.txt " ) , pointsTo: externalSuiteURL. appendingPathComponent ( " ReadMe.txt " ) )
207+ XCTAssertFalse ( fileManager. fileExists ( atPath: localSuiteURL. appendingPathComponent ( " Excel.app " ) . path) )
208+ XCTAssertTrue ( fileManager. fileExists ( atPath: localSuiteURL. appendingPathComponent ( AppMigrationService . folderPortalMarkerName) . path) )
209+ }
210+
211+ func testRefreshFolderMirrorIgnoresLegacySymlinkFolder( ) throws {
212+ let workspace = try makeWorkspace ( )
213+ defer { cleanupWorkspace ( workspace. rootURL) }
214+
215+ // 旧版整体符号链接文件夹(无标记文件)
216+ let externalSuiteURL = workspace. externalRootURL. appendingPathComponent ( " Office " )
217+ try fileManager. createDirectory ( at: externalSuiteURL, withIntermediateDirectories: true )
218+ try createAppBundle ( at: externalSuiteURL. appendingPathComponent ( " Word.app " ) )
219+ let localSuiteURL = workspace. localAppsURL. appendingPathComponent ( " Office " )
220+ try fileManager. createSymbolicLink ( at: localSuiteURL, withDestinationURL: externalSuiteURL)
221+
222+ // 应安全跳过:不抛错,不改动符号链接
223+ AppMigrationService ( ) . refreshFolderMirror ( at: localSuiteURL, from: externalSuiteURL)
224+ try assertWholeAppSymlink ( localSuiteURL, pointsTo: externalSuiteURL)
225+ }
226+
139227 func testIOSRelinkUsesStubPortal( ) throws {
140228 let workspace = try makeWorkspace ( )
141229 defer { cleanupWorkspace ( workspace. rootURL) }
@@ -425,14 +513,22 @@ final class AppMigrationServiceTests: XCTestCase {
425513 let launcherURL = localURL. appendingPathComponent ( " Contents/MacOS/launcher " )
426514 XCTAssertTrue ( fileManager. fileExists ( atPath: launcherURL. path) , " launcher script should exist " , file: file, line: line)
427515
516+ // 解析符号链接后比较,规避测试临时目录位于 /var -> /private/var 软链导致的路径差异
517+ let expectedResolved = externalURL. resolvingSymlinksInPath ( ) . path
428518 let pathFileURL = localURL. appendingPathComponent ( " Contents/Resources/real_app_path.txt " )
429519 if fileManager. fileExists ( atPath: pathFileURL. path) {
430520 let path = try String ( contentsOf: pathFileURL, encoding: . utf8)
431521 . trimmingCharacters ( in: . whitespacesAndNewlines)
432- XCTAssertEqual ( path, externalURL. path, " launcher path file should reference external app " , file: file, line: line)
522+ let resolved = URL ( fileURLWithPath: path) . resolvingSymlinksInPath ( ) . path
523+ XCTAssertEqual ( resolved, expectedResolved, " launcher path file should reference external app " , file: file, line: line)
433524 } else {
434525 let script = try String ( contentsOf: launcherURL, encoding: . utf8)
435- XCTAssertTrue ( script. contains ( externalURL. path) , " launcher should reference external app " , file: file, line: line)
526+ XCTAssertTrue (
527+ script. contains ( externalURL. path) || script. contains ( expectedResolved) ,
528+ " launcher should reference external app " ,
529+ file: file,
530+ line: line
531+ )
436532 }
437533 }
438534
@@ -442,6 +538,43 @@ final class AppMigrationServiceTests: XCTestCase {
442538 XCTAssertEqual ( resolvedDestination, externalURL. standardizedFileURL, file: file, line: line)
443539 }
444540
541+ private func assertSymlink( _ localURL: URL , pointsTo target: URL , file: StaticString = #filePath, line: UInt = #line) throws {
542+ let destination = try fileManager. destinationOfSymbolicLink ( atPath: localURL. path)
543+ let resolvedDestination = URL ( fileURLWithPath: destination, relativeTo: localURL. deletingLastPathComponent ( ) ) . standardizedFileURL
544+ XCTAssertEqual ( resolvedDestination, target. standardizedFileURL, file: file, line: line)
545+ }
546+
547+ private func assertFolderMirror(
548+ _ localURL: URL ,
549+ stubAppNames: [ String ] ,
550+ externalURL: URL ,
551+ file: StaticString = #filePath,
552+ line: UInt = #line
553+ ) throws {
554+ let values = try localURL. resourceValues ( forKeys: [ . isDirectoryKey, . isSymbolicLinkKey] )
555+ XCTAssertEqual ( values. isDirectory, true , " mirror should be a real directory " , file: file, line: line)
556+ XCTAssertNotEqual ( values. isSymbolicLink, true , " mirror should not be a symlink " , file: file, line: line)
557+
558+ let markerURL = localURL. appendingPathComponent ( AppMigrationService . folderPortalMarkerName)
559+ XCTAssertTrue ( fileManager. fileExists ( atPath: markerURL. path) , " mirror marker should exist " , file: file, line: line)
560+ XCTAssertEqual (
561+ AppMigrationService . folderMirrorExternalURL ( at: localURL) ,
562+ externalURL. standardizedFileURL,
563+ " marker should record external folder path " ,
564+ file: file,
565+ line: line
566+ )
567+
568+ for name in stubAppNames {
569+ try assertStubPortal (
570+ localURL. appendingPathComponent ( name) ,
571+ pointsTo: externalURL. appendingPathComponent ( name) ,
572+ file: file,
573+ line: line
574+ )
575+ }
576+ }
577+
445578 private func assertRealAppBundle( _ appURL: URL , file: StaticString = #filePath, line: UInt = #line) throws {
446579 let appValues = try appURL. resourceValues ( forKeys: [ . isDirectoryKey, . isSymbolicLinkKey] )
447580 XCTAssertEqual ( appValues. isDirectory, true , file: file, line: line)
0 commit comments