From 60bc811bbae790887a18df59fdfca9dcea82057c Mon Sep 17 00:00:00 2001 From: "dongju.lim" Date: Thu, 8 Oct 2026 18:48:15 +0900 Subject: [PATCH 01/11] =?UTF-8?q?[20261008]=20=EB=B9=8C=EB=93=9C/=EC=84=A4?= =?UTF-8?q?=EC=A0=95:=20iOS=2015=EC=99=80=20Swift=206.4=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SnapKit과 Kingfisher 버전을 올리고 ProgressHUD 사내 포크를 연결한다. Swift 언어 모드는 5로 유지하고 플랫폼 하한을 의존성 요구에 맞춘다. --- Package.resolved | 20 ++++++++++---------- Package.swift | 13 ++++++------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/Package.resolved b/Package.resolved index f812e90..47aed91 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "3839c28c7c36e56406ba9aea91d421dbc1e398643006e1a6a34f9bd14292fb7a", + "originHash" : "04341a2b96c1d904ef034eb14dc9b909730e1d4381a9b6bed211bfe756a8bd17", "pins" : [ { "identity" : "alamofire", "kind" : "remoteSourceControl", "location" : "https://github.com/Alamofire/Alamofire.git", "state" : { - "revision" : "7595cbcf59809f9977c5f6378500de2ad73b7ddb", - "version" : "5.12.0" + "revision" : "bda9ed57d72988a3a2ada33d824583541f86eac6", + "version" : "5.12.2" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher", "state" : { - "revision" : "ac632bd26a1c00f139ff62fd01806f21cf67325e", - "version" : "8.10.0" + "revision" : "8648fe649c5d62be38721110f835821906e9242a", + "version" : "8.13.0" } }, { @@ -31,10 +31,10 @@ { "identity" : "progresshud", "kind" : "remoteSourceControl", - "location" : "https://github.com/relatedcode/ProgressHUD.git", + "location" : "https://github.com/uwiseone-ios/ProgressHUD", "state" : { - "revision" : "4aa638a88450d6d48a6620070722e875e7a179ef", - "version" : "14.1.4" + "branch" : "master", + "revision" : "a8b101d905f78178bef45c16122be0f16e54aa40" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SnapKit/SnapKit.git", "state" : { - "revision" : "2842e6e84e82eb9a8dac0100ca90d9444b0307f4", - "version" : "5.7.1" + "revision" : "e27a338a03a5f388de759da63f9baf7988ed9e00", + "version" : "6.0.0" } }, { diff --git a/Package.swift b/Package.swift index e9ae067..6d1c4ae 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.10 +// swift-tools-version: 6.4 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "CommonUtils", defaultLocalization: "ko", - platforms: [.iOS(.v13), .macOS(.v10_15), .tvOS(.v13), .watchOS(.v6)], + platforms: [.iOS(.v15), .macOS(.v12), .tvOS(.v15), .watchOS(.v9)], products: [ .library( name: "CommonUtils", @@ -15,12 +15,11 @@ let package = Package( dependencies: [ // 패키지 추가 .package(url: "https://github.com/Moya/Moya.git", .upToNextMajor(from: "15.0.3")), - .package(url: "https://github.com/SnapKit/SnapKit.git", .upToNextMajor(from: "5.7.1")), + .package(url: "https://github.com/SnapKit/SnapKit.git", .upToNextMajor(from: "6.0.0")), .package(url: "https://github.com/devxoul/Then", .upToNextMajor(from: "3.0.0")), - .package(url: "https://github.com/relatedcode/ProgressHUD.git", .upToNextMajor(from: "14.1.4")), - .package(url: "https://github.com/onevcat/Kingfisher", .upToNextMajor(from: "8.10.0")), + .package(url: "https://github.com/uwiseone-ios/ProgressHUD", branch: "master"), + .package(url: "https://github.com/onevcat/Kingfisher", .upToNextMajor(from: "8.13.0")), .package(url: "https://github.com/weichsel/ZIPFoundation", .upToNextMajor(from: "0.9.20")) - ], targets: [ .target( @@ -41,5 +40,5 @@ let package = Package( .process("TestSampleData") ]) ], - swiftLanguageVersions: [.v5] + swiftLanguageModes: [.v5] ) From a2bfe28198d28e8b40c59a1b1fb5b0a41463c9fe Mon Sep 17 00:00:00 2001 From: "dongju.lim" Date: Thu, 8 Oct 2026 18:48:26 +0900 Subject: [PATCH 02/11] =?UTF-8?q?[20261008]=20=EA=B8=B0=EB=8A=A5=EC=B6=94?= =?UTF-8?q?=EA=B0=80:=20=EA=B3=B5=ED=86=B5=20=EB=A1=9C=EA=B7=B8=EC=9D=98?= =?UTF-8?q?=20=EC=99=B8=EB=B6=80=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 명시적인 externalEventName이 있는 로그만 앱의 externalLogHandler로 전달한다. 기존 일반 로그와 핸들러 내부 재호출이 외부 이벤트로 재전달되지 않는지 테스트를 추가한다. --- Sources/Function/DebugLoggerManager.swift | 12 +++- .../ExternalLogHandlerTests.swift | 55 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 Tests/CommonUtilsTests/ExternalLogHandlerTests.swift diff --git a/Sources/Function/DebugLoggerManager.swift b/Sources/Function/DebugLoggerManager.swift index bf70ede..cee60ee 100644 --- a/Sources/Function/DebugLoggerManager.swift +++ b/Sources/Function/DebugLoggerManager.swift @@ -39,7 +39,12 @@ public func DebugLog(_ message: Any? = "", line: Int = #line, param: [String: Any] = [:], isDebugPrint: Bool = false, - isFileLog: Bool = true) { + isFileLog: Bool = true, + externalEventName: String? = nil) { + if let event = externalEventName { + DebugLoggerManager.externalLogHandler?(event, String(describing: message ?? ""), level, + param, file, funcName, line) + } if isDebugPrint { let fileName: String = (file as NSString).lastPathComponent var fullMessage = """ @@ -98,6 +103,11 @@ public final class DebugLoggerManager { public static let shared = DebugLoggerManager() + public typealias ExternalLogHandler = (String, String, DebugLogLevel, [String: Any], String, String, Int) -> Void + + /// 외부 기록이 지정된 로그를 앱에서 등록한 공통 처리기로 전달한다. + public static var externalLogHandler: ExternalLogHandler? + private let logDirectory: URL private let logQueue = DispatchQueue(label: "\(CommonUtils.getBundleIdentifier).debuglogger", qos: .utility) private let maxFileSize: UInt64 = 512 * 1024 // 512KB diff --git a/Tests/CommonUtilsTests/ExternalLogHandlerTests.swift b/Tests/CommonUtilsTests/ExternalLogHandlerTests.swift new file mode 100644 index 0000000..83252ad --- /dev/null +++ b/Tests/CommonUtilsTests/ExternalLogHandlerTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import CommonUtils + +final class ExternalLogHandlerTests: XCTestCase { + private var originalHandler: DebugLoggerManager.ExternalLogHandler? + + override func setUp() { + super.setUp() + originalHandler = DebugLoggerManager.externalLogHandler + } + + override func tearDown() { + DebugLoggerManager.externalLogHandler = originalHandler + super.tearDown() + } + + func testExplicitEventForwardsDetailsAndCallSiteWithFileLoggingDisabled() { + var called = false + DebugLoggerManager.externalLogHandler = { event, message, level, details, file, function, line in + called = true + XCTAssertEqual(event, "sample_diagnostic") + XCTAssertEqual(message, "진단 정보") + XCTAssertEqual(level, .warning) + XCTAssertEqual(details["attempt"] as? Int, 3) + XCTAssertEqual(file, "Feature.swift") + XCTAssertEqual(function, "retry()") + XCTAssertEqual(line, 42) + } + DebugLog("진단 정보", level: .warning, file: "Feature.swift", funcName: "retry()", line: 42, + param: ["attempt": 3], isFileLog: false, externalEventName: "sample_diagnostic") + XCTAssertTrue(called) + } + + func testExistingLogsAreNotForwardedWithoutExplicitEvent() { + var calls = 0 + DebugLoggerManager.externalLogHandler = { _, _, _, _, _, _, _ in calls += 1 } + DebugLog("기존 일반 로그", level: .error, isFileLog: false) + XCTAssertEqual(calls, 0) + } + + func testHandlerCanReuseExistingLogWithoutRecursiveForwarding() { + var calls = 0 + DebugLoggerManager.externalLogHandler = { _, message, level, _, _, _, _ in + calls += 1 + DebugLog(message, level: level, isFileLog: false) + } + DebugLog("외부 이벤트", isFileLog: false, externalEventName: "sample_diagnostic") + XCTAssertEqual(calls, 1) + } + + func testExplicitEventWithoutHandlerDoesNotInterruptCaller() { + DebugLoggerManager.externalLogHandler = nil + DebugLog("외부 이벤트", isFileLog: false, externalEventName: "sample_diagnostic") + } +} From 5e56d6cba637fbfbc4fdf19513b459e4fad5e62a Mon Sep 17 00:00:00 2001 From: "dongju.lim" Date: Thu, 8 Oct 2026 18:48:44 +0900 Subject: [PATCH 03/11] =?UTF-8?q?[20261008]=20=EB=B2=84=EA=B7=B8=EC=88=98?= =?UTF-8?q?=EC=A0=95:=20=EC=98=81=EC=83=81=20=EC=95=95=EC=B6=95=EC=9D=98?= =?UTF-8?q?=20=EB=8B=A4=EC=B1=84=EB=84=90=20=EC=98=A4=EB=94=94=EC=98=A4=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다채널 입력의 레이아웃과 AAC 지원 여부를 입력 생성 전에 검증하고 설정 거절을 오류로 반환한다. 오디오 입력 진단 정보를 외부 로그 이벤트로 전달하고 압축 통합 테스트의 실행 조건과 대기 시간을 정리한다. --- Sources/Function/CommonUtils.swift | 78 +++++++++++--- .../VideoCompressionAudioSettings.swift | 101 ++++++++++++++++++ Sources/Model/CommonUtilsModel.swift | 2 + .../CommonUtilsFunctionTests.swift | 17 +-- 4 files changed, 168 insertions(+), 30 deletions(-) create mode 100644 Sources/Function/VideoCompressionAudioSettings.swift diff --git a/Sources/Function/CommonUtils.swift b/Sources/Function/CommonUtils.swift index 7c521cb..2a903b0 100644 --- a/Sources/Function/CommonUtils.swift +++ b/Sources/Function/CommonUtils.swift @@ -211,8 +211,9 @@ public class CommonUtils { } } - if let audioTrack = asset.tracks(withMediaType: .audio).first { - let formatDescription = audioTrack.formatDescriptions.first as! CMAudioFormatDescription + if let audioTrack = asset.tracks(withMediaType: .audio).first, + let rawDescription = audioTrack.formatDescriptions.first { + let formatDescription = rawDescription as! CMAudioFormatDescription if let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(formatDescription)?.pointee { audioCodec = audioTrack.mediaType.rawValue audioSampleRate = asbd.mSampleRate @@ -244,7 +245,7 @@ public class CommonUtils { // 비디오 트랙 가져오기 guard let videoTrack = asset.tracks(withMediaType: .video).first else { - completion(.failure(CommonUtilCompressionError.exportSessionFailure("비디오 트랙을 찾을 수 없음"))) + completion(.failure(CommonUtilCompressionError.videoTrackNotFound)) return } @@ -269,8 +270,6 @@ public class CommonUtils { // 오디오 비트레이트 (비디오는 전체 비트레이트의 약 90%로 가정) let videoBitRate = targetBitRate * 0.9 - let audioSampleRate = originalVideoInfo.audioSampleRate ?? 44100 - let audioChannels = originalVideoInfo.audioChannels ?? 2 let audioBitRate: Float = 128_000 // 128 kbps (표준 오디오 품질) let estimatedFileSize = Int64((videoBitRate + audioBitRate) * Float(duration) / 8) // 예상 파일 사이즈 DebugLog("원본 비디오 정보: \(originalVideoInfo)") @@ -280,6 +279,47 @@ public class CommonUtils { DebugLog("원본 파일 사이즈: \(originalFileSize.toFileByteSting)") DebugLog("예상 파일 사이즈: \(estimatedFileSize.toFileByteSting)") + let sourceAudioTrack = asset.tracks(withMediaType: .audio).first + var audioConfiguration: VideoCompressionAudioSettings? + var audioDetails: [String: Any] = [ + "has_audio": sourceAudioTrack == nil ? 0 : 1, + "input_channels": originalVideoInfo.audioChannels ?? 0, + "sample_rate": originalVideoInfo.audioSampleRate.map { $0.isFinite ? $0 : 0 } ?? 0, + "input_format_id": 0, + "input_bytes": originalFileSize, + "duration_seconds": duration.isFinite ? duration : 0, + "quality": String(describing: quality), + "layout_source": "none", + "layout_bytes": 0, + "layout_tag": 0, + "status": sourceAudioTrack == nil ? "no_audio" : "ready" + ] + do { + if let sourceAudioTrack = sourceAudioTrack { + let formatDescription = sourceAudioTrack.formatDescriptions.first.map { $0 as! CMAudioFormatDescription } + if let formatDescription = formatDescription { + audioDetails["input_format_id"] = Int(CMFormatDescriptionGetMediaSubType(formatDescription)) + } + let configuration = try VideoCompressionAudioSettings.prepare(formatDescription: formatDescription) + audioConfiguration = configuration + audioDetails["layout_source"] = configuration.layoutSource + audioDetails["output_channels"] = configuration.outputSettings[AVNumberOfChannelsKey] + audioDetails["output_sample_rate"] = configuration.outputSettings[AVSampleRateKey] + audioDetails["uses_default_format"] = configuration.usesDefaultFormat ? 1 : 0 + if let layout = configuration.outputSettings[AVChannelLayoutKey] as? Data { + audioDetails["layout_bytes"] = layout.count + audioDetails["layout_tag"] = Int(layout.withUnsafeBytes { $0.loadUnaligned(as: UInt32.self) }) + } + } + } catch { + audioDetails["status"] = "invalid_audio_settings" + VideoCompressionAudioSettings.reportInput(audioDetails) + completion(.failure(error)) + return + } + // 정상 입력도 기록하여 다채널 입력의 발생 비율을 집계한다. + VideoCompressionAudioSettings.reportInput(audioDetails) + // 최대 파일 크기 제한이 있는 경우, 예상 파일 크기 미리 계산 if let maxFileSize = maxFileSize { if estimatedFileSize > maxFileSize { @@ -363,20 +403,28 @@ public class CommonUtils { // 오디오 설정 및 입력 추가 (오디오 트랙이 있는 경우) var audioWriterInput: AVAssetWriterInput? - if audioTrackExists { - let audioSettings: [String: Any] = [ - AVFormatIDKey: kAudioFormatMPEG4AAC, - AVNumberOfChannelsKey: audioChannels, - AVSampleRateKey: audioSampleRate, - AVEncoderBitRateKey: audioBitRate - ] - + if let audioConfiguration = audioConfiguration { + let audioSettings = audioConfiguration.outputSettings + let channels = audioSettings[AVNumberOfChannelsKey] as? Int ?? 0 + guard channels <= 2 || VideoCompressionAudioSettings.supportsAACChannelCount(channels), + assetWriter.canApply(outputSettings: audioSettings, forMediaType: .audio) else { + audioDetails["status"] = "unsupported_audio_settings" + DebugLog("오디오 설정을 사용할 수 없음", level: .error, param: audioDetails, isFileLog: false, + externalEventName: "video_compression_audio_rejected") + completion(.failure(CommonUtilCompressionError.unsupportedAudioFormat("AAC 출력으로 사용할 수 없는 오디오 설정"))) + return + } audioWriterInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) audioWriterInput?.expectsMediaDataInRealTime = false - if let audioInput = audioWriterInput, assetWriter.canAdd(audioInput) { - assetWriter.add(audioInput) + guard let audioInput = audioWriterInput, assetWriter.canAdd(audioInput) else { + audioDetails["status"] = "audio_input_rejected" + DebugLog("오디오 설정을 사용할 수 없음", level: .error, param: audioDetails, isFileLog: false, + externalEventName: "video_compression_audio_rejected") + completion(.failure(CommonUtilCompressionError.unsupportedAudioFormat("오디오 입력을 추가할 수 없음"))) + return } + assetWriter.add(audioInput) } // 에셋 리더 설정 diff --git a/Sources/Function/VideoCompressionAudioSettings.swift b/Sources/Function/VideoCompressionAudioSettings.swift new file mode 100644 index 0000000..40013c8 --- /dev/null +++ b/Sources/Function/VideoCompressionAudioSettings.swift @@ -0,0 +1,101 @@ +import AVFoundation +import AudioToolbox + +struct VideoCompressionAudioSettings { + let outputSettings: [String: Any] + let layoutSource: String + let usesDefaultFormat: Bool + + /// 부족한 형식 정보는 기존 기본값으로 보완하고, 다채널에 필수인 레이아웃을 검증한다. + static func prepare(formatDescription: CMAudioFormatDescription?) throws -> VideoCompressionAudioSettings { + let description = formatDescription.flatMap { + CMAudioFormatDescriptionGetStreamBasicDescription($0)?.pointee + } + var layoutSize = 0 + let sourceLayout = formatDescription.flatMap { format -> Data? in + guard let layout = CMAudioFormatDescriptionGetChannelLayout(format, sizeOut: &layoutSize), + layoutSize > 0 else { return nil } + return Data(bytes: layout, count: layoutSize) + } + return try prepare(channels: description?.mChannelsPerFrame ?? 0, + sampleRate: description?.mSampleRate ?? 0, channelLayout: sourceLayout) + } + + static func prepare(channels sourceChannels: UInt32, sampleRate sourceSampleRate: Double, + channelLayout: Data? = nil) throws -> VideoCompressionAudioSettings { + let channels = sourceChannels > 0 ? Int(sourceChannels) : 2 + let validSampleRate = sourceSampleRate.isFinite && sourceSampleRate > 0 + let usesDefaultFormat = sourceChannels == 0 || !validSampleRate + var settings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVNumberOfChannelsKey: channels, + AVSampleRateKey: validSampleRate ? sourceSampleRate : 44100.0, + AVEncoderBitRateKey: 128_000 + ] + var hasInvalidLayout = false + if let data = channelLayout { + var layoutChannels: UInt32 = 0 + var channelsSize = UInt32(MemoryLayout.size) + let status = data.withUnsafeBytes { + AudioFormatGetProperty(kAudioFormatProperty_NumberOfChannelsForLayout, + UInt32(data.count), $0.baseAddress, &channelsSize, &layoutChannels) + } + if status == noErr, layoutChannels == UInt32(channels) { + settings[AVChannelLayoutKey] = data + return VideoCompressionAudioSettings(outputSettings: settings, layoutSource: "source", + usesDefaultFormat: usesDefaultFormat) + } + // 모노·스테레오는 표준 구성을 사용하고, 실제 다채널 구성은 임의로 바꾸지 않는다. + guard channels <= 2 else { + throw CommonUtilCompressionError.unsupportedAudioFormat("오디오 채널 수와 레이아웃이 일치하지 않음") + } + hasInvalidLayout = true + } + + if channels == 2 { + var layout = AudioChannelLayout() + layout.mChannelLayoutTag = kAudioChannelLayoutTag_Stereo + settings[AVChannelLayoutKey] = Data(bytes: &layout, count: MemoryLayout.size) + return VideoCompressionAudioSettings(outputSettings: settings, + layoutSource: hasInvalidLayout ? "stereo_fallback" : "stereo", + usesDefaultFormat: usesDefaultFormat) + } + guard channels == 1 else { + throw CommonUtilCompressionError.unsupportedAudioFormat("다채널 오디오의 채널 레이아웃을 찾을 수 없음") + } + return VideoCompressionAudioSettings(outputSettings: settings, + layoutSource: hasInvalidLayout ? "mono_fallback" : "implicit_mono", + usesDefaultFormat: usesDefaultFormat) + } + + /// 전체 입력과 다채널·설정 거절을 구분하여 공통 외부 로그 핸들러에 전달한다. + static func reportInput(_ details: [String: Any]) { + DebugLog("동영상 압축 오디오 입력", param: details, isFileLog: false, + externalEventName: "video_compression_audio") + if (details["input_channels"] as? Int ?? 0) > 2 { + DebugLog("다채널 오디오 입력", level: .warning, param: details, isFileLog: false, + externalEventName: "video_compression_multichannel") + } + if details["status"] as? String == "invalid_audio_settings" { + DebugLog("오디오 설정을 사용할 수 없음", level: .error, param: details, isFileLog: false, + externalEventName: "video_compression_audio_rejected") + } + } + + /// AAC 인코더가 지원하지 않는 채널 수를 입력 생성 전에 거절한다. + static func supportsAACChannelCount(_ channels: Int) -> Bool { + guard channels > 0, channels <= Int(UInt32.max) else { return false } + var description = AudioStreamBasicDescription() + description.mFormatID = kAudioFormatMPEG4AAC + let descriptionSize = UInt32(MemoryLayout.size) + var size: UInt32 = 0 + guard AudioFormatGetPropertyInfo(kAudioFormatProperty_AvailableEncodeNumberChannels, + descriptionSize, &description, &size) == noErr, size > 0 else { return false } + var counts = [UInt32](repeating: 0, count: Int(size) / MemoryLayout.size) + let status = counts.withUnsafeMutableBytes { + AudioFormatGetProperty(kAudioFormatProperty_AvailableEncodeNumberChannels, + descriptionSize, &description, &size, $0.baseAddress) + } + return status == noErr && (counts.contains(UInt32(channels)) || counts.contains(UInt32.max)) + } +} diff --git a/Sources/Model/CommonUtilsModel.swift b/Sources/Model/CommonUtilsModel.swift index 582e531..0bceef2 100644 --- a/Sources/Model/CommonUtilsModel.swift +++ b/Sources/Model/CommonUtilsModel.swift @@ -69,6 +69,8 @@ public struct CommonUtilVideoInfo { } public enum CommonUtilCompressionError: Error { + case videoTrackNotFound + case unsupportedAudioFormat(String) case exportSessionFailure(String) case unknownError case minimumQualityExceedsMaxFileSize(Int64) diff --git a/Tests/CommonUtilsTests/CommonUtilsFunctionTests.swift b/Tests/CommonUtilsTests/CommonUtilsFunctionTests.swift index 6c95ff3..3360b08 100644 --- a/Tests/CommonUtilsTests/CommonUtilsFunctionTests.swift +++ b/Tests/CommonUtilsTests/CommonUtilsFunctionTests.swift @@ -149,13 +149,6 @@ final class VideoCompressionTests: XCTestCase { var inputVideoURL: URL! var outputVideoURL: URL! - private func skipVideoCompressionIntegrationTestInDefaultCI() throws { - if ProcessInfo.processInfo.environment["CI"] == "true", - ProcessInfo.processInfo.environment["RUN_VIDEO_COMPRESSION_TESTS"] != "1" { - throw XCTSkip("CI 기본 테스트에서는 비디오 압축 통합 테스트를 별도 job으로 분리합니다.") - } - } - // MARK: - Helper to load video URL from bundle or temp private func loadTestVideo(named name: String, withExtension ext: String = "mp4") -> URL? { return Bundle.module.url(forResource: name, withExtension: ext) @@ -187,8 +180,6 @@ final class VideoCompressionTests: XCTestCase { // MARK: - 기본 압축 성공 케이스 func testCompressVideo_Success() throws { - try skipVideoCompressionIntegrationTestInDefaultCI() - let expectation = XCTestExpectation(description: "비디오 압축 성공") CommonUtils.compressVideo(inputURL: inputVideoURL, @@ -206,13 +197,11 @@ final class VideoCompressionTests: XCTestCase { expectation.fulfill() } - wait(for: [expectation], timeout: 30) + wait(for: [expectation], timeout: 60) } // MARK: - 너무 작은 maxFileSize로 인해 압축 실패 func testCompressVideo_TooSmallMaxFileSize_ShouldFail() throws { - try skipVideoCompressionIntegrationTestInDefaultCI() - let expectation = XCTestExpectation(description: "너무 작은 파일 크기로 압축 실패") CommonUtils.compressVideo(inputURL: inputVideoURL, @@ -234,8 +223,6 @@ final class VideoCompressionTests: XCTestCase { // MARK: - 오디오 없는 비디오도 정상 압축 처리 func testCompressVideo_NoAudioTrack_Success() throws { - try skipVideoCompressionIntegrationTestInDefaultCI() - // 오디오 트랙이 없는 샘플 파일을 미리 프로젝트에 포함시켜야 함 (예: "no_audio.mp4") guard let videoPath = loadTestVideo(named: "video_no_audio") else { XCTFail("video_no_audio.mp4 파일이 필요합니다") @@ -260,7 +247,7 @@ final class VideoCompressionTests: XCTestCase { expectation.fulfill() } - wait(for: [expectation], timeout: 90) + wait(for: [expectation], timeout: 30) } // MARK: - validateHTML Tests From ca4cefeeb203a051f511e83f0d35a8c725fc3315 Mon Sep 17 00:00:00 2001 From: "dongju.lim" Date: Thu, 8 Oct 2026 18:49:09 +0900 Subject: [PATCH 04/11] =?UTF-8?q?[20261008]=20=EA=B8=B0=EB=8A=A5=EC=B6=94?= =?UTF-8?q?=EA=B0=80:=20=EA=B3=B5=EC=9C=A0=20Data=EC=9D=98=20=EC=9E=84?= =?UTF-8?q?=EC=8B=9C=20=ED=8C=8C=EC=9D=BC=20=EC=A4=80=EB=B9=84=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 판별 가능한 공유 Data를 세션별 임시 파일로 준비하는 내부 유틸리티를 추가한다. 항목 순서 보존, 변환 실패 시 원본 유지, 세션 정리와 해제 동작을 테스트한다. 공유 시트 연결은 이 변경에 포함하지 않는다. --- .../Function/ActivityShareItemPreparer.swift | 197 +++++++++++ .../ActivityShareItemPreparerTests.swift | 308 ++++++++++++++++++ 2 files changed, 505 insertions(+) create mode 100644 Sources/Function/ActivityShareItemPreparer.swift create mode 100644 Tests/CommonUtilsTests/ActivityShareItemPreparerTests.swift diff --git a/Sources/Function/ActivityShareItemPreparer.swift b/Sources/Function/ActivityShareItemPreparer.swift new file mode 100644 index 0000000..c9cbc1f --- /dev/null +++ b/Sources/Function/ActivityShareItemPreparer.swift @@ -0,0 +1,197 @@ +// +// ActivityShareItemPreparer.swift +// CommonUtils +// + +import Foundation + +enum ActivityShareEnvironment { + static var shouldMaterializeData: Bool { + #if os(iOS) && !targetEnvironment(macCatalyst) && !targetEnvironment(simulator) + if #available(iOS 14.0, *) { + return ProcessInfo.processInfo.isiOSAppOnMac + } + #endif + + return false + } +} + +final class ActivityShareItemPreparer { + typealias DataWriter = (Data, URL) throws -> Void + + let activityItems: [Any] + + var temporaryDirectoryURL: URL? { + cleanupLock.lock() + defer { cleanupLock.unlock() } + return storedTemporaryDirectoryURL + } + + private let fileManager: FileManager + private let cleanupLock = NSLock() + private var storedTemporaryDirectoryURL: URL? + + private init( + activityItems: [Any], + temporaryDirectoryURL: URL?, + fileManager: FileManager + ) { + self.activityItems = activityItems + self.storedTemporaryDirectoryURL = temporaryDirectoryURL + self.fileManager = fileManager + } + + deinit { + cleanup() + } + + static func requiresPreparation( + activityItems: [Any], + shouldMaterializeData: Bool + ) -> Bool { + guard shouldMaterializeData else { return false } + + return activityItems.contains { item in + guard let data = data(from: item) else { return false } + return !data.isEmpty + } + } + + /// 판별 가능한 Data를 공유 세션 전용 임시 파일로 변환합니다. + static func prepare( + activityItems: [Any], + shouldMaterializeData: Bool, + fileManager: FileManager = .default, + temporaryDirectory: URL? = nil, + dataWriter: DataWriter? = nil + ) -> ActivityShareItemPreparer { + guard shouldMaterializeData else { + return ActivityShareItemPreparer( + activityItems: activityItems, + temporaryDirectoryURL: nil, + fileManager: fileManager + ) + } + + let rootDirectory = temporaryDirectory ?? fileManager.temporaryDirectory + let writer = dataWriter ?? { data, fileURL in + try data.write(to: fileURL, options: .atomic) + } + var sessionDirectoryURL: URL? + var preparedItems = [Any]() + preparedItems.reserveCapacity(activityItems.count) + + for (index, item) in activityItems.enumerated() { + guard let data = data(from: item), + !data.isEmpty, + let mimeType = data.mimeType, + isSafeToMaterialize(mimeType.type) else { + preparedItems.append(item) + continue + } + + do { + let directoryURL: URL + if let sessionDirectoryURL { + directoryURL = sessionDirectoryURL + } else { + directoryURL = try createSessionDirectory( + in: rootDirectory, + fileManager: fileManager + ) + sessionDirectoryURL = directoryURL + } + + let fileURL = directoryURL + .appendingPathComponent("share-\(index + 1)") + .appendingPathExtension(mimeType.ext) + try writer(data, fileURL) + preparedItems.append(fileURL) + } catch { + DebugLog( + "공유 항목 임시 파일 변환 실패", + level: .error, + param: [ + "index": index, + "mimeType": mimeType.mime, + "fileExtension": mimeType.ext, + "error": error.localizedDescription + ], + isDebugPrint: true, + isFileLog: false + ) + preparedItems.append(item) + } + } + + return ActivityShareItemPreparer( + activityItems: preparedItems, + temporaryDirectoryURL: sessionDirectoryURL, + fileManager: fileManager + ) + } + + /// 공유 세션에서 만든 임시 디렉터리만 정리합니다. + func cleanup() { + cleanupLock.lock() + let directoryURL = storedTemporaryDirectoryURL + storedTemporaryDirectoryURL = nil + cleanupLock.unlock() + + guard let directoryURL else { return } + + do { + try fileManager.removeItem(at: directoryURL) + } catch CocoaError.fileNoSuchFile { + return + } catch { + DebugLog( + "공유 임시 디렉터리 정리 실패", + level: .error, + param: ["error": error.localizedDescription], + isDebugPrint: true, + isFileLog: false + ) + } + } +} + +private extension ActivityShareItemPreparer { + static func data(from item: Any) -> Data? { + if let data = item as? Data { + return data + } + if let data = item as? NSData { + return data as Data + } + return nil + } + + static func createSessionDirectory( + in temporaryDirectory: URL, + fileManager: FileManager + ) throws -> URL { + let directoryURL = temporaryDirectory + .appendingPathComponent("CommonUtilsShare", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + + try fileManager.createDirectory( + at: directoryURL, + withIntermediateDirectories: true + ) + return directoryURL + } + + static func isSafeToMaterialize(_ fileType: FileType) -> Bool { + switch fileType { + case .jpg, .png, .gif, .heic, .heif, .webp, .bmp, .tif, + .pdf, .rtf, + .mp3, .aac, .wav, .m4a, + .mp4, .mov, .m4v: + return true + default: + return false + } + } +} diff --git a/Tests/CommonUtilsTests/ActivityShareItemPreparerTests.swift b/Tests/CommonUtilsTests/ActivityShareItemPreparerTests.swift new file mode 100644 index 0000000..2326aa4 --- /dev/null +++ b/Tests/CommonUtilsTests/ActivityShareItemPreparerTests.swift @@ -0,0 +1,308 @@ +// +// ActivityShareItemPreparerTests.swift +// CommonUtilsTests +// + +import XCTest +import UIKit +@testable import CommonUtils + +final class ActivityShareItemPreparerTests: XCTestCase { + private var testTemporaryDirectory: URL! + + override func setUpWithError() throws { + try super.setUpWithError() + + testTemporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("ActivityShareItemPreparerTests-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: testTemporaryDirectory, + withIntermediateDirectories: true + ) + } + + override func tearDownWithError() throws { + if FileManager.default.fileExists(atPath: testTemporaryDirectory.path) { + try FileManager.default.removeItem(at: testTemporaryDirectory) + } + testTemporaryDirectory = nil + + try super.tearDownWithError() + } + + func testSupportedDataCreatesFilesWithDetectedExtensionsAndOriginalContents() throws { + let samples = [ + (name: "sample", extension: "jpg", expectedExtension: "jpg"), + (name: "sample", extension: "jpeg", expectedExtension: "jpg"), + (name: "sample", extension: "png", expectedExtension: "png"), + (name: "sample", extension: "gif", expectedExtension: "gif"), + (name: "sample", extension: "heic", expectedExtension: "heic"), + (name: "video_720p", extension: "mp4", expectedExtension: "mp4") + ] + + for sample in samples { + let originalData = try loadTestData( + named: sample.name, + extension: sample.extension + ) + let preparer = makePreparer(activityItems: [originalData]) + defer { preparer.cleanup() } + + let fileURL = try XCTUnwrap(preparer.activityItems.first as? URL) + XCTAssertTrue(fileURL.isFileURL) + XCTAssertEqual(fileURL.pathExtension, sample.expectedExtension) + XCTAssertEqual(fileURL.pathExtension, originalData.mimeType?.ext) + XCTAssertEqual(try Data(contentsOf: fileURL), originalData) + } + } + + func testMultipleDataPreservesCountAndOrder() throws { + let jpegData = try loadTestData(named: "sample", extension: "jpg") + let pngData = try loadTestData(named: "sample", extension: "png") + let gifData = try loadTestData(named: "sample", extension: "gif") + let preparer = makePreparer(activityItems: [jpegData, pngData, gifData]) + defer { preparer.cleanup() } + + XCTAssertEqual(preparer.activityItems.count, 3) + let fileURLs = try preparer.activityItems.map { try XCTUnwrap($0 as? URL) } + XCTAssertEqual(fileURLs.map(\.pathExtension), ["jpg", "png", "gif"]) + XCTAssertEqual( + try fileURLs.map { try Data(contentsOf: $0) }, + [jpegData, pngData, gifData] + ) + } + + func testSameTypeDataCreatesDifferentFileURLs() throws { + let jpegData = try loadTestData(named: "sample", extension: "jpg") + let preparer = makePreparer(activityItems: [jpegData, jpegData]) + defer { preparer.cleanup() } + + let firstURL = try XCTUnwrap(preparer.activityItems[0] as? URL) + let secondURL = try XCTUnwrap(preparer.activityItems[1] as? URL) + + XCTAssertNotEqual(firstURL, secondURL) + XCTAssertEqual(firstURL.lastPathComponent, "share-1.jpg") + XCTAssertEqual(secondURL.lastPathComponent, "share-2.jpg") + } + + func testNSDataCreatesFileURL() throws { + let jpegData = try loadTestData(named: "sample", extension: "jpg") + let nsData = jpegData as NSData + let preparer = makePreparer(activityItems: [nsData]) + defer { preparer.cleanup() } + + let fileURL = try XCTUnwrap(preparer.activityItems.first as? URL) + XCTAssertEqual(try Data(contentsOf: fileURL), jpegData) + } + + func testNonDataItemsRemainUnchanged() throws { + let string = "공유 텍스트" + let nsString = NSMutableString(string: "NSString 텍스트") + let attributedString = NSAttributedString(string: "서식 텍스트") + let webURL = try XCTUnwrap(URL(string: "https://example.com/share")) + let existingFileURL = testTemporaryDirectory.appendingPathComponent("existing.txt") + try Data("existing".utf8).write(to: existingFileURL) + let image = UIImage() + let items: [Any] = [ + string, + nsString, + attributedString, + webURL, + existingFileURL, + image + ] + + let preparer = makePreparer(activityItems: items) + + XCTAssertEqual(preparer.activityItems.count, items.count) + XCTAssertEqual(preparer.activityItems[0] as? String, string) + XCTAssertTrue(preparer.activityItems[1] as? NSString === nsString) + XCTAssertTrue(preparer.activityItems[2] as? NSAttributedString === attributedString) + XCTAssertEqual(preparer.activityItems[3] as? URL, webURL) + XCTAssertEqual(preparer.activityItems[4] as? URL, existingFileURL) + XCTAssertTrue(preparer.activityItems[5] as? UIImage === image) + XCTAssertNil(preparer.temporaryDirectoryURL) + } + + func testUnknownDataRemainsOriginalData() { + let unknownData = Data([0x00, 0x01, 0x02, 0x03]) + let preparer = makePreparer(activityItems: [unknownData]) + + XCTAssertEqual(preparer.activityItems.first as? Data, unknownData) + XCTAssertNil(preparer.temporaryDirectoryURL) + } + + func testEmptyDataRemainsOriginalData() { + let emptyData = Data() + let preparer = makePreparer(activityItems: [emptyData]) + + XCTAssertEqual(preparer.activityItems.first as? Data, emptyData) + XCTAssertNil(preparer.temporaryDirectoryURL) + } + + func testZipDataIsNotMaterialized() { + let zipData = Data( + [0x50, 0x4B, 0x03, 0x04] + Array(repeating: 0x00, count: 46) + ) + let preparer = makePreparer(activityItems: [zipData]) + + XCTAssertEqual(preparer.activityItems.first as? Data, zipData) + XCTAssertNil(preparer.temporaryDirectoryURL) + } + + func testDisabledMaterializationKeepsSupportedDataUnchanged() throws { + let jpegData = try loadTestData(named: "sample", extension: "jpg") + let preparer = makePreparer( + activityItems: [jpegData], + shouldMaterializeData: false + ) + + XCTAssertEqual(preparer.activityItems.first as? Data, jpegData) + XCTAssertNil(preparer.temporaryDirectoryURL) + } + + func testMixedItemsPreserveCountAndOrder() throws { + let text = "text" + let jpegData = try loadTestData(named: "sample", extension: "jpg") + let webURL = try XCTUnwrap(URL(string: "https://example.com")) + let pngData = try loadTestData(named: "sample", extension: "png") + let existingFileURL = testTemporaryDirectory.appendingPathComponent("existing.txt") + try Data("existing".utf8).write(to: existingFileURL) + let preparer = makePreparer( + activityItems: [text, jpegData, webURL, pngData, existingFileURL] + ) + defer { preparer.cleanup() } + + XCTAssertEqual(preparer.activityItems.count, 5) + XCTAssertEqual(preparer.activityItems[0] as? String, text) + XCTAssertEqual((preparer.activityItems[1] as? URL)?.pathExtension, "jpg") + XCTAssertEqual(preparer.activityItems[2] as? URL, webURL) + XCTAssertEqual((preparer.activityItems[3] as? URL)?.pathExtension, "png") + XCTAssertEqual(preparer.activityItems[4] as? URL, existingFileURL) + } + + func testCleanupRemovesSessionDirectoryAndIsIdempotent() throws { + let jpegData = try loadTestData(named: "sample", extension: "jpg") + let preparer = makePreparer(activityItems: [jpegData]) + let directoryURL = try XCTUnwrap(preparer.temporaryDirectoryURL) + let fileURL = try XCTUnwrap(preparer.activityItems.first as? URL) + + XCTAssertTrue(FileManager.default.fileExists(atPath: directoryURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: fileURL.path)) + + preparer.cleanup() + preparer.cleanup() + + XCTAssertFalse(FileManager.default.fileExists(atPath: directoryURL.path)) + XCTAssertNil(preparer.temporaryDirectoryURL) + } + + func testWriteFailureKeepsFailedItemAndPreservesSuccessfulItem() throws { + enum TestError: Error { + case writeFailed + } + + let jpegData = try loadTestData(named: "sample", extension: "jpg") + let pngData = try loadTestData(named: "sample", extension: "png") + let preparer = makePreparer( + activityItems: [jpegData, pngData], + dataWriter: { data, fileURL in + if fileURL.lastPathComponent == "share-1.jpg" { + throw TestError.writeFailed + } + try data.write(to: fileURL, options: .atomic) + } + ) + defer { preparer.cleanup() } + + XCTAssertEqual(preparer.activityItems[0] as? Data, jpegData) + let pngFileURL = try XCTUnwrap(preparer.activityItems[1] as? URL) + XCTAssertEqual(try Data(contentsOf: pngFileURL), pngData) + } + + func testDeinitRemovesSessionDirectory() throws { + let jpegData = try loadTestData(named: "sample", extension: "jpg") + weak var weakPreparer: ActivityShareItemPreparer? + var directoryURL: URL? + + autoreleasepool { + let preparer = makePreparer(activityItems: [jpegData]) + weakPreparer = preparer + directoryURL = preparer.temporaryDirectoryURL + } + + XCTAssertNil(weakPreparer) + let unwrappedDirectoryURL = try XCTUnwrap(directoryURL) + XCTAssertFalse(FileManager.default.fileExists(atPath: unwrappedDirectoryURL.path)) + } + + func testCleanupDoesNotRemoveAnotherSessionDirectory() throws { + let jpegData = try loadTestData(named: "sample", extension: "jpg") + let firstPreparer = makePreparer(activityItems: [jpegData]) + let secondPreparer = makePreparer(activityItems: [jpegData]) + defer { + firstPreparer.cleanup() + secondPreparer.cleanup() + } + + let firstDirectoryURL = try XCTUnwrap(firstPreparer.temporaryDirectoryURL) + let secondDirectoryURL = try XCTUnwrap(secondPreparer.temporaryDirectoryURL) + XCTAssertNotEqual(firstDirectoryURL, secondDirectoryURL) + + firstPreparer.cleanup() + + XCTAssertFalse(FileManager.default.fileExists(atPath: firstDirectoryURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: secondDirectoryURL.path)) + } + + func testPreparationRequirementUsesInjectedMaterializationFlag() throws { + let jpegData = try loadTestData(named: "sample", extension: "jpg") + + XCTAssertTrue( + ActivityShareItemPreparer.requiresPreparation( + activityItems: [jpegData], + shouldMaterializeData: true + ) + ) + XCTAssertFalse( + ActivityShareItemPreparer.requiresPreparation( + activityItems: [jpegData], + shouldMaterializeData: false + ) + ) + XCTAssertFalse( + ActivityShareItemPreparer.requiresPreparation( + activityItems: ["text", Data()], + shouldMaterializeData: true + ) + ) + } + + #if targetEnvironment(simulator) + func testSimulatorDoesNotMaterializeDataInCurrentEnvironment() { + XCTAssertFalse(ActivityShareEnvironment.shouldMaterializeData) + } + #endif +} + +private extension ActivityShareItemPreparerTests { + func loadTestData(named name: String, extension fileExtension: String) throws -> Data { + let fileURL = try XCTUnwrap( + Bundle.module.url(forResource: name, withExtension: fileExtension) + ) + return try Data(contentsOf: fileURL) + } + + func makePreparer( + activityItems: [Any], + shouldMaterializeData: Bool = true, + dataWriter: ActivityShareItemPreparer.DataWriter? = nil + ) -> ActivityShareItemPreparer { + ActivityShareItemPreparer.prepare( + activityItems: activityItems, + shouldMaterializeData: shouldMaterializeData, + temporaryDirectory: testTemporaryDirectory, + dataWriter: dataWriter + ) + } +} From faf61fa1083f39b30163767477f3794d3fcf1e6c Mon Sep 17 00:00:00 2001 From: "dongju.lim" Date: Thu, 8 Oct 2026 18:50:42 +0900 Subject: [PATCH 05/11] =?UTF-8?q?[20261008]=20=EA=B8=B0=EB=8A=A5=EC=B6=94?= =?UTF-8?q?=EA=B0=80:=20UI=20=EB=82=B4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98?= =?UTF-8?q?=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=8A=A4=ED=83=80=EC=9D=BC?= =?UTF-8?q?=EA=B3=BC=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=A0=95=EB=A0=AC=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 명시적으로 활성화한 iOS 26 이상 경로에서 시스템 버튼을 생성하고 색상, target/action과 메뉴를 보존한다. 내비게이션 배경 스타일과 아이콘 중앙 정렬, 관련 지역화와 회귀 테스트를 추가한다. --- Sources/Extensions/UIButton+Extension.swift | 22 ++ Sources/Extensions/UIImage+Extension.swift | 36 ++++ .../UINavigationBar+Extension.swift | 45 ++-- .../Model/CommonNavigationAppearance.swift | 52 +++++ Sources/Resources/Localizable.xcstrings | 34 +++ .../NavigationAppearanceTests.swift | 200 ++++++++++++++++++ 6 files changed, 365 insertions(+), 24 deletions(-) create mode 100644 Sources/Model/CommonNavigationAppearance.swift create mode 100644 Tests/CommonUtilsTests/NavigationAppearanceTests.swift diff --git a/Sources/Extensions/UIButton+Extension.swift b/Sources/Extensions/UIButton+Extension.swift index 16e70fe..8bfd2ee 100644 --- a/Sources/Extensions/UIButton+Extension.swift +++ b/Sources/Extensions/UIButton+Extension.swift @@ -60,6 +60,11 @@ public extension UIBarButtonItem { target: Any, action: Selector, buttonSize: CGFloat = 26) -> UIBarButtonItem { + if CommonNavigationAppearance.usesSystemButtons { + let item = UIBarButtonItem(image: navigationImage(image), style: .plain, target: target, action: action) + item.tintColor = CommonNavigationAppearance.buttonTintColor + return item + } let button = UIButton(type: .custom) button.setImage(image, for: .normal) button.addTarget(target, action: action, for: .touchUpInside) @@ -76,6 +81,11 @@ public extension UIBarButtonItem { static func createUIBarButtonItem(image: UIImage?, menu: UIMenu, buttonSize: CGFloat = 26) -> UIBarButtonItem { + if CommonNavigationAppearance.usesSystemButtons { + let item = UIBarButtonItem(image: navigationImage(image), menu: menu) + item.tintColor = CommonNavigationAppearance.buttonTintColor + return item + } let button = UIButton(type: .custom) button.setImage(image, for: .normal) if #available(iOS 14.0, *) { @@ -95,12 +105,24 @@ public extension UIBarButtonItem { return UIBarButtonItem(customView: button) } + private static func navigationImage(_ image: UIImage?) -> UIImage? { + return image?.centeredNavigationIcon().withRenderingMode(.alwaysTemplate) + } + static func buttonToBarButtonItem(_ button: UIButton) -> UIBarButtonItem { if #available(iOS 15.0, *) { var configuration = UIButton.Configuration.plain() configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) button.configuration = configuration } + if CommonNavigationAppearance.usesSystemButtons { + button.tintColor = CommonNavigationAppearance.buttonTintColor + for state: UIControl.State in [.normal, .selected, .highlighted, .disabled] { + if let image = button.image(for: state) { + button.setImage(navigationImage(image), for: state) + } + } + } return UIBarButtonItem(customView: button) } } diff --git a/Sources/Extensions/UIImage+Extension.swift b/Sources/Extensions/UIImage+Extension.swift index be96636..5214c3a 100644 --- a/Sources/Extensions/UIImage+Extension.swift +++ b/Sources/Extensions/UIImage+Extension.swift @@ -9,6 +9,42 @@ import Foundation import UIKit public extension UIImage { + /// 아이콘의 투명 여백을 제외한 도형을 원래 캔버스 중앙으로 옮긴다. + /// SF Symbols의 시스템 정렬 정보와 원본 크기·배율은 유지한다. + func centeredNavigationIcon() -> UIImage { + guard !isSymbolImage, imageOrientation == .up, let cgImage else { return self } + let width = cgImage.width + let height = cgImage.height + var pixels = [UInt8](repeating: 0, count: width * height * 4) + let drawn = pixels.withUnsafeMutableBytes { buffer -> Bool in + guard let context = CGContext(data: buffer.baseAddress, width: width, height: height, + bitsPerComponent: 8, bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return false } + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + return true + } + guard drawn else { return self } + var minX = width, minY = height, maxX = -1, maxY = -1 + for y in 0.. 0 { + minX = min(minX, x) + minY = min(minY, y) + maxX = max(maxX, x) + maxY = max(maxY, y) + } + } + guard maxX >= minX, maxY >= minY else { return self } + let offset = CGPoint(x: CGFloat(width - minX - maxX - 1) / (2 * scale), + y: CGFloat(height - minY - maxY - 1) / (2 * scale)) + guard offset != .zero else { return self } + let format = UIGraphicsImageRendererFormat() + format.scale = scale + return UIGraphicsImageRenderer(size: size, format: format).image { _ in + draw(at: offset) + }.withRenderingMode(renderingMode) + } + static func getSFSymbolImage(name: String, size: CGFloat, weight: UIImage.SymbolWeight, color: UIColor) -> UIImage? { let config = UIImage.SymbolConfiguration(pointSize: size, weight: weight) let image = UIImage(systemName: name, withConfiguration: config)?.withTintColor(color) diff --git a/Sources/Extensions/UINavigationBar+Extension.swift b/Sources/Extensions/UINavigationBar+Extension.swift index f0f0faa..3b64ded 100644 --- a/Sources/Extensions/UINavigationBar+Extension.swift +++ b/Sources/Extensions/UINavigationBar+Extension.swift @@ -14,34 +14,23 @@ public extension UINavigationBar { fontColor foregroundColor: UIColor, font: UIFont, largeFont: UIFont? = nil, - prefersLargeTitles: Bool = false + prefersLargeTitles: Bool = false, + style: CommonNavigationBarStyle = .legacy ) { if #available(iOS 15.0, *) { - let appearance = UINavigationBarAppearance() - appearance.configureWithOpaqueBackground() - appearance.backgroundColor = backgroundColor - appearance.shadowColor = .clear - appearance.shadowImage = UIImage() // 명시적으로 빈 이미지 설정 - - // 기본 타이틀 - appearance.titleTextAttributes = [ - .foregroundColor: foregroundColor, - .font: font - ] - - // Large title이 켜져 있을 때만 적용 - if prefersLargeTitles, let largeFont = largeFont { - appearance.largeTitleTextAttributes = [ - .foregroundColor: foregroundColor, - .font: largeFont - ] - } - - // Appearance 일괄 적용 + let appearance = CommonNavigationAppearance.makeAppearance( + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + font: font, + largeFont: prefersLargeTitles ? largeFont : nil, + style: style + ) self.standardAppearance = appearance self.scrollEdgeAppearance = appearance -// self.compactAppearance = appearance -// self.compactScrollEdgeAppearance = appearance + if style != .legacy { + self.compactAppearance = appearance + self.compactScrollEdgeAppearance = appearance + } } else { // iOS 14 이하 fallback self.titleTextAttributes = [ @@ -62,6 +51,14 @@ public extension UINavigationBar { self.setBackgroundImage(UIImage(), for: .default) } // 공통 설정 + if #available(iOS 26.0, *), style == .system { + self.setBackgroundImage(nil, for: .default) + self.shadowImage = nil + self.backgroundColor = nil + self.barTintColor = nil + self.isTranslucent = true + } + // 텍스트 버튼은 기존 색상을 상속하고 이미지 버튼은 개별 tintColor를 사용한다. self.tintColor = foregroundColor self.prefersLargeTitles = prefersLargeTitles } diff --git a/Sources/Model/CommonNavigationAppearance.swift b/Sources/Model/CommonNavigationAppearance.swift new file mode 100644 index 0000000..0d50eea --- /dev/null +++ b/Sources/Model/CommonNavigationAppearance.swift @@ -0,0 +1,52 @@ +import UIKit + +public enum CommonNavigationBarStyle { + case legacy + case system + case customBackground +} + +@MainActor +public enum CommonNavigationAppearance { + // 앱에서 명시적으로 켠 경우에만 공통 버튼을 시스템 스타일로 생성한다. + public static var isEnabled = false + public static var buttonTintColor: UIColor = .label + + public static var usesSystemButtons: Bool { + if #available(iOS 26.0, *) { + return isEnabled + } + return false + } + + public static func makeAppearance(backgroundColor: UIColor, + foregroundColor: UIColor, + font: UIFont, + largeFont: UIFont? = nil, + style: CommonNavigationBarStyle) -> UINavigationBarAppearance { + let appearance = UINavigationBarAppearance() + switch style { + case .system, .legacy: + // 시스템 버튼을 사용해도 화면에서 지정한 배경과 제목 색상은 유지한다. + appearance.configureWithOpaqueBackground() + case .customBackground: + appearance.configureWithTransparentBackground() + } + appearance.backgroundColor = backgroundColor + appearance.shadowColor = .clear + appearance.shadowImage = UIImage() + appearance.titleTextAttributes = [.foregroundColor: foregroundColor, .font: font] + if let largeFont { + appearance.largeTitleTextAttributes = [.foregroundColor: foregroundColor, .font: largeFont] + } + if #available(iOS 26.0, *), style == .system { + // Glass의 자동 글자색 선택 대신 텍스트 버튼의 기존 색상을 명시한다. + for button in [appearance.buttonAppearance, appearance.doneButtonAppearance, appearance.backButtonAppearance] { + button.normal.titleTextAttributes[.foregroundColor] = foregroundColor + button.highlighted.titleTextAttributes[.foregroundColor] = foregroundColor + button.disabled.titleTextAttributes[.foregroundColor] = foregroundColor.withAlphaComponent(0.3) + } + } + return appearance + } +} diff --git a/Sources/Resources/Localizable.xcstrings b/Sources/Resources/Localizable.xcstrings index 2c9f33e..3757857 100644 --- a/Sources/Resources/Localizable.xcstrings +++ b/Sources/Resources/Localizable.xcstrings @@ -1,6 +1,40 @@ { "sourceLanguage" : "en", "strings" : { + "navigation_back" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Back" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "뒤로" + } + } + } + }, + "navigation_close" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "닫기" + } + } + } + }, "나중에" : { "extractionState" : "manual", "localizations" : { diff --git a/Tests/CommonUtilsTests/NavigationAppearanceTests.swift b/Tests/CommonUtilsTests/NavigationAppearanceTests.swift new file mode 100644 index 0000000..a52acce --- /dev/null +++ b/Tests/CommonUtilsTests/NavigationAppearanceTests.swift @@ -0,0 +1,200 @@ +import XCTest +import UIKit +@testable import CommonUtils + +@MainActor +final class NavigationAppearanceTests: XCTestCase { + func testLegacyAppearancePreservesExplicitColors() { + let appearance = makeAppearance(style: .legacy, background: .red, foreground: .white) + XCTAssertEqual(appearance.backgroundColor, .red) + XCTAssertNil(appearance.backgroundEffect) + XCTAssertEqual(appearance.titleTextAttributes[.foregroundColor] as? UIColor, .white) + } + + func testSystemTextButtonAppearancesPreserveNormalAndDisabledColors() throws { + guard #available(iOS 26.0, *) else { throw XCTSkip("iOS 26 이상에서 Glass 텍스트 버튼 색상을 검증합니다.") } + let appearance = makeAppearance(style: .system, background: .blue, foreground: .white) + for button in [appearance.buttonAppearance, appearance.doneButtonAppearance, appearance.backButtonAppearance] { + XCTAssertEqual(button.normal.titleTextAttributes[.foregroundColor] as? UIColor, .white) + XCTAssertEqual(button.highlighted.titleTextAttributes[.foregroundColor] as? UIColor, .white) + XCTAssertEqual(button.disabled.titleTextAttributes[.foregroundColor] as? UIColor, UIColor.white.withAlphaComponent(0.3)) + } + } + + func testCustomBackgroundPreservesTransparentAndPartialAlpha() { + for alpha in [CGFloat(0), 0.5, 1] { + let color = UIColor.red.withAlphaComponent(alpha) + let appearance = makeAppearance(style: .customBackground, background: color, foreground: .white) + XCTAssertEqual(appearance.backgroundColor, color) + XCTAssertNil(appearance.backgroundEffect) + } + } + + func testSystemAppearancePreservesBackgroundAndTitleColors() { + let appearance = makeAppearance(style: .system, background: .red, foreground: .white) + XCTAssertEqual(appearance.backgroundColor, .red) + XCTAssertNil(appearance.backgroundEffect) + XCTAssertEqual(appearance.titleTextAttributes[.foregroundColor] as? UIColor, .white) + } + + func testSystemStyleClearsPreviouslyAppliedLegacyBarState() { + let bar = UINavigationBar() + bar.backgroundColor = .red + bar.barTintColor = .red + bar.isTranslucent = false + bar.setNavigationBarStyle(.red, fontColor: .white, font: .systemFont(ofSize: 18), style: .system) + if #available(iOS 26.0, *) { + XCTAssertNil(bar.backgroundColor) + XCTAssertNil(bar.barTintColor) + XCTAssertTrue(bar.isTranslucent) + } + } + + func testButtonFactoryOptInPreservesTargetActionAndMenu() { + let previous = CommonNavigationAppearance.isEnabled + defer { CommonNavigationAppearance.isEnabled = previous } + let target = ButtonTarget() + let image = UIImage(systemName: "magnifyingglass") + CommonNavigationAppearance.isEnabled = false + let legacy = UIBarButtonItem.createUIBarButtonItem(image: image, target: target, action: #selector(ButtonTarget.tap)) + XCTAssertNotNil(legacy.customView) + + CommonNavigationAppearance.isEnabled = true + let item = UIBarButtonItem.createUIBarButtonItem(image: image, target: target, action: #selector(ButtonTarget.tap)) + let menu = UIMenu(children: [UIAction(title: "Search") { _ in }]) + let menuItem = UIBarButtonItem.createUIBarButtonItem(image: image, menu: menu) + if #available(iOS 26.0, *) { + XCTAssertNil(item.customView) + XCTAssertTrue(item.target as? ButtonTarget === target) + XCTAssertEqual(item.action, #selector(ButtonTarget.tap)) + XCTAssertEqual(item.image?.renderingMode, .alwaysTemplate) + XCTAssertEqual(menuItem.menu?.title, menu.title) + XCTAssertEqual(menuItem.menu?.children.map(\.title), menu.children.map(\.title)) + if let action = item.action { + _ = (item.target as? NSObject)?.perform(action) + } + XCTAssertEqual(target.tapCount, 1) + } else { + XCTAssertNotNil(item.customView) + XCTAssertNotNil(menuItem.customView) + } + } + + func testBackAndCloseKeepTheirActions() { + let previous = CommonNavigationAppearance.isEnabled + defer { CommonNavigationAppearance.isEnabled = previous } + CommonNavigationAppearance.isEnabled = true + let root = UIViewController() + let detail = UIViewController() + let navigation = UINavigationController(rootViewController: root) + navigation.pushViewController(detail, animated: false) + let target = ButtonTarget() + detail.hideNavigationLeftButton(hidden: false, target: target, action: #selector(ButtonTarget.tap)) + navigation.popViewController(animated: false) + root.hideNavigationLeftButton(hidden: false, target: target, action: #selector(ButtonTarget.tap)) + XCTAssertEqual(detail.navigationItem.leftBarButtonItem?.action, #selector(ButtonTarget.tap)) + XCTAssertEqual(root.navigationItem.leftBarButtonItem?.action, #selector(ButtonTarget.tap)) + XCTAssertTrue(detail.navigationItem.hidesBackButton) + if #available(iOS 26.0, *) { + XCTAssertEqual(detail.navigationItem.leftBarButtonItem?.accessibilityLabel, "navigation_back".localization(true)) + XCTAssertEqual(root.navigationItem.leftBarButtonItem?.accessibilityLabel, "navigation_close".localization(true)) + } + detail.hideNavigationLeftButton(hidden: true) + XCTAssertNil(detail.navigationItem.leftBarButtonItem) + } + + func testConfiguredIconTintDoesNotChangeTextButtonTint() throws { + guard #available(iOS 26.0, *) else { throw XCTSkip("iOS 26 이상에서 시스템 버튼 색상을 검증합니다.") } + let previousEnabled = CommonNavigationAppearance.isEnabled + let previousTint = CommonNavigationAppearance.buttonTintColor + defer { + CommonNavigationAppearance.isEnabled = previousEnabled + CommonNavigationAppearance.buttonTintColor = previousTint + } + CommonNavigationAppearance.isEnabled = true + CommonNavigationAppearance.buttonTintColor = .black + let bar = UINavigationBar() + bar.setNavigationBarStyle(.red, fontColor: .white, font: .systemFont(ofSize: 18), style: .system) + XCTAssertEqual(bar.tintColor, .white) + let target = ButtonTarget() + let image = UIImage(systemName: "magnifyingglass")?.withTintColor(.white, renderingMode: .alwaysOriginal) + let item = UIBarButtonItem.createUIBarButtonItem(image: image, target: target, action: #selector(ButtonTarget.tap)) + XCTAssertEqual(item.image?.renderingMode, .alwaysTemplate) + XCTAssertEqual(item.tintColor?.resolvedColor(with: UITraitCollection(userInterfaceStyle: .dark)), .black) + let custom = UIButton(type: .custom) + custom.setImage(image, for: .normal) + _ = UIBarButtonItem.buttonToBarButtonItem(custom) + XCTAssertEqual(custom.image(for: .normal)?.renderingMode, .alwaysTemplate) + XCTAssertEqual(custom.tintColor, .black) + } + + func testNavigationIconCenteringMovesArtworkWithoutResizing() throws { + let format = UIGraphicsImageRendererFormat() + format.scale = 3 + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 24, height: 24), format: format) + let image = renderer.image { _ in + UIColor.white.setFill() + UIRectFill(CGRect(x: 18, y: 2, width: 4, height: 8)) + }.withRenderingMode(.alwaysOriginal) + let centered = image.centeredNavigationIcon() + XCTAssertEqual(centered.size, image.size) + XCTAssertEqual(centered.scale, image.scale) + XCTAssertEqual(centered.renderingMode, image.renderingMode) + let alpha = try alphaPixels(centered) + XCTAssertEqual(alpha.filter { $0 > 0 }.count, 4 * 8 * 9) + XCTAssertEqual(alpha, Array(alpha.reversed())) + } + + func testNavigationIconCenteringKeepsTransparentAndSystemImages() throws { + let empty = UIGraphicsImageRenderer(size: CGSize(width: 24, height: 24)).image { _ in } + XCTAssertTrue(empty.centeredNavigationIcon() === empty) + let symbol = try XCTUnwrap(UIImage(systemName: "chevron.backward")) + XCTAssertTrue(symbol.centeredNavigationIcon() === symbol) + } + + func testNavigationIconCenteringOnlyAppliesToOptedInSystemButtons() throws { + let previous = CommonNavigationAppearance.isEnabled + defer { CommonNavigationAppearance.isEnabled = previous } + let image = UIGraphicsImageRenderer(size: CGSize(width: 24, height: 24)).image { _ in + UIColor.white.setFill() + UIRectFill(CGRect(x: 18, y: 2, width: 4, height: 8)) + } + let target = ButtonTarget() + CommonNavigationAppearance.isEnabled = false + let legacy = UIBarButtonItem.createUIBarButtonItem(image: image, target: target, action: #selector(ButtonTarget.tap)) + XCTAssertTrue((legacy.customView as? UIButton)?.image(for: .normal) === image) + CommonNavigationAppearance.isEnabled = true + let menu = UIMenu(children: []) + let item = UIBarButtonItem.createUIBarButtonItem(image: image, menu: menu) + if #available(iOS 26.0, *) { + let alpha = try alphaPixels(XCTUnwrap(item.image)) + XCTAssertEqual(alpha, Array(alpha.reversed())) + } else { + XCTAssertTrue((item.customView as? UIButton)?.image(for: .normal) === image) + } + } + + private func alphaPixels(_ image: UIImage) throws -> [UInt8] { + let cgImage = try XCTUnwrap(image.cgImage) + var pixels = [UInt8](repeating: 0, count: cgImage.width * cgImage.height * 4) + try pixels.withUnsafeMutableBytes { buffer in + let context = try XCTUnwrap(CGContext(data: buffer.baseAddress, width: cgImage.width, height: cgImage.height, + bitsPerComponent: 8, bytesPerRow: cgImage.width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height)) + } + return stride(from: 3, to: pixels.count, by: 4).map { pixels[$0] } + } + + private func makeAppearance(style: CommonNavigationBarStyle, background: UIColor, foreground: UIColor) -> UINavigationBarAppearance { + CommonNavigationAppearance.makeAppearance(backgroundColor: background, foregroundColor: foreground, + font: .systemFont(ofSize: 18), style: style) + } +} + +@MainActor +private final class ButtonTarget: NSObject { + var tapCount = 0 + @objc func tap() { tapCount += 1 } +} From 96849e422d6b98784f22f0bb902ff9e1723a27dd Mon Sep 17 00:00:00 2001 From: "dongju.lim" Date: Thu, 8 Oct 2026 18:51:03 +0900 Subject: [PATCH 06/11] =?UTF-8?q?[20261008]=20=EB=B2=84=EA=B7=B8=EC=88=98?= =?UTF-8?q?=EC=A0=95:=20UI=20=EB=B2=84=ED=8A=BC=20=EC=A0=9C=EB=AA=A9?= =?UTF-8?q?=EC=9D=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=95=84=EB=9E=98?= =?UTF-8?q?=EC=97=90=20=EB=B0=B0=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit alignTextBelow를 UIButton.Configuration의 imagePlacement와 imagePadding으로 구현한다. 속성 제목, 일반 제목의 글꼴과 상태 색상, 기존 configuration과 action 보존을 검증하는 테스트를 추가한다. --- Sources/Extensions/UIButton+Extension.swift | 31 ++--- .../UIButtonExtensionTests.swift | 120 ++++++++++++++++++ 2 files changed, 136 insertions(+), 15 deletions(-) diff --git a/Sources/Extensions/UIButton+Extension.swift b/Sources/Extensions/UIButton+Extension.swift index 8bfd2ee..e5f7419 100644 --- a/Sources/Extensions/UIButton+Extension.swift +++ b/Sources/Extensions/UIButton+Extension.swift @@ -32,26 +32,27 @@ public extension UIButton { setAttributedTitle(titleString, for: .normal) } - // https://developer-eungb.tistory.com/30 + // 기존 버튼 설정을 유지하면서 이미지 아래에 제목을 배치한다. func alignTextBelow(spacing: CGFloat = 4.0) { - guard let image = self.imageView?.image else { + guard imageView?.image != nil, let titleLabel = titleLabel, titleLabel.text != nil else { return } - guard let titleLabel = self.titleLabel else { - return - } - - guard let titleText = titleLabel.text else { - return + var configuration = self.configuration ?? .plain() + if self.configuration == nil { + configuration.contentInsets = .zero + if currentAttributedTitle == nil { + let font = titleLabel.font + configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in + var attributes = attributes + attributes.font = font + return attributes + } + } } - - let titleSize = titleText.size(withAttributes: [ - NSAttributedString.Key.font: titleLabel.font as Any - ]) - - titleEdgeInsets = UIEdgeInsets(top: spacing, left: -image.size.width, bottom: -image.size.height, right: 0) - imageEdgeInsets = UIEdgeInsets(top: -(titleSize.height + spacing), left: 0, bottom: 0, right: -titleSize.width) + configuration.imagePlacement = .top + configuration.imagePadding = spacing + self.configuration = configuration } } diff --git a/Tests/CommonUtilsTests/UIButtonExtensionTests.swift b/Tests/CommonUtilsTests/UIButtonExtensionTests.swift index dcabdd9..b10f7b9 100644 --- a/Tests/CommonUtilsTests/UIButtonExtensionTests.swift +++ b/Tests/CommonUtilsTests/UIButtonExtensionTests.swift @@ -11,6 +11,126 @@ import UIKit final class UIButtonExtensionTests: XCTestCase { + @MainActor + func test_alignTextBelow_withAttributedTitle_preservesAppearanceAndSpacing() throws { + let button = UIButton(type: .custom) + let font = UIFont.systemFont(ofSize: 16) + let title = NSAttributedString(string: "메뉴", attributes: [.font: font, .foregroundColor: UIColor.gray]) + button.setImage(makeImage(), for: .normal) + button.setAttributedTitle(title, for: .normal) + + button.alignTextBelow(spacing: 8) + + try assertVerticalLayout(button, spacing: 8) + XCTAssertEqual(button.currentAttributedTitle, title) + let displayedTitle = try XCTUnwrap(button.titleLabel?.attributedText) + XCTAssertEqual(displayedTitle.attribute(.font, at: 0, effectiveRange: nil) as? UIFont, font) + XCTAssertEqual(displayedTitle.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? UIColor, .gray) + } + + @MainActor + func test_alignTextBelow_withPlainTitle_preservesFontColorStateAndAction() throws { + let button = UIButton(type: .custom) + let font = UIFont.boldSystemFont(ofSize: 14) + button.titleLabel?.font = font + button.setTitle("메뉴", for: .normal) + button.setTitle("선택", for: .selected) + button.setTitleColor(.red, for: .normal) + button.setTitleColor(.orange, for: .selected) + button.setImage(makeImage(), for: .normal) + var actionCount = 0 + button.addAction(UIAction { _ in actionCount += 1 }, for: .touchUpInside) + + button.alignTextBelow() + + try assertVerticalLayout(button, spacing: 4) + XCTAssertEqual(button.titleLabel?.font, font) + XCTAssertEqual(displayedTitleColor(button), .red) + button.isSelected = true + try assertVerticalLayout(button, spacing: 4) + XCTAssertEqual(button.currentTitle, "선택") + XCTAssertEqual(button.titleLabel?.font, font) + XCTAssertEqual(displayedTitleColor(button), .orange) + button.sendActions(for: .touchUpInside) + XCTAssertEqual(actionCount, 1) + } + + @MainActor + func test_alignTextBelow_withExistingConfiguration_preservesOtherSettings() throws { + var configuration = UIButton.Configuration.filled() + configuration.title = "메뉴" + configuration.subtitle = "설명" + configuration.image = makeImage() + configuration.baseForegroundColor = .white + configuration.baseBackgroundColor = .blue + configuration.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 12, bottom: 14, trailing: 16) + configuration.cornerStyle = .large + let font = UIFont.boldSystemFont(ofSize: 18) + configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in + var attributes = attributes + attributes.font = font + return attributes + } + let button = UIButton(configuration: configuration) + + button.alignTextBelow(spacing: 6) + + try assertVerticalLayout(button, spacing: 6) + let updated = try XCTUnwrap(button.configuration) + XCTAssertEqual(updated.subtitle, configuration.subtitle) + XCTAssertEqual(updated.baseForegroundColor, configuration.baseForegroundColor) + XCTAssertEqual(updated.baseBackgroundColor, configuration.baseBackgroundColor) + XCTAssertEqual(updated.contentInsets, configuration.contentInsets) + XCTAssertEqual(updated.cornerStyle, configuration.cornerStyle) + XCTAssertEqual(button.titleLabel?.font, font) + } + + @MainActor + func test_alignTextBelow_withoutImageOrTitle_keepsConfigurationUnchanged() { + let titleOnly = UIButton(type: .custom) + titleOnly.setTitle("메뉴", for: .normal) + titleOnly.alignTextBelow() + XCTAssertNil(titleOnly.configuration) + + let imageOnly = UIButton(type: .custom) + imageOnly.setImage(makeImage(), for: .normal) + imageOnly.alignTextBelow() + XCTAssertNil(imageOnly.configuration) + } + + @MainActor + private func makeImage() -> UIImage { + let size = CGSize(width: 24, height: 24) + return UIGraphicsImageRenderer(size: size).image { context in + UIColor.blue.setFill() + context.fill(CGRect(origin: .zero, size: size)) + }.withRenderingMode(.alwaysOriginal) + } + + @MainActor + private func assertVerticalLayout(_ button: UIButton, spacing: CGFloat, + file: StaticString = #filePath, line: UInt = #line) throws { + button.frame = CGRect(x: 0, y: 0, width: 160, height: 160) + button.setNeedsLayout() + button.layoutIfNeeded() + let imageView = try XCTUnwrap(button.imageView, file: file, line: line) + let titleLabel = try XCTUnwrap(button.titleLabel, file: file, line: line) + let imageFrame = button.convert(imageView.bounds, from: imageView) + let titleFrame = button.convert(titleLabel.bounds, from: titleLabel) + XCTAssertGreaterThan(imageFrame.height, 0, file: file, line: line) + XCTAssertGreaterThan(titleFrame.height, 0, file: file, line: line) + XCTAssertEqual(titleFrame.minY - imageFrame.maxY, spacing, accuracy: 0.5, file: file, line: line) + XCTAssertEqual(titleFrame.midX, imageFrame.midX, accuracy: 0.5, file: file, line: line) + } + + @MainActor + private func displayedTitleColor(_ button: UIButton) -> UIColor? { + guard let attributedTitle = button.titleLabel?.attributedText, attributedTitle.length > 0 else { + return button.titleLabel?.textColor + } + return attributedTitle.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? UIColor + } + func test_underlineText_withEmojiTitle_shouldApplyAttributeAcrossUTF16Range() throws { let button = UIButton(type: .system) let title = "👨‍👩‍👧‍👦테스트" From 5c2d2a151717a98968be71e9078f2e92c5b18733 Mon Sep 17 00:00:00 2001 From: "dongju.lim" Date: Thu, 8 Oct 2026 18:51:23 +0900 Subject: [PATCH 07/11] =?UTF-8?q?[20261008]=20=ED=85=8C=EC=8A=A4=ED=8A=B8:?= =?UTF-8?q?=20=EB=82=A0=EC=A7=9C=EC=99=80=20=EB=AC=B8=EC=9E=90=EC=97=B4?= =?UTF-8?q?=EC=9D=98=20=EA=B8=B0=EB=B3=B8=20=ED=8F=AC=EB=A7=B7=20=EA=B8=B0?= =?UTF-8?q?=EB=8C=80=EA=B0=92=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 날짜 확장 테스트를 기본 시간대와 locale 인자로 실행하고 오래전 지역화 기대값을 수정한다. 문자열 날짜 포맷 변경 테스트의 기대값을 한국어 출력으로 명시한다. --- .../CommonUtilsTests/DateExtensionTests.swift | 104 +++++++++--------- .../StringExtensionTests.swift | 7 +- 2 files changed, 52 insertions(+), 59 deletions(-) diff --git a/Tests/CommonUtilsTests/DateExtensionTests.swift b/Tests/CommonUtilsTests/DateExtensionTests.swift index 2199b8d..9d3c86f 100644 --- a/Tests/CommonUtilsTests/DateExtensionTests.swift +++ b/Tests/CommonUtilsTests/DateExtensionTests.swift @@ -10,8 +10,6 @@ import XCTest final class DateExtensionTests: XCTestCase { let calendar = Calendar(identifier: .gregorian) - let kstTimeZone = TimeZone(identifier: "Asia/Seoul")! - let koKRLocale = Locale(identifier: "ko_KR") let formatter: DateFormatter = { let df = DateFormatter() df.setKSTFormatter() @@ -21,16 +19,16 @@ final class DateExtensionTests: XCTestCase { // MARK: - dayOfWeek(timeZone:locale:) 테스트 func test_dayOfWeek() throws { let date = formatter.date(from: "2024-04-14 00:00:00")! // 일요일 - XCTAssertEqual(date.dayOfWeek(timeZone: kstTimeZone, locale: koKRLocale), .sunday) + XCTAssertEqual(date.dayOfWeek(), .sunday) let monday = formatter.date(from: "2024-04-15 12:00:00")! - XCTAssertEqual(monday.dayOfWeek(timeZone: kstTimeZone, locale: koKRLocale), .monday) + XCTAssertEqual(monday.dayOfWeek(), .monday) let wednesday = formatter.date(from: "2024-04-17 10:00:00")! - XCTAssertEqual(wednesday.dayOfWeek(timeZone: kstTimeZone, locale: koKRLocale), .wednesday) + XCTAssertEqual(wednesday.dayOfWeek(), .wednesday) let saturday = formatter.date(from: "2024-04-20 23:59:59")! - XCTAssertEqual(saturday.dayOfWeek(timeZone: kstTimeZone, locale: koKRLocale), .saturday) + XCTAssertEqual(saturday.dayOfWeek(), .saturday) let tz = TimeZone(abbreviation: "UTC")! let dateUTC = formatter.date(from: "2024-04-14 00:00:00")! @@ -40,11 +38,11 @@ final class DateExtensionTests: XCTestCase { // MARK: - toString(format:) 테스트 func test_toString() throws { let date = formatter.date(from: "2024-04-14 15:20:00")! - XCTAssertEqual(date.toString(format: "yyyy", timeZone: kstTimeZone, locale: koKRLocale), "2024") - XCTAssertEqual(date.toString(format: "MM", timeZone: kstTimeZone, locale: koKRLocale), "04") - XCTAssertEqual(date.toString(format: "dd", timeZone: kstTimeZone, locale: koKRLocale), "14") - XCTAssertEqual(date.toString(format: "HH:mm", timeZone: kstTimeZone, locale: koKRLocale), "15:20") - XCTAssertEqual(date.toString(format: "yyyy-MM-dd HH:mm", timeZone: kstTimeZone, locale: koKRLocale), "2024-04-14 15:20") + XCTAssertEqual(date.toString(format: "yyyy"), "2024") + XCTAssertEqual(date.toString(format: "MM"), "04") + XCTAssertEqual(date.toString(format: "dd"), "14") + XCTAssertEqual(date.toString(format: "HH:mm"), "15:20") + XCTAssertEqual(date.toString(format: "yyyy-MM-dd HH:mm"), "2024-04-14 15:20") } // MARK: - betweenDates(toDate:component:) 테스트 @@ -70,24 +68,24 @@ final class DateExtensionTests: XCTestCase { // MARK: - toFirstDay / toLastDay 테스트 func test_toFirstDay_and_toLastDay() throws { let date = formatter.date(from: "2024-04-15 12:00:00")! - XCTAssertEqual(date.toFirstDay.toString(format: "yyyy-MM-dd", timeZone: kstTimeZone, locale: koKRLocale), "2024-04-01") - XCTAssertEqual(date.toLastDay.toString(format: "yyyy-MM-dd", timeZone: kstTimeZone, locale: koKRLocale), "2024-04-30") + XCTAssertEqual(date.toFirstDay.toString(format: "yyyy-MM-dd"), "2024-04-01") + XCTAssertEqual(date.toLastDay.toString(format: "yyyy-MM-dd"), "2024-04-30") let febDate = formatter.date(from: "2024-02-10 00:00:00")! - XCTAssertEqual(febDate.toFirstDay.toString(format: "yyyy-MM-dd", timeZone: kstTimeZone, locale: koKRLocale), "2024-02-01") - XCTAssertEqual(febDate.toLastDay.toString(format: "yyyy-MM-dd", timeZone: kstTimeZone, locale: koKRLocale), "2024-02-29") + XCTAssertEqual(febDate.toFirstDay.toString(format: "yyyy-MM-dd"), "2024-02-01") + XCTAssertEqual(febDate.toLastDay.toString(format: "yyyy-MM-dd"), "2024-02-29") let jan = formatter.date(from: "2024-01-05 00:00:00")! - XCTAssertEqual(jan.toFirstDay.toString(format: "yyyy-MM-dd", timeZone: kstTimeZone, locale: koKRLocale), "2024-01-01") - XCTAssertEqual(jan.toLastDay.toString(format: "yyyy-MM-dd", timeZone: kstTimeZone, locale: koKRLocale), "2024-01-31") + XCTAssertEqual(jan.toFirstDay.toString(format: "yyyy-MM-dd"), "2024-01-01") + XCTAssertEqual(jan.toLastDay.toString(format: "yyyy-MM-dd"), "2024-01-31") let dec = formatter.date(from: "2023-12-31 00:00:00")! - XCTAssertEqual(dec.toFirstDay.toString(format: "yyyy-MM-dd", timeZone: kstTimeZone, locale: koKRLocale), "2023-12-01") - XCTAssertEqual(dec.toLastDay.toString(format: "yyyy-MM-dd", timeZone: kstTimeZone, locale: koKRLocale), "2023-12-31") + XCTAssertEqual(dec.toFirstDay.toString(format: "yyyy-MM-dd"), "2023-12-01") + XCTAssertEqual(dec.toLastDay.toString(format: "yyyy-MM-dd"), "2023-12-31") let random = formatter.date(from: "2022-06-10 12:00:00")! - XCTAssertEqual(random.toFirstDay.toString(format: "yyyy-MM-dd", timeZone: kstTimeZone, locale: koKRLocale), "2022-06-01") - XCTAssertEqual(random.toLastDay.toString(format: "yyyy-MM-dd", timeZone: kstTimeZone, locale: koKRLocale), "2022-06-30") + XCTAssertEqual(random.toFirstDay.toString(format: "yyyy-MM-dd"), "2022-06-01") + XCTAssertEqual(random.toLastDay.toString(format: "yyyy-MM-dd"), "2022-06-30") } // MARK: - toCalculateDateString(from nowDate: Date = Date()) -> String 테스트 @@ -111,7 +109,7 @@ final class DateExtensionTests: XCTestCase { XCTAssertTrue(fiveMinsLater.toCalculateDateString(from: now).contains(("분".localization() + "후".localization()))) XCTAssertTrue(twoDaysAgo.toCalculateDateString(from: now).contains(("일".localization() + "전".localization()))) XCTAssertTrue(oneHourLater.toCalculateDateString(from: now).contains(("시간".localization() + "후".localization()))) - XCTAssertEqual(hundredDaysAgo.toCalculateDateString(from: now), "오래".localization() + "전".localization()) + XCTAssertEqual(hundredDaysAgo.toCalculateDateString(from: now), "오래전".localization()) } // MARK: - toUnixTimeStamp @@ -152,41 +150,41 @@ final class DateExtensionTests: XCTestCase { // MARK: - toHourhDate(_:) func test_toHourhDate() throws { let now = formatter.date(from: "2024-04-15 12:00:00")! - XCTAssertEqual(now.toHourhDate(1)?.toString(format: "HH:mm", timeZone: kstTimeZone, locale: koKRLocale), "13:00") - XCTAssertEqual(now.toHourhDate(-1)?.toString(format: "HH:mm", timeZone: kstTimeZone, locale: koKRLocale), "11:00") - XCTAssertEqual(now.toHourhDate(0)?.toString(format: "HH:mm", timeZone: kstTimeZone, locale: koKRLocale), "12:00") - XCTAssertEqual(now.toHourhDate(12)?.toString(format: "HH:mm", timeZone: kstTimeZone, locale: koKRLocale), "00:00") // 다음날 0시 - XCTAssertEqual(now.toHourhDate(-12)?.toString(format: "HH:mm", timeZone: kstTimeZone, locale: koKRLocale), "00:00") + XCTAssertEqual(now.toHourhDate(1)?.toString(format: "HH:mm"), "13:00") + XCTAssertEqual(now.toHourhDate(-1)?.toString(format: "HH:mm"), "11:00") + XCTAssertEqual(now.toHourhDate(0)?.toString(format: "HH:mm"), "12:00") + XCTAssertEqual(now.toHourhDate(12)?.toString(format: "HH:mm"), "00:00") // 다음날 0시 + XCTAssertEqual(now.toHourhDate(-12)?.toString(format: "HH:mm"), "00:00") } // MARK: - toMinuteDate(_:) func test_toMinuteDate() throws { let now = formatter.date(from: "2024-04-15 12:00:00")! - XCTAssertEqual(now.toMinuteDate(1)?.toString(format: "HH:mm", timeZone: kstTimeZone, locale: koKRLocale), "12:01") - XCTAssertEqual(now.toMinuteDate(-1)?.toString(format: "HH:mm", timeZone: kstTimeZone, locale: koKRLocale), "11:59") - XCTAssertEqual(now.toMinuteDate(30)?.toString(format: "HH:mm", timeZone: kstTimeZone, locale: koKRLocale), "12:30") - XCTAssertEqual(now.toMinuteDate(-30)?.toString(format: "HH:mm", timeZone: kstTimeZone, locale: koKRLocale), "11:30") - XCTAssertEqual(now.toMinuteDate(0)?.toString(format: "HH:mm", timeZone: kstTimeZone, locale: koKRLocale), "12:00") + XCTAssertEqual(now.toMinuteDate(1)?.toString(format: "HH:mm"), "12:01") + XCTAssertEqual(now.toMinuteDate(-1)?.toString(format: "HH:mm"), "11:59") + XCTAssertEqual(now.toMinuteDate(30)?.toString(format: "HH:mm"), "12:30") + XCTAssertEqual(now.toMinuteDate(-30)?.toString(format: "HH:mm"), "11:30") + XCTAssertEqual(now.toMinuteDate(0)?.toString(format: "HH:mm"), "12:00") } // MARK: - toDayDate(_:) func test_toDayDate() throws { let date = formatter.date(from: "2024-04-15 00:00:00")! - XCTAssertEqual(date.toDayDate(1)?.toString(format: "yyyy-MM-dd", timeZone: kstTimeZone, locale: koKRLocale), "2024-04-16") - XCTAssertEqual(date.toDayDate(-1)?.toString(format: "yyyy-MM-dd", timeZone: kstTimeZone, locale: koKRLocale), "2024-04-14") - XCTAssertEqual(date.toDayDate(30)?.toString(format: "yyyy-MM-dd", timeZone: kstTimeZone, locale: koKRLocale), "2024-05-15") - XCTAssertEqual(date.toDayDate(-15)?.toString(format: "yyyy-MM-dd", timeZone: kstTimeZone, locale: koKRLocale), "2024-03-31") - XCTAssertEqual(date.toDayDate(0)?.toString(format: "yyyy-MM-dd", timeZone: kstTimeZone, locale: koKRLocale), "2024-04-15") + XCTAssertEqual(date.toDayDate(1)?.toString(format: "yyyy-MM-dd"), "2024-04-16") + XCTAssertEqual(date.toDayDate(-1)?.toString(format: "yyyy-MM-dd"), "2024-04-14") + XCTAssertEqual(date.toDayDate(30)?.toString(format: "yyyy-MM-dd"), "2024-05-15") + XCTAssertEqual(date.toDayDate(-15)?.toString(format: "yyyy-MM-dd"), "2024-03-31") + XCTAssertEqual(date.toDayDate(0)?.toString(format: "yyyy-MM-dd"), "2024-04-15") } // MARK: - toYearDate(_:) func test_toYearDate() throws { let date = formatter.date(from: "2024-04-15 00:00:00")! - XCTAssertEqual(date.toYearDate(1)?.toString(format: "yyyy", timeZone: kstTimeZone, locale: koKRLocale), "2025") - XCTAssertEqual(date.toYearDate(-1)?.toString(format: "yyyy", timeZone: kstTimeZone, locale: koKRLocale), "2023") - XCTAssertEqual(date.toYearDate(10)?.toString(format: "yyyy", timeZone: kstTimeZone, locale: koKRLocale), "2034") - XCTAssertEqual(date.toYearDate(-100)?.toString(format: "yyyy", timeZone: kstTimeZone, locale: koKRLocale), "1924") - XCTAssertEqual(date.toYearDate(0)?.toString(format: "yyyy", timeZone: kstTimeZone, locale: koKRLocale), "2024") + XCTAssertEqual(date.toYearDate(1)?.toString(format: "yyyy"), "2025") + XCTAssertEqual(date.toYearDate(-1)?.toString(format: "yyyy"), "2023") + XCTAssertEqual(date.toYearDate(10)?.toString(format: "yyyy"), "2034") + XCTAssertEqual(date.toYearDate(-100)?.toString(format: "yyyy"), "1924") + XCTAssertEqual(date.toYearDate(0)?.toString(format: "yyyy"), "2024") } // MARK: - getComponentValue(_:) @@ -194,11 +192,11 @@ final class DateExtensionTests: XCTestCase { guard let date = formatter.date(from: "2024-04-15 13:45:30") else { throw XCTSkip("데이트 생성 안됨. 패스!!") } - XCTAssertEqual(date.getComponentValue([.year], timeZone: kstTimeZone, locale: koKRLocale)?.year, 2024) - XCTAssertEqual(date.getComponentValue([.month], timeZone: kstTimeZone, locale: koKRLocale)?.month, 4) - XCTAssertEqual(date.getComponentValue([.day], timeZone: kstTimeZone, locale: koKRLocale)?.day, 15) - XCTAssertEqual(date.getComponentValue([.hour], timeZone: kstTimeZone, locale: koKRLocale)?.hour, 13) - XCTAssertEqual(date.getComponentValue([.minute], timeZone: kstTimeZone, locale: koKRLocale)?.minute, 45) + XCTAssertEqual(date.getComponentValue([.year])?.year, 2024) + XCTAssertEqual(date.getComponentValue([.month])?.month, 4) + XCTAssertEqual(date.getComponentValue([.day])?.day, 15) + XCTAssertEqual(date.getComponentValue([.hour])?.hour, 13) + XCTAssertEqual(date.getComponentValue([.minute])?.minute, 45) } // MARK: - dateKST (한국 시간 변환) @@ -210,29 +208,29 @@ final class DateExtensionTests: XCTestCase { // KST 기준 포매터로 설정 let formatter = DateFormatter() - formatter.timeZone = kstTimeZone + formatter.timeZone = TimeZone(identifier: "Asia/Seoul") formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" // ✅ 1970-01-01 09:00:00이어야 함 (KST는 UTC+9) let kstDateString = formatter.string(from: utcDate) - XCTAssertEqual(kst.toString(format: "yyyy-MM-dd HH:mm:ss", timeZone: kstTimeZone, locale: koKRLocale), kstDateString) + XCTAssertEqual(kst.toString(format: "yyyy-MM-dd HH:mm:ss"), kstDateString) // ✅ 고정된 날짜 변환 확인 let sampleDate = formatter.date(from: "2024-04-15 00:00:00")! - XCTAssertEqual(sampleDate.dateKST?.toString(format: "yyyy-MM-dd HH:mm:ss", timeZone: kstTimeZone, locale: koKRLocale), "2024-04-15 00:00:00") + XCTAssertEqual(sampleDate.dateKST?.toString(format: "yyyy-MM-dd HH:mm:ss"), "2024-04-15 00:00:00") // ✅ 음수 타임스탬프 확인 (-1시간) let negativeDate = Date(timeIntervalSince1970: -3600) let expectedNegativeString = formatter.string(from: negativeDate) - XCTAssertEqual(negativeDate.dateKST?.toString(format: "yyyy-MM-dd HH:mm:ss", timeZone: kstTimeZone, locale: koKRLocale), expectedNegativeString) + XCTAssertEqual(negativeDate.dateKST?.toString(format: "yyyy-MM-dd HH:mm:ss"), expectedNegativeString) // ✅ 현재 시간 기준 KST 변환도 날짜로 비교 let now = Date() let expectedNowKST = formatter.string(from: now) - XCTAssertEqual(now.dateKST?.toString(format: "yyyy-MM-dd HH:mm:ss", timeZone: kstTimeZone, locale: koKRLocale), expectedNowKST) + XCTAssertEqual(now.dateKST?.toString(format: "yyyy-MM-dd HH:mm:ss"), expectedNowKST) // ✅ 시간 컴포넌트만 추출해서 KST 기준으로 9시 확인 (1970-01-01 09:00:00) - let kstHour = Calendar(identifier: .gregorian).dateComponents(in: kstTimeZone, from: kst).hour + let kstHour = Calendar(identifier: .gregorian).dateComponents(in: TimeZone(identifier: "Asia/Seoul")!, from: kst).hour XCTAssertEqual(kstHour, 9) } } diff --git a/Tests/CommonUtilsTests/StringExtensionTests.swift b/Tests/CommonUtilsTests/StringExtensionTests.swift index 9cc27fe..c48f542 100644 --- a/Tests/CommonUtilsTests/StringExtensionTests.swift +++ b/Tests/CommonUtilsTests/StringExtensionTests.swift @@ -264,12 +264,7 @@ final class StringExtensionTests: XCTestCase { func test_dateFormatChange_withDifferentFormat() throws { let input = "2024-01-01 00:00:00" let result = input.dateFormatChange(changeFormat: "MMM d, yyyy") - - let formatter = DateFormatter() - formatter.setFormatter() - let date = formatter.date(from: input)! - formatter.dateFormat = "MMM d, yyyy" - XCTAssertEqual(result, formatter.string(from: date)) + XCTAssertEqual(result, "1월 1, 2024") } func test_dateFormatChange_withInvalidDateString() throws { From 01be169c770d898e36ba2d1c49c032401d16a5bd Mon Sep 17 00:00:00 2001 From: "dongju.lim" Date: Thu, 8 Oct 2026 18:51:57 +0900 Subject: [PATCH 08/11] =?UTF-8?q?[20261008]=20=EB=AC=B8=EC=84=9C:=20Common?= =?UTF-8?q?Utils=20=EC=9E=91=EC=97=85=20=EB=B0=8F=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=A7=80=EC=B9=A8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS 15 지원, 공통 API와 테스트 작성 원칙, 의존성 최신화와 빌드 검증 절차를 문서화한다. 기능별 커밋과 완료 보고 기준을 명시한다. --- AGENTS.md | 158 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bbf9dbd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,158 @@ +# CommonUtils 에이전트 작업 지침 + +이 지침은 저장소 전체에 적용한다. 명시적인 사용자 요청과 상위 작업 지침을 우선하며, 작업 시작 시 실제 소스와 설정을 다시 확인한다. + +## 1. 프로젝트 이해 + +- `CommonUtils`는 여러 iOS 앱에서 사용하는 Swift Package Manager 공통 라이브러리다. 독립 실행 앱이 아니므로 공개 API 변경은 사용하는 앱에 영향을 준다. +- 패키지·제품·소스 대상 이름은 `CommonUtils`, 테스트 대상은 `CommonUtilsTests`다. +- `Sources/Extensions`: Foundation·UIKit·AVFoundation 등의 확장. 기존 `타입+Extension.swift` 명명과 배치를 따른다. +- `Sources/Function`: 공통 함수, 공유 파일 준비, 영상 압축, TTS, 연결 상태, 로그, 한글 검색 등. +- `Sources/Model`, `Sources/Views`: 공통 설정·모델과 UIKit 뷰·레이아웃. +- `Sources/CombineCocoa`: UIKit 이벤트를 Combine으로 연결하는 Publisher·Subscription. +- `Sources/MoyaNetwork`: Moya·Combine 요청, 응답 변환, 오류 처리와 네트워크 로그. +- `Sources/Resources`: 한국어 기본 지역화와 이미지 리소스. `Bundle.module`을 사용한다. +- `Tests/CommonUtilsTests`: XCTest 단위 테스트. 이미지·동영상 샘플은 `TestSampleData`에 있다. + +## 2. 기본 작업 방식과 변경 범위 + +1. `git status --short`로 기존 변경을 확인하고, 요청과 관련된 소스·테스트·`Package.swift`를 읽는다. +2. 한국어와 Markdown으로 목표 요약 → 실행 계획(3~7줄) → 변경 내용 → 검증 방법 순서로 소통한다. +3. 기존 코드 스타일·구조·명명·접근 수준을 유지하고 관련 파일만 최소한으로 수정한다. 전체 자동 포맷, 무관한 파일 이동·리네이밍·경고 정리는 하지 않는다. +4. 신규 기능은 구현과 테스트를 함께 작성하고, 버그 수정은 재현되는 회귀 테스트를 가능한 범위에서 추가한다. +5. 관련 테스트부터 실행하고, 영향에 따라 전체 테스트와 빌드를 진행한다. 결과·미검증 항목·원인·필요한 다음 단계를 구분해서 보고한다. +6. 불확실한 내용은 추정/가설로 표시한다. 확인 질문은 최대 3개로 제한하고, 질문과 무관한 작업은 계속한다. + +- 요청 밖의 대규모 리팩터링·폴더 재구성은 근거와 범위를 제안하고 승인 후 진행한다. +- 새 의존성 추가, 빌드 설정 변경, CI 수정은 목적·대안·영향 범위를 설명하고 승인 후 진행한다. 이미 승인된 범위는 다시 확인하지 않는다. +- 시크릿·API 키·토큰을 소스, 샘플 데이터, 로그, 커밋에 넣지 않는다. 유출이 의심되면 작업을 중단하고 안전한 대응 계획을 제시한다. + +## 3. iOS 15 지원 기준 + +- **신규 기능과 수정의 최소 지원 기준은 iOS 15.0이다.** iOS 15에서 실행할 수 있는 API와 의존성을 선택한다. +- iOS 16 이상의 API는 정확한 도입 버전으로 `if #available` 또는 `@available`을 적용한다. 공통 기능에는 iOS 15 대체 동작을 제공하고, 최신 OS 전용 기능은 제한을 공개 API에 명시한다. +- 최신 SDK로 빌드가 성공했다는 사실과 iOS 15에서 실행된다는 사실을 구분한다. iOS 15 실행 환경이 없으면 호환성 검토와 실제 실행 검증의 범위를 따로 보고한다. +- 기존 iOS 13/14 가드나 호환 코드를 이번 변경과 무관하게 일괄 삭제하지 않는다. +- iPad, 회전, Split View, safe area를 고려하고, 화면 크기는 실제 뷰·레이아웃 가이드를 기준으로 계산한다. + +### 현재 패키지 설정: 2026-10-07 갱신 + +- `Package.swift`는 iOS 15, macOS 12, tvOS 15, watchOS 9를 선언한다. iOS 최소 지원 기준은 `.iOS(.v15)`로 유지한다. +- 다른 플랫폼의 선언은 최신 Kingfisher·Alamofire의 최소 버전에 맞춘 것이다. UIKit 의존성이 있으므로 선언만으로 해당 플랫폼의 빌드·실행 지원을 보장하지 않는다. +- 자체 매니페스트는 `swift-tools-version: 6.4`, Swift 언어 모드 5다. 개발 환경·CI·소비 앱은 Swift 도구 버전 6.4 이상을 사용한다. +- 도구 버전 갱신과 Swift 언어 모드 전환은 별개다. 최신 컴파일러 사용을 이유로 Swift 6 언어 모드로 일괄 전환하지 않는다. + +## 4. 신규 기능의 테스트 작성 원칙 + +- **신규 기능 구현과 대응하는 테스트 코드는 같은 작업에 포함한다.** `Tests/CommonUtilsTests`에 기존 XCTest 스타일로 작성하고 `@testable import CommonUtils`를 사용한다. +- 기능에 해당하는 정상 입력, 경계값(빈 값·nil·최소/최대값), 실패 경로를 검증한다. 반환값뿐 아니라 필요한 상태 변경·콜백·자원 정리도 확인한다. +- UIKit·공통 UI 상태 테스트는 메인 스레드 또는 `@MainActor`에서 수행한다. 변경한 전역 설정·싱글톤 상태는 `defer` 또는 `tearDown`으로 원복한다. +- 네트워크는 `MoyaProvider.immediatelyStub` 및 기존 mock 패턴을 우선 사용한다. 실제 서버·외부 인터넷·계정에 의존하는 단위 테스트를 만들지 않는다. +- TTS·오디오 세션·연결 상태는 기존 프로토콜 기반 의존성 주입 패턴을 활용한다. 테스트를 위해 공개 API를 불필요하게 늘리지 않는다. +- Combine·비동기 콜백은 expectation과 명시적인 timeout으로 검증한다. 임의의 `sleep` 대신 완료·실패·취소 및 이벤트 전달을 검증하고 구독을 정리한다. +- 파일·영상 테스트는 `Bundle.module`의 샘플과 테스트별 임시 디렉터리를 사용하고 생성 파일을 정리한다. 필수 샘플 누락은 실패로 처리하고 테스트를 통과시키기 위한 `XCTSkip`을 추가하지 않는다. +- 날짜·문자열 테스트는 필요에 따라 locale·time zone·기준 시각을 고정한다. 테스트 순서나 기기 설정에 의존하지 않게 한다. +- OS 전용 테스트의 skip은 실제 API 가용성으로 제한하고 이유를 명시한다. skip을 성공한 검증과 동일하게 보고하지 않는다. +- UI 표시·사진 권한·공유 시트·실제 음성 재생처럼 단위 테스트만으로 확인하기 어려운 부분은 자동 테스트와 함께 소비 앱에서의 수동 검증 절차·성공 기준을 적는다. + +## 5. 공통 라이브러리 구현 원칙 + +- **공통 기능은 기존 공통 모듈의 API를 우선 사용한다.** 구현 전에 관련 모듈·확장·유틸리티를 검색하고, 같은 역할의 로직을 화면이나 기능 코드에 다시 구현하지 않는다. +- **공통 모듈에는 특정 앱·화면·업무 기능만을 위한 별도 메서드를 만들지 않는다.** 특정 기능의 정책·분기·데이터 조합은 그 기능의 소유 코드에서 처리한다. 공통 모듈에는 여러 사용처가 재사용할 수 있는 범용 동작만 둔다. +- **같은 공통 코드를 여러 곳에 중복 정의하지 않는다.** 기존 구현을 단일 정의로 재사용하고, 공통 동작 변경은 그 정의와 관련 테스트에 반영한다. 변경 범위 밖의 중복 정리가 필요하면 먼저 범위와 근거를 제안한다. +- 공개 함수의 시그니처, 기본값, 오류 타입, 콜백 전달 스레드와 기존 동작을 보존한다. 변경이 필요하면 소비 앱 영향과 마이그레이션 방법을 설명한다. +- UIKit 작업은 메인 스레드에서 처리한다. 비동기 작업의 중복 완료·취소·객체 수명과 Combine 구독 해제를 확인한다. +- `CommonNavigationAppearance.isEnabled`는 현재 명시적으로 활성화하는 설정이다. iOS 26 이상 시스템 버튼 경로와 기본 비활성 경로를 유지하고, 색상·target/action·메뉴·접근성을 보존한다. 시스템 `UIBarButtonItem`의 `customView`가 nil일 수 있음을 고려한다. +- 네트워크 변경은 `NetworkException` 매핑, HTTP 실패 응답, 디코딩 오류와 요청 취소를 확인한다. 요청·응답 로그에 인증 정보나 개인정보를 노출하지 않는다. +- 영상·오디오·공유 작업은 오디오가 없는 파일, 잘못된 입력, 임시 파일 정리, 완료·실패 경로를 확인한다. 기존 오디오 설정과 TTS 세션 정책을 무관하게 바꾸지 않는다. +- 공통 로그의 외부 전달은 기존 `DebugLoggerManager.externalLogHandler`와 테스트를 참고한다. 특정 앱의 분석·크래시 SDK를 공통 라이브러리에 임의로 추가하지 않는다. +- 리소스 변경 시 `Bundle.module`, 지역화 키와 `PrivacyInfo.xcprivacy`의 패키지 포함 여부를 확인한다. + +## 6. 패키지 최신화 기준 + +**각 작업 시점의 공식 최신 안정 버전을 조사하되, iOS 15·도구 버전·의존성 제약과 호환되는 버전을 선택한다.** beta/RC, 단순히 가장 높은 버전, 다른 저장소의 동명 패키지를 자동 적용하지 않는다. + +아래는 **2026-10-07에 확인한 참고 정보**다. 로컬 해석 기록은 `Package.resolved`를 읽은 값이며 실제 빌드에 사용된 버전을 증명하지 않는다. 이후 최신화 작업에서는 공식 릴리스와 매니페스트를 다시 확인하고 이 표를 갱신한다. + +| 패키지 | 종류 | 현재 선언 / 로컬 해석 기록 | 공식 최신 안정 버전 | 작업 기준 | +| --- | --- | --- | --- | --- | +| [Moya](https://github.com/Moya/Moya/releases) | 직접 | 15.0.3 이상, 16 미만 / 15.0.3 | 15.0.3 | 네트워크·Combine 회귀 테스트 실행 | +| [SnapKit](https://github.com/SnapKit/SnapKit/releases) | 직접 | 6.0.0 이상, 7 미만 / 6.0.0 | 6.0.0 | Swift 도구 버전 요구와 소비 앱의 레이아웃 확인 | +| [Then](https://github.com/devxoul/Then/releases) | 직접 | 3.0.0 이상, 4 미만 / 3.0.0 | 3.0.0 | 유지 후보, 다음 작업 시 재확인 | +| ProgressHUD | 직접, 사내 포크 | `uwiseone-ios/ProgressHUD`의 `master` / `a8b101d905f78178bef45c16122be0f16e54aa40` | `master` HEAD `a8b101d905f78178bef45c16122be0f16e54aa40` | 최신 브랜치 커밋 확인 완료. 다른 upstream으로 교체하지 않음 | +| [Kingfisher](https://github.com/onevcat/Kingfisher/releases) | 직접 | 8.13.0 이상, 9 미만 / 8.13.0 | 8.13.0 | iOS 15 선언과 도구 버전 요구 정합성 확인, 이미지 로딩 회귀 검증 | +| [ZIPFoundation](https://github.com/weichsel/ZIPFoundation/releases) | 직접 | 0.9.20 이상, 1.0.0 미만 / 0.9.20 | 0.9.20 | 로그 압축·파일 처리 검증 | +| [Alamofire](https://github.com/Alamofire/Alamofire/releases) | Moya의 간접 의존성, 소스에서도 import | 직접 선언 없음 / 5.12.2 | 5.12.2 | Moya 허용 범위 내 갱신 완료, 도구 버전·연결 상태 테스트 확인 | +| [RxSwift](https://github.com/ReactiveX/RxSwift/releases) | Moya의 간접 의존성 | 직접 선언 없음 / 6.10.2 | 6.10.2 | 해석 결과 점검, 사용하지 않는 제품을 새로 연결하지 않음 | +| [ReactiveSwift](https://github.com/ReactiveCocoa/ReactiveSwift/releases) | Moya의 간접 의존성 | 직접 선언 없음 / 6.7.0 | 7.2.1 | Moya 15.0.3은 6.x만 허용. 호환 6.x 유지, 7.x 강제 추가 금지 | + +- [Kingfisher 8.13.0](https://github.com/onevcat/Kingfisher/blob/8.13.0/Package.swift)은 Swift 도구 버전 6.2 이상을 요구한다. +- [SnapKit 6.0.0](https://github.com/SnapKit/SnapKit/blob/6.0.0/Package.swift)은 Swift 도구 버전 6.0, iOS/tvOS 14, macOS 12 이상을 요구한다. +- [Alamofire 5.12.2](https://github.com/Alamofire/Alamofire/blob/5.12.2/Package.swift)는 Swift 도구 버전 6.4, iOS/tvOS 15, macOS 12, watchOS 9 이상을 요구한다. 최신화 시 개발·CI·소비 앱의 도구 버전을 함께 확인한다. +- [Moya 15.0.3 매니페스트](https://github.com/Moya/Moya/blob/15.0.3/Package.swift)의 Alamofire 5.x, RxSwift 6.x, ReactiveSwift 6.x 제약을 존중한다. + +### 최신화 절차 + +1. 현재 `Package.swift`, 로컬 `Package.resolved`, 관련 checkout revision과 소비 앱의 의존성 설정을 확인한다. 오래된 `.build` checkout을 최신 또는 현재 적용 버전으로 간주하지 않는다. +2. 공식 릴리스·마이그레이션 문서·해당 태그의 `Package.swift`에서 안정 버전, 최소 OS·도구 버전, API 변경과 간접 의존성 범위를 확인한다. +3. 변경 목적·후보 버전·영향·대안을 정리한다. 새 의존성·빌드 설정·CI 변경은 승인 후 적용한다. 최신 버전이 iOS 15와 호환되지 않으면 호환 가능한 최신 안정 버전을 제안한다. +4. 승인되거나 요청에 포함된 패키지만 선언 범위·해석 결과를 갱신한다. 의존성 작업과 무관한 빌드에서 전체 패키지 업데이트를 실행하지 않는다. +5. 관련 회귀 테스트, 전체 iOS Simulator 테스트, 필요 시 Debug/Release 빌드를 실행하고 선언 하한·해석 버전·실제 checkout을 대조한다. +6. 적용 버전·확인 날짜·남은 제약을 이 문서에 갱신한다. 실패 시 해당 작업에서 변경한 선언과 해석 결과만 되돌리고 기존 사용자 변경을 보존한다. + +현재 `Package.resolved`는 `.gitignore` 대상이다. 임의로 강제 추가하거나 추적 정책을 바꾸지 않는다. 로컬 해석 파일이 없는 환경에서는 `Package.swift`의 범위 안에서 버전이 다시 선택되므로 검증에 사용한 실제 버전도 보고한다. + +## 7. 빌드·테스트·검증 + +저장소 루트에서 실행한다. UIKit을 사용하므로 macOS용 `swift test` 결과를 iOS 검증으로 대신하지 않는다. 먼저 패키지 선언과 의존성의 최소 OS·도구 버전 불일치를 확인하고, 실패 원인을 숨기기 위해 배포 대상을 명령행에서 강제로 덮어쓰지 않는다. + +```sh +# 환경과 패키지 scheme 확인 +xcodebuild -version +xcrun swift --version +xcodebuild -list -disableAutomaticPackageResolution -skipPackageUpdates + +# 테스트 가능한 Simulator 확인: 출력에서 실제 사용 가능한 UDID 선택 +xcodebuild -scheme CommonUtils -showdestinations \ + -disableAutomaticPackageResolution -skipPackageUpdates +xcrun simctl list devices available +``` + +아래 `SIMULATOR_UDID`를 위 출력의 실제 ID로 바꾼다. `CommonUtils` scheme이 출력에 없다면 확인된 scheme을 사용하고 차이를 보고한다. 로컬 해석 파일이 없으면 의도한 버전을 먼저 resolve한 뒤 버전을 기록한다. + +```sh +# 변경과 관련된 테스트 먼저 실행: 테스트 클래스는 변경 대상에 맞게 선택 +xcodebuild -scheme CommonUtils -configuration Debug \ + -destination 'platform=iOS Simulator,id=SIMULATOR_UDID' \ + -disableAutomaticPackageResolution -skipPackageUpdates \ + -only-testing:CommonUtilsTests/NavigationAppearanceTests test + +# 전체 테스트 +xcodebuild -scheme CommonUtils -configuration Debug \ + -destination 'platform=iOS Simulator,id=SIMULATOR_UDID' \ + -disableAutomaticPackageResolution -skipPackageUpdates test + +# 공개 API·의존성·플랫폼 영향이 있으면 빌드도 확인 +xcodebuild -scheme CommonUtils -configuration Release \ + -destination 'generic/platform=iOS' \ + -disableAutomaticPackageResolution -skipPackageUpdates \ + CODE_SIGNING_ALLOWED=NO build + +# 변경 파일·공백 오류 확인 +git diff --check +git status --short +``` + +- 성공 기준: 명령 종료 코드 0, 테스트 `TEST SUCCEEDED`, 빌드 `BUILD SUCCEEDED`, 관련 테스트 실패 0건. skip·기존 실패·새 경고는 별도로 보고한다. +- 문서만 바꾼 경우에는 경로·명령 옵션·설정과의 일치, 공백 오류와 변경 범위를 확인한다. 기능을 구현하지 않은 문서 변경에 테스트 코드를 추가하지 않는다. +- OS 분기가 바뀌면 iOS 15 기본 경로와 최신 OS 경로를 각각 검증한다. 해당 실행 환경이 없으면 미검증으로 기록하고 소비 앱에서 확인할 절차를 제시한다. +- UI 변경은 소비 앱에서 색상·동작·메뉴·safe area·스크롤 마지막 콘텐츠를 확인한다. 미디어·공유·TTS 변경은 파일 결과와 실제 재생·공유 동작도 확인한다. +- 의존성 갱신 후 모듈 캐시 오류가 발생하면 별도의 새 `-derivedDataPath`로 검증한다. 전역 캐시나 다른 앱의 빌드 결과를 임의로 삭제하지 않는다. + +## 8. Git과 완료 보고 + +- 사용자의 기존 변경을 되돌리거나 무관한 파일을 stage하지 않는다. 커밋은 요청 또는 승인된 작업 범위에서만 수행한다. +- 새 브랜치가 필요하면 활성 상위 지침의 명명 규칙을 따른다. 한 커밋에는 한 가지 논리적 변경만 담고 기능·포맷·리네이밍을 섞지 않는다. +- 커밋 메시지는 활성 상위 지침과 최근 저장소의 한국어 형식을 따른다: `[YYYYMMDD] 키워드: 내용`. 날짜는 커밋 생성일을 사용한다. +- 최종 보고에는 변경 파일과 이유, 실행한 명령·성공 기준·실제 결과, 미검증 항목, 필요한 경우 변경 부분만 되돌리는 방법을 포함한다. +- 빌드·자동 테스트 성공, 기기·소비 앱 실행 확인, 외부 서비스 확인을 구분하고 마지막에 `git status --short`로 변경 범위를 재확인한다. From 4228e16da14ef9e1842e0cb5c482b07415165d04 Mon Sep 17 00:00:00 2001 From: "dongju.lim" Date: Mon, 12 Oct 2026 15:53:17 +0900 Subject: [PATCH 09/11] =?UTF-8?q?[20261012]=20=EB=B2=84=EA=B7=B8=EC=88=98?= =?UTF-8?q?=EC=A0=95:=20UI=20=EB=92=A4=EB=A1=9C=20=EB=B0=8F=20=EB=8B=AB?= =?UTF-8?q?=EA=B8=B0=20=EB=B2=84=ED=8A=BC=20=EC=A0=91=EA=B7=BC=EC=84=B1=20?= =?UTF-8?q?=EB=9D=BC=EB=B2=A8=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 버튼 아이콘 선택과 동일한 조건으로 지역화 라벨을 지정하고 기존 target/action을 유지한다. 회귀 테스트에서 target 동일성, 접근성 라벨, 숨김 동작을 검증한다. 검증: 로컬 iOS 26.2 Simulator 전체 462개 통과; 영어/미국 내비게이션 테스트 11개 통과. --- Sources/Extensions/UIViewController+Extension.swift | 6 ++++-- Tests/CommonUtilsTests/NavigationAppearanceTests.swift | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Sources/Extensions/UIViewController+Extension.swift b/Sources/Extensions/UIViewController+Extension.swift index d1a9b6a..22db811 100644 --- a/Sources/Extensions/UIViewController+Extension.swift +++ b/Sources/Extensions/UIViewController+Extension.swift @@ -107,12 +107,13 @@ public extension UIViewController { if hidden { self.navigationItem.setLeftBarButton(nil, animated: true) } else { + let isCloseButton = navigationController?.viewControllers.count == 1 || navigationController == nil var backImage: UIImage? = UIImage(named: "actionbar_btn_back", in: Bundle.module, with: nil)?.withRenderingMode(.alwaysTemplate) if (self.presentingViewController != nil) { - if navigationController?.viewControllers.count == 1 || navigationController == nil { + if isCloseButton { backImage = UIImage(named: "actionbar_btn_close", in: Bundle.module, with: nil)?.withRenderingMode(.alwaysTemplate) } - } else if navigationController?.viewControllers.count == 1 || navigationController == nil { + } else if isCloseButton { backImage = UIImage(named: "actionbar_btn_close", in: Bundle.module, with: nil)?.withRenderingMode(.alwaysTemplate) } @@ -123,6 +124,7 @@ public extension UIViewController { target: target, action: action ) + navigationItem.leftBarButtonItem?.accessibilityLabel = (isCloseButton ? "navigation_close" : "navigation_back").localization(true) } } diff --git a/Tests/CommonUtilsTests/NavigationAppearanceTests.swift b/Tests/CommonUtilsTests/NavigationAppearanceTests.swift index a52acce..b60a837 100644 --- a/Tests/CommonUtilsTests/NavigationAppearanceTests.swift +++ b/Tests/CommonUtilsTests/NavigationAppearanceTests.swift @@ -92,13 +92,13 @@ final class NavigationAppearanceTests: XCTestCase { detail.hideNavigationLeftButton(hidden: false, target: target, action: #selector(ButtonTarget.tap)) navigation.popViewController(animated: false) root.hideNavigationLeftButton(hidden: false, target: target, action: #selector(ButtonTarget.tap)) + XCTAssertTrue(detail.navigationItem.leftBarButtonItem?.target as? ButtonTarget === target) + XCTAssertTrue(root.navigationItem.leftBarButtonItem?.target as? ButtonTarget === target) XCTAssertEqual(detail.navigationItem.leftBarButtonItem?.action, #selector(ButtonTarget.tap)) XCTAssertEqual(root.navigationItem.leftBarButtonItem?.action, #selector(ButtonTarget.tap)) XCTAssertTrue(detail.navigationItem.hidesBackButton) - if #available(iOS 26.0, *) { - XCTAssertEqual(detail.navigationItem.leftBarButtonItem?.accessibilityLabel, "navigation_back".localization(true)) - XCTAssertEqual(root.navigationItem.leftBarButtonItem?.accessibilityLabel, "navigation_close".localization(true)) - } + XCTAssertEqual(detail.navigationItem.leftBarButtonItem?.accessibilityLabel, "navigation_back".localization(true)) + XCTAssertEqual(root.navigationItem.leftBarButtonItem?.accessibilityLabel, "navigation_close".localization(true)) detail.hideNavigationLeftButton(hidden: true) XCTAssertNil(detail.navigationItem.leftBarButtonItem) } From 53136f78fb17f6450f3461f41b1741caad286204 Mon Sep 17 00:00:00 2001 From: "dongju.lim" Date: Mon, 12 Oct 2026 15:53:58 +0900 Subject: [PATCH 10/11] =?UTF-8?q?[20261012]=20=EB=AC=B8=EC=84=9C:=201.0.18?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=EC=9D=B4=EB=A0=A5=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.0.17 태그 이후의 기능, 버그 수정, 의존성과 검증 결과를 정리한다. 기존 버전 이력은 유지하고 언어·시간대 및 CI 도구 버전 제약을 명시한다. 검증: 기존 이력 보존, 릴리즈 워크플로의 1.0.18 본문 추출, 공백 오류 확인. --- CHANGELOG.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa382ea..7846392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,48 @@ - 다음 릴리즈에 포함할 변경사항을 이곳에 정리합니다. +## [1.0.18] - 2026-10-12 + +### 빌드/설정 + +- 최소 지원 기준을 iOS 15, macOS 12, tvOS 15, watchOS 9로 조정했습니다. UIKit을 사용하는 패키지이므로 다른 플랫폼의 선언만으로 해당 플랫폼의 빌드·실행 지원을 보장하지 않습니다. +- Swift 도구 버전을 6.4로 상향하고 Swift 언어 모드는 5로 유지했습니다. 소비 앱과 CI도 Swift 6.4 이상의 도구가 필요합니다. +- SnapKit 최소 버전을 6.0.0, Kingfisher 최소 버전을 8.13.0으로 상향했습니다. Moya 15.0.3, Then 3.0.0, ZIPFoundation 0.9.20의 선언 하한은 유지했습니다. +- ProgressHUD 의존성을 `uwiseone-ios/ProgressHUD` 포크의 `master` 브랜치로 변경했습니다. 검증 시 사용한 revision은 `a8b101d905f78178bef45c16122be0f16e54aa40`입니다. +- 간접 의존성은 Alamofire 5.12.2, RxSwift 6.10.2, ReactiveSwift 6.7.0으로 해석해 검증했습니다. ReactiveSwift는 Moya 15.0.3이 허용하는 6.x 범위를 유지했습니다. + +### 기능추가 + +- `DebugLog`에 `externalEventName`을 추가하고 `DebugLoggerManager.externalLogHandler`를 통해 이벤트 이름, 메시지, 레벨, 파라미터와 호출 위치를 소비 앱에 전달할 수 있도록 했습니다. 이벤트를 지정한 로그는 콘솔·파일 기록 설정과 독립적으로 전달됩니다. +- `CommonNavigationAppearance`와 `CommonNavigationBarStyle`을 추가했습니다. 기본값은 기존 동작을 유지하며, 명시적으로 활성화하면 iOS 26 이상에서 공통 이미지·메뉴 버튼을 시스템 `UIBarButtonItem`으로 생성합니다. +- 내비게이션바의 기본·시스템·사용자 지정 배경 스타일과 compact appearance 설정을 지원하고, `UIImage.centeredNavigationIcon()`으로 이미지의 투명 여백을 고려한 아이콘 정렬을 추가했습니다. +- 공유 항목의 알려진 형식에 해당하는 `Data`·`NSData`를 세션별 임시 파일로 준비하고 정리하는 내부 `ActivityShareItemPreparer`를 추가했습니다. 현재 공개 `showActivityViewController`에는 연결되지 않은 내부 유틸리티입니다. + +### 버그수정 + +- 영상 압축에서 비디오 트랙과 오디오 형식 정보를 안전하게 확인하고, AAC 채널 수와 `AVChannelLayoutKey`의 크기·채널 수·레이아웃을 검증하도록 보강했습니다. +- 다채널 오디오의 유효한 레이아웃을 유지하고, 지원하지 않는 오디오 설정은 writer 구성 전에 `unsupportedAudioFormat` 오류로 반환하도록 했습니다. 압축 입력·다채널 처리·오디오 설정 거부 이벤트도 외부 로그 처리기에 전달합니다. +- `UIButton.alignTextBelow(spacing:)`가 기존 버튼 설정을 유지하면서 이미지를 제목 위에 배치하도록 수정했습니다. +- `hideNavigationLeftButton(hidden:target:action:)`의 뒤로·닫기 버튼에 지역화된 접근성 라벨을 설정했습니다. 전달받은 target/action과 기존 아이콘 선택 동작은 유지합니다. + +### 테스트 + +- 외부 로그 전달, 공유 임시 파일 준비·실패 시 원본 유지·정리, 내비게이션 스타일·아이콘 정렬·메뉴·동작 보존에 대한 회귀 테스트를 추가했습니다. +- 영상 압축의 무음·다채널·잘못된 오디오 설정 경로와 버튼의 이미지·제목 배치 및 기존 설정 보존을 검증했습니다. +- `testBackAndCloseKeepTheirActions`에서 target/action, 뒤로·닫기 접근성 라벨과 숨김 동작을 확인하도록 보강했습니다. 접근성 라벨 검증은 iOS 26 전용 조건 없이 수행합니다. +- 날짜·문자열 테스트의 기본 포맷 기대값을 현재 출력에 맞췄습니다. 언어·시간대에 따른 실패 가능성은 아래 검증 제약에 별도로 기록했습니다. + +### 문서 + +- `AGENTS.md`에 공통 라이브러리의 iOS 15 지원, 공개 API 보존, 테스트와 검증 기준을 정리했습니다. +- README를 현재 패키지 구성, 실제 공개 API, 의존성, 내비게이션 활성화 방법과 테스트·릴리즈 절차에 맞게 다시 작성했습니다. + +### 검증 및 남은 제약 + +- Xcode 27.0 / Swift 6.4 / iPhone 17 Pro Simulator(iOS 26.2)의 로컬 기본 환경에서 전체 462개 테스트가 통과했습니다. iOS 15 기기·시뮬레이터 실행과 소비 앱의 UI·VoiceOver 동작은 별도 검증이 필요합니다. +- 영어/미국 설정에서는 내비게이션 테스트 11개가 통과했지만, `StringExtensionTests.test_dateFormatChange_withDifferentFormat`은 한국어 월 이름을 고정 기대값으로 사용해 실패하는 것을 재현했습니다. 날짜 테스트에도 KST 입력과 현재 시간대의 차이에 따른 실패 가능성이 남아 있습니다. +- 현재 PR 테스트 워크플로는 `macos-15`에서 별도의 Xcode 선택 없이 실행합니다. Swift 6.4 이상 도구와 사용 가능한 Simulator를 확인해야 하며, 영상 통합 테스트에는 충분한 디스크 공간이 필요합니다. 이번 버전 문서 작업에서 CI 설정은 변경하지 않았습니다. + ## [1.0.17] - 2026-06-23 ### 빌드/설정 From 44314ee7b2f8ad342cc5d8ef00513713aa65cc80 Mon Sep 17 00:00:00 2001 From: "dongju.lim" Date: Mon, 12 Oct 2026 15:54:15 +0900 Subject: [PATCH 11/11] =?UTF-8?q?[20261012]=20=EB=AC=B8=EC=84=9C:=20?= =?UTF-8?q?=ED=98=84=EC=9E=AC=20CommonUtils=20=EA=B5=AC=EC=84=B1=EA=B3=BC?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=20=EC=95=88=EB=82=B4=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 현재 플랫폼·Swift 도구 버전·의존성에 맞춰 설치와 사용 안내를 재작성한다. 실제 공개 API의 사용 예시와 내비게이션 활성화, 로그·영상 처리 조건, 테스트·릴리즈 제약을 설명한다. 검증: Swift 예시 10개 컴파일 검사, 문서 링크 16개 확인, 셸 명령 문법 및 공백 검사 통과. --- README.md | 380 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 299 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index d85966c..cca397b 100644 --- a/README.md +++ b/README.md @@ -1,150 +1,368 @@ # CommonUtils -`CommonUtils`는 iOS 앱 개발에서 반복적으로 사용하는 확장 함수, UI 헬퍼, 네트워크 유틸, 미디어 처리 기능을 모아둔 Swift Package입니다. +`CommonUtils`는 여러 iOS 앱에서 사용하는 Swift Package 공통 라이브러리입니다. Foundation·UIKit 확장, Combine 이벤트, Moya 네트워크 요청, 이미지 로딩, 영상 압축과 공통 UI 기능을 제공합니다. + +이 문서는 1.0.18에 포함할 현재 소스와 설정을 기준으로 작성했습니다. 버전별 변경 내용은 [CHANGELOG.md](CHANGELOG.md)를 참고하세요. ## 주요 기능 -- `String`, `Int`, `Date`, `UIColor`, `UIImage`, `UIView` 등 Foundation/UIKit 타입 확장 -- Combine 기반 UIKit control event/control property publisher -- Moya 기반 네트워크 provider 및 logging helper -- Kingfisher 기반 이미지 로딩 helper와 ProgressHUD wrapper -- 비디오 정보 추출, 비디오 압축, MIME 타입 판별, TTS, 한글 검색 유틸 -- collection view layout, padding label 등 공통 UI 컴포넌트 +| 영역 | 제공 기능 | 관련 소스 | +| --- | --- | --- | +| 타입 확장 | 문자열·날짜·숫자·색상·이미지·뷰·CoreData 등 공통 확장 | [Sources/Extensions](Sources/Extensions) | +| UIKit 이벤트 | 버튼·텍스트필드·스크롤뷰·제스처 등의 Combine publisher | [Sources/CombineCocoa](Sources/CombineCocoa) | +| 네트워크 | Moya 요청 publisher, 응답 디코딩, 상태 코드 오류와 요청·응답 로깅 | [Sources/MoyaNetwork](Sources/MoyaNetwork) | +| 이미지·로딩 UI | Kingfisher 이미지 다운로드·다운샘플링·캐시 설정, ProgressHUD 설정과 표시 | [CommonUtils.swift](Sources/Function/CommonUtils.swift), [UIImageView 확장](Sources/Extensions/UIImageView+Extension.swift) | +| 내비게이션 | 내비게이션바 appearance, iOS 26 시스템 버튼, 아이콘 정렬·메뉴·접근성 라벨 | [CommonNavigationAppearance.swift](Sources/Model/CommonNavigationAppearance.swift) | +| 미디어·기기 유틸 | 영상 정보·압축, MIME 판별, TTS, 한글 초성·자모 처리, 연결 상태 감시 | [Sources/Function](Sources/Function) | +| 로그·공통 뷰 | 파일 로그·ZIP 압축·외부 로그 전달, collection view layout와 padding label | [DebugLoggerManager.swift](Sources/Function/DebugLoggerManager.swift), [Sources/Views](Sources/Views) | ## 요구사항 -- Swift tools version: `5.10` -- Swift language version: `5` -- iOS 13.0+ -- macOS 10.15+ -- tvOS 13.0+ -- watchOS 6.0+ +| 항목 | 현재 기준 | +| --- | --- | +| Swift 도구 버전 | **6.4 이상** (`swift-tools-version: 6.4`) | +| Swift 언어 모드 | **5** (`swiftLanguageModes: [.v5]`) | +| iOS | **15.0 이상** | +| 기타 플랫폼 선언 | macOS 12, tvOS 15, watchOS 9 | +| 기본 지역화 | 한국어 (`defaultLocalization: "ko"`) | +| 제품·소스 대상 / 테스트 대상 | `CommonUtils` / `CommonUtilsTests` | -> 이 패키지는 UIKit 기반 기능과 `ProgressHUD` 의존성이 포함되어 있어 실제 테스트/빌드는 iOS 대상에서 검증하는 것을 권장합니다. +패키지는 UIKit, Photos, AVFoundation 및 ProgressHUD를 사용합니다. macOS·tvOS·watchOS 버전 선언만으로 해당 플랫폼의 빌드·실행 지원을 보장하지 않으며, 빌드와 테스트는 iOS 대상을 기준으로 확인합니다. macOS에서 실행하는 `swift test`는 iOS Simulator 테스트를 대신할 수 없습니다. -## 의존성 - -| 패키지 | 버전 범위 | -| --- | --- | -| Moya | `15.0.3 ..< 16.0.0` | -| SnapKit | `5.7.1 ..< 6.0.0` | -| Then | `3.0.0 ..< 4.0.0` | -| ProgressHUD | `14.1.4 ..< 15.0.0` | -| Kingfisher | `8.10.0 ..< 9.0.0` | -| ZIPFoundation | `0.9.20 ..< 1.0.0` | +1.0.17에서 갱신하는 소비 앱은 iOS 최소 버전과 Swift 도구 버전을 먼저 확인하세요. 최신 컴파일러를 사용해도 패키지의 Swift 언어 모드는 5로 유지됩니다. ## 설치 ### Xcode -1. Xcode에서 프로젝트를 엽니다. -2. **File > Add Packages...** 를 선택합니다. -3. 저장소 URL을 입력합니다. +1. **File > Add Package Dependencies…**에서 아래 저장소 URL을 입력합니다. +2. 사용할 태그 또는 브랜치를 선택합니다. +3. `CommonUtils` 제품을 앱 대상의 의존성에 추가합니다. ```text https://github.com/appsstudioio/CommonUtils.git ``` -4. 사용할 버전을 선택한 뒤 `CommonUtils`를 앱 타겟에 추가합니다. +1.0.18 버전으로 설치하려면 해당 태그가 원격 저장소에 게시되어 있어야 합니다. 태그 게시 전에는 검토할 브랜치나 로컬 패키지를 선택하세요. ### Package.swift -```swift -dependencies: [ - .package(url: "https://github.com/appsstudioio/CommonUtils.git", from: "1.0.0") -] -``` +소비 패키지에서 사용하는 예시입니다. `AppSupport`는 소비 프로젝트의 실제 대상 이름으로 바꿉니다. ```swift -targets: [ - .target( - name: "YourTargetName", - dependencies: ["CommonUtils"] - ) -] +// swift-tools-version: 6.4 +import PackageDescription + +let package = Package( + name: "AppModules", + platforms: [.iOS(.v15)], + dependencies: [ + .package(url: "https://github.com/appsstudioio/CommonUtils.git", from: "1.0.18") + ], + targets: [ + .target( + name: "AppSupport", + dependencies: [.product(name: "CommonUtils", package: "CommonUtils")] + ) + ] +) ``` +앱 소스에서는 `import CommonUtils`로 공개 API를 사용합니다. Moya 등 의존 패키지의 타입을 앱에서 직접 사용할 때는 해당 제품도 소비 대상의 의존성에 연결합니다. + +## 의존성 + +[Package.swift](Package.swift)의 직접 의존성 선언입니다. + +| 패키지 | 선언 범위 | 용도 | +| --- | --- | --- | +| [Moya](https://github.com/Moya/Moya) | `15.0.3 ..< 16.0.0` | 네트워크 요청·응답 | +| [SnapKit](https://github.com/SnapKit/SnapKit) | `6.0.0 ..< 7.0.0` | 레이아웃 | +| [Then](https://github.com/devxoul/Then) | `3.0.0 ..< 4.0.0` | 객체 설정 | +| [ProgressHUD](https://github.com/uwiseone-ios/ProgressHUD) | 사내 포크의 `master` | 로딩·진행률 UI | +| [Kingfisher](https://github.com/onevcat/Kingfisher) | `8.13.0 ..< 9.0.0` | 이미지 다운로드·캐시 | +| [ZIPFoundation](https://github.com/weichsel/ZIPFoundation) | `0.9.20 ..< 1.0.0` | 파일 로그 압축 | + +2026-10-12 로컬 테스트에서는 Moya 15.0.3, SnapKit 6.0.0, Then 3.0.0, Kingfisher 8.13.0, ZIPFoundation 0.9.20과 ProgressHUD revision `a8b101d905f78178bef45c16122be0f16e54aa40`을 사용했습니다. 간접 의존성은 Alamofire 5.12.2, RxSwift 6.10.2, ReactiveSwift 6.7.0입니다. 실제 선택 버전은 소비 프로젝트의 해석 결과에 따라 달라질 수 있고, 브랜치 의존성은 고정 버전과 달리 이후 커밋을 선택할 수 있습니다. + ## 사용 예시 +### 문자열·숫자·날짜·한글 처리 + +날짜나 숫자 출력의 형식이 계약으로 정해져 있다면 locale과 time zone을 명시합니다. 기본값은 현재 환경의 설정을 사용합니다. + ```swift -import CommonUtils +import Foundation import UIKit +import CommonUtils -let formatted = 1234567.withCommas() -print(formatted) // 1,234,567 - +let formatted = 1234567.withCommas(Locale(identifier: "en_US_POSIX")) +// "1,234,567" let phoneNumber = "0212345678".toPhoneNumberFormat() -print(phoneNumber) // 02-1234-5678 - +// "02-1234-5678" let color = UIColor(hex: "#FF9900") -print(color?.toHex(isAlpha: false) ?? "") +let initials = HangeulSearch.getCho("한글") +// "ㅎㄱ" + +let locale = Locale(identifier: "en_US_POSIX") +let timeZone = TimeZone(secondsFromGMT: 9 * 60 * 60)! +let date = "2024-01-01 15:20:00".toDate(locale: locale, timeZone: timeZone) +let text = date?.toString(format: "yyyy-MM-dd HH:mm", timeZone: timeZone, locale: locale) +// "2024-01-01 15:20" ``` -### 이미지 로딩 +### 이미지 로딩과 캐시 + +`UIImageView.setUrlImage`는 이전 다운로드를 취소한 뒤 이미지를 요청합니다. `imageResize`를 지정하지 않으면 레이아웃이 완료된 뷰의 크기를 기준으로 다운샘플링합니다. 크기가 아직 0이라면 다운샘플링을 적용하지 않습니다. ```swift -imageView.setImage( - "https://example.com/image.jpg", - placeholder: UIImage(named: "placeholder"), - imageResize: CGSize(width: 300, height: 300) -) +@MainActor +func loadImages(thumbnail: UIImageView, original: UIImageView) { + thumbnail.setUrlImage( + "https://example.com/image.jpg", + placeholder: UIImage(named: "placeholder"), + imageResize: CGSize(width: 300, height: 300) + ) + + original.setUrlImage( + "https://example.com/image.jpg", + skipDownsampling: true, + isIndicator: false + ) +} ``` -원본 크기 캐시/표시가 필요하면 다운샘플링을 건너뛸 수 있습니다. +`skipDownsampling: true`는 공통 헬퍼의 다운샘플링 적용을 건너뜁니다. 추가 `options`로 전달한 Kingfisher processor까지 제거하지는 않습니다. 재사용 셀에서는 `stopDownloadTask()`로 이미지와 이전 요청을 정리할 수 있습니다. + +캐시와 로딩 UI의 공통 설정은 앱 초기화 시 메인 스레드에서 적용합니다. `CommonKingfisherConfig`의 기본값은 메모리 200 MiB·10개, 디스크 2,000 MiB, 디스크 만료 14일, 메모리 만료 300초입니다. ```swift -imageView.setImage( - "https://example.com/image.jpg", - skipDownsampling: true -) +@MainActor +func configureCommonUI() { + CommonUtils.kingfisherConfig( + CommonKingfisherConfig(diskStorageSizeLimit: 500 * 1024 * 1024) + ) + CommonUtils.progressHUDConfig(CommonProgressHUDConfig(colorStatus: .label)) +} ``` ### Combine UIKit 이벤트 +구독을 유지할 `AnyCancellable`을 보관합니다. UI 변경은 메인 스레드에서 처리하고, 화면이나 기능의 수명이 끝나면 구독을 해제합니다. + ```swift -button.tapPublisher - .sink { - print("button tapped") +import Combine + +@MainActor +final class ExampleViewController: UIViewController { + private var cancellables = Set() + private let button = UIButton(type: .system) + + override func viewDidLoad() { + super.viewDidLoad() + button.tapPublisher + .sink { print("button tapped") } + .store(in: &cancellables) } - .store(in: &cancellables) +} ``` -## 테스트 +버튼 외에 `UIControl`, `UIBarButtonItem`, `UITextField`, `UIScrollView`, `UISwitch`, `UISlider`, `UIDatePicker`와 제스처 확장이 있습니다. -로컬에서 iOS 빌드를 확인하려면 다음 명령을 사용합니다. +### 내비게이션바와 iOS 26 시스템 버튼 -```bash -xcodebuild -scheme CommonUtils -destination generic/platform=iOS build +`CommonNavigationAppearance.isEnabled`의 기본값은 `false`입니다. 앱에서 활성화하면 iOS 26 이상에서 `UIBarButtonItem.createUIBarButtonItem`의 이미지·메뉴 팩토리가 시스템 버튼을 생성합니다. iOS 15~25와 비활성 상태에서는 기존 사용자 지정 버튼 경로를 사용합니다. + +```swift +@MainActor +func configureNavigation(_ navigation: UINavigationController, target: Any, action: Selector) { + CommonNavigationAppearance.isEnabled = true + CommonNavigationAppearance.buttonTintColor = .label + + navigation.navigationBar.setNavigationBarStyle( + .systemBackground, + fontColor: .label, + font: .systemFont(ofSize: 17, weight: .semibold), + style: .system + ) + + navigation.topViewController?.navigationItem.rightBarButtonItem = + UIBarButtonItem.createUIBarButtonItem( + image: UIImage(systemName: "plus"), + target: target, + action: action + ) +} ``` -테스트는 iOS Simulator 대상에서 실행합니다. +`setNavigationBarStyle`의 기본 스타일은 `.legacy`이며 `.system`, `.customBackground`도 선택할 수 있습니다. appearance의 `style`과 버튼 팩토리의 `isEnabled`는 별도 설정입니다. 시스템 버튼은 `customView`가 nil일 수 있으므로 기존 소비 코드의 강제 캐스팅을 확인하세요. `buttonToBarButtonItem`은 전달받은 `UIButton`을 custom view로 사용하는 API입니다. + +`hideNavigationLeftButton(hidden:target:action:)`는 root 또는 내비게이션 컨트롤러가 없는 화면에 닫기 아이콘을, 상세 화면에 뒤로 아이콘을 설정하며 지역화된 접근성 라벨과 전달받은 target/action을 유지합니다. `UIButton.alignTextBelow(spacing:)`는 이미지를 제목 위로 배치할 때 사용합니다. + +### Moya 요청과 연결 상태 -```bash -xcodebuild test -scheme CommonUtils -destination 'platform=iOS Simulator,name=iPhone 16' +`MoyaProvider.requestPublisher`는 HTTP 200~299 응답을 전달하고, 그 외 상태 코드는 `NetworkException.errorStatusCode`로 처리합니다. 요청 실패와 응답 변환 오류는 `NetworkException.serverError`로 전달합니다. 응답 publisher에서 `map`, `mapJSON`, `mapString`, `mapImage`와 상태 코드 필터를 사용할 수 있습니다. + +```swift +import Moya + +func request( + _ model: Model.Type, + target: API, + provider: MoyaProvider +) -> AnyPublisher { + provider.requestPublisher(target) + .map(model) + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() +} +``` + +앱이 provider와 구독의 수명을 관리합니다. `NetworkConnectivityManager`는 연결 상태 변경을 `networkStatusPublisher`로 제공하며 `startMonitoring()`·`stopMonitoring()`을 지원합니다. 이 publisher는 현재 상태를 재전달하지 않고, `isOnline`의 초기값은 첫 상태 통지 전까지 `true`입니다. 연결 상태는 특정 서버 요청의 성공을 보장하지 않습니다. + +### 파일 로그와 외부 이벤트 + +`DebugLog`는 콘솔 출력(`isDebugPrint`, 기본 `false`), 파일 기록(`isFileLog`, 기본 `true`), 외부 이벤트 전달을 각각 설정할 수 있습니다. 파일에는 `.debug`, `.error`, `.log`, `.trace`, `.critical`, `.fault` 레벨을 기록합니다. 기본 `.info`는 파일에 기록하지 않습니다. + +```swift +func configureExternalLogging() { + DebugLoggerManager.externalLogHandler = { event, _, level, _, _, _, _ in + // 소비 앱의 로깅 SDK 연결 위치입니다. + print("\(event): \(level.rawValue)") + } + + DebugLog( + "미디어 처리 준비", + level: .log, + param: ["stage": "prepare"], + isDebugPrint: false, + isFileLog: false, + externalEventName: "media_prepare" + ) +} ``` -Pull Request가 열리거나 갱신되면 GitHub Actions가 사용 가능한 iPhone 시뮬레이터를 선택해 테스트를 실행합니다. PR 본문은 커밋 메시지를 기준으로 자동 갱신됩니다. +처리기는 이벤트 이름, 메시지, 레벨, 파라미터, 파일, 함수, 줄 번호를 호출한 스레드에서 동기적으로 받습니다. `externalEventName`을 지정하면 콘솔·파일 기록을 꺼도 전달되므로, 앱 초기화 시 처리기를 등록하고 필요한 마스킹·전달량 제한을 적용합니다. 인증 정보나 개인정보를 메시지·파라미터에 넣지 않습니다. -## 릴리즈 +파일 로그는 앱의 `Documents/Logs` 아래에 저장하며 파일별 512 KiB, 전체 50 MiB 제한과 오래된 로그 정리를 적용합니다. `DebugLoggerManager.shared.zipLogFiles()`로 로그 ZIP 파일을 만들 수 있고, 반환값이 nil이면 실패한 것입니다. -버전 태그를 push하면 GitHub Release가 자동으로 생성됩니다. 태그는 `1.2.3` 또는 `v1.2.3` 형식을 사용할 수 있습니다. +### 영상 압축 -```bash -git tag 1.2.3 -git push origin 1.2.3 +```swift +func compress(inputURL: URL, completion: @escaping (Result) -> Void) { + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("mp4") + + CommonUtils.compressVideo( + inputURL: inputURL, + outputURL: outputURL, + quality: .medium + ) { result in + DispatchQueue.main.async { completion(result) } + } +} ``` -릴리즈 노트를 직접 관리하려면 `CHANGELOG.md`에 아래 형식으로 섹션을 추가합니다. +압축 품질은 `.low`, `.medium`, `.high`, `.original`입니다. 비디오 트랙이 없는 입력은 `videoTrackNotFound`, 지원하지 않는 오디오 구성은 `unsupportedAudioFormat` 오류를 반환합니다. 다채널 오디오는 유효한 채널 레이아웃과 AAC writer 지원 여부를 확인합니다. + +`maxFileSize`는 예상 압축 크기를 기준으로 한 사전 검사이며 실제 결과 파일 크기의 상한을 보장하지 않습니다. 입력과 다른 출력 URL을 사용하고 완료 후 필요 없어진 파일은 호출자가 정리합니다. 완료 콜백의 메인 스레드 전달은 보장하지 않으므로 UI 작업은 예시처럼 메인 큐로 전달합니다. -```markdown -## [1.2.3] - 2026-06-23 +### TTS와 로딩 UI -- 변경 내용 1 -- 변경 내용 2 +```swift +@MainActor +func playGuide() { + TTSManager.shared.play("안녕하세요", locale: Locale(identifier: "ko_KR")) +} + +@MainActor +func updateLoadingUI(progress: CGFloat) { + showProgressView("처리 중", value: progress) +} ``` -태그와 같은 버전 섹션이 있으면 해당 내용을 릴리즈 본문으로 사용하고, 없으면 GitHub의 자동 생성 릴리즈 노트를 사용합니다. +TTS 중단은 `TTSManager.shared.stop()`, 로딩 표시는 `showLoadingView`, 닫기는 `dismissLoadingView`를 사용합니다. TTS는 앱의 오디오 세션을 사용하므로 다른 재생 기능과 함께 쓰는 앱에서는 실제 재생·중단 동작을 확인합니다. + +## 소스와 리소스 구성 + +```text +Sources/ + Extensions/ Foundation·UIKit·AVFoundation 등 확장 + Function/ 공통 함수, 영상·TTS·로그·연결 상태·MIME·한글 처리 + Model/ 설정 모델, 영상 오류·품질, 내비게이션 appearance + CombineCocoa/ UIKit 이벤트·프로퍼티 publisher와 subscription + MoyaNetwork/ 네트워크 요청 publisher와 logging plugin + Views/ collection view layout와 PaddingLaebl + Resources/ 지역화 문자열과 이미지 +Tests/CommonUtilsTests/ + TestSampleData/ 이미지·영상 테스트 샘플 +``` + +`Sources/Resources`와 `Tests/CommonUtilsTests/TestSampleData`는 각 대상에서 SPM 리소스로 처리하며 내부에서 `Bundle.module`로 접근합니다. `ActivityShareItemPreparer`와 `VideoCompressionAudioSettings`는 내부 구현입니다. 공유 파일 준비 유틸은 현재 공개 `showActivityViewController`에 연결되지 않았으므로, 공유 시트 호출이 자동으로 `Data`를 임시 파일로 바꾸지는 않습니다. + +## 빌드와 테스트 + +저장소 루트에서 도구 버전과 실제 사용 가능한 Simulator를 확인합니다. + +```sh +xcodebuild -version +xcrun swift --version +xcodebuild -list -disableAutomaticPackageResolution -skipPackageUpdates +xcodebuild -scheme CommonUtils -showdestinations \ + -disableAutomaticPackageResolution -skipPackageUpdates +xcrun simctl list devices available +``` + +아래 변수에 확인한 UDID를 넣습니다. 기존 해석 결과를 사용하며, 최초 환경에서 의존성이 없으면 먼저 해석하고 실제 선택 버전을 확인합니다. + +```sh +SIMULATOR_UDID='사용 가능한 Simulator UDID' + +# 내비게이션 회귀 테스트 +xcodebuild test -scheme CommonUtils -configuration Debug \ + -destination "platform=iOS Simulator,id=${SIMULATOR_UDID}" \ + -disableAutomaticPackageResolution -skipPackageUpdates \ + -only-testing:CommonUtilsTests/NavigationAppearanceTests \ + CODE_SIGNING_ALLOWED=NO + +# 전체 테스트: 영상 통합 테스트 포함 +xcodebuild test -scheme CommonUtils -configuration Debug \ + -destination "platform=iOS Simulator,id=${SIMULATOR_UDID}" \ + -disableAutomaticPackageResolution -skipPackageUpdates \ + CODE_SIGNING_ALLOWED=NO + +# iOS Release 빌드 +xcodebuild build -scheme CommonUtils -configuration Release \ + -destination 'generic/platform=iOS' \ + -disableAutomaticPackageResolution -skipPackageUpdates \ + CODE_SIGNING_ALLOWED=NO +``` + +성공 기준은 종료 코드 0, `TEST SUCCEEDED` 또는 `BUILD SUCCEEDED`, 관련 테스트 실패 0건입니다. skip은 통과와 구분합니다. 의존성 변경 후 모듈 캐시 오류가 있다면 별도 `-derivedDataPath`와 `-clonedSourcePackagesDirPath`를 지정해 검증하고 다른 작업의 캐시는 삭제하지 않습니다. 영상 테스트 전에는 디스크 여유 공간을 확인합니다. + +### 확인된 결과와 CI 제약 + +2026-10-12에 Xcode 27.0 / Swift 6.4 / iPhone 17 Pro Simulator(iOS 26.2)의 로컬 기본 환경에서 전체 **462개 통과, 실패 0개, skip 0개**를 확인했습니다. iOS 15 실제 실행, 소비 앱의 화면·메뉴·VoiceOver, 공유 시트와 TTS 재생은 별도 검증 항목입니다. + +현재 [PR 테스트 워크플로](.github/workflows/pr-tests.yml)는 `macos-15`에서 사용 가능한 첫 iPhone Simulator를 선택합니다. 기본 job은 영상 통합 테스트 3개를 제외하고, 해당 테스트는 수동 실행 또는 PR의 `run-video-compression-tests` 라벨이 있을 때 별도 job으로 실행합니다. + +| 실패 조건 | 현재 확인한 내용 | 필요한 확인·대응 | +| --- | --- | --- | +| Swift 도구 버전 부족 | 워크플로에 명시적 Xcode 선택이 없고 패키지는 Swift 6.4 이상을 요구 | runner의 `xcodebuild -version`, `xcrun swift --version` 확인 후 호환 Xcode 선택 | +| 영어 locale | `test_dateFormatChange_withDifferentFormat`이 한국어 월 이름을 고정 기대값으로 사용하며 영어/미국 실행에서 실패 재현 | 테스트가 의도한 locale을 명시하거나 현재 locale에 맞춘 기대값으로 검증 | +| KST와 현재 시간대의 차이 | 일부 Date 테스트는 KST 입력을 만든 뒤 기본 `.current` 시간대로 변환 | 테스트 입력·변환·기대값의 time zone을 일치시키기 | +| 저장 공간 부족·영상 처리 제한 | 압축 테스트는 샘플 읽기, 임시 영상 쓰기와 인코더 실행이 필요 | 디스크·샘플·writer 오류를 확인하고 테스트가 만든 파일 정리 | +| Simulator 차이 | 선택하는 기기·OS 버전을 고정하지 않음 | 실패한 실행의 실제 UDID·OS를 기록하고 같은 대상으로 재현 | + +영어/미국 설정에서도 내비게이션 테스트 11개는 통과했습니다. 위 CI·locale·time zone 제약을 해결하는 소스나 워크플로 변경은 이번 문서 갱신에 포함하지 않았습니다. + +## 릴리즈와 기여 + +[릴리즈 워크플로](.github/workflows/release.yml)는 `1.2.3` 또는 `v1.2.3` 형식의 태그 push를 계기로 GitHub Release를 생성합니다. `CHANGELOG.md`의 `## [1.2.3] - YYYY-MM-DD` 섹션을 본문으로 사용하며, 해당 섹션이 없으면 GitHub 자동 생성 노트를 사용합니다. 릴리즈 job 자체는 빌드·테스트를 실행하지 않으므로 태그 게시 전에 위 검증을 마칩니다. + +[PR 본문 워크플로](.github/workflows/pr-description.yml)는 커밋 메시지를 기준으로 PR 내용을 갱신합니다. 공통 API를 변경할 때는 소비 앱의 동작과 iOS 15 호환성을 함께 확인하고, 정상·경계·실패 경로의 테스트를 추가합니다. 상세 작업·검증 기준은 [AGENTS.md](AGENTS.md)를 참고하세요. ## 라이선스 -이 프로젝트는 MIT 라이선스 하에 배포됩니다. 자세한 내용은 [LICENSE](LICENSE)를 참고하세요. +MIT 라이선스를 따릅니다. 자세한 내용은 [LICENSE](LICENSE)를 참고하세요.