From d8d1a407e471d3ece2aa3e8da2787389829e6fa5 Mon Sep 17 00:00:00 2001 From: "dongju.lim" Date: Tue, 23 Jun 2026 17:39:49 +0900 Subject: [PATCH 1/9] =?UTF-8?q?[20260623]=20=EB=B9=8C=EB=93=9C/=EC=84=A4?= =?UTF-8?q?=EC=A0=95:=20SDK=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=ED=95=98?= =?UTF-8?q?=EC=9C=84=20=EB=B2=84=EC=A0=84=20=EC=83=81=ED=96=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Package.resolved | 20 ++++++++++---------- Package.swift | 12 ++++++------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Package.resolved b/Package.resolved index d75fe15..f812e90 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Alamofire/Alamofire.git", "state" : { - "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", - "version" : "5.10.2" + "revision" : "7595cbcf59809f9977c5f6378500de2ad73b7ddb", + "version" : "5.12.0" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher", "state" : { - "revision" : "7deda23bbdca612076c5c315003d8638a08ed0f1", - "version" : "8.3.2" + "revision" : "ac632bd26a1c00f139ff62fd01806f21cf67325e", + "version" : "8.10.0" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/relatedcode/ProgressHUD.git", "state" : { - "revision" : "9364904a42cb25f58d026451140c4080a868e72e", - "version" : "14.1.3" + "revision" : "4aa638a88450d6d48a6620070722e875e7a179ef", + "version" : "14.1.4" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ReactiveX/RxSwift.git", "state" : { - "revision" : "5dd1907d64f0d36f158f61a466bab75067224893", - "version" : "6.9.0" + "revision" : "132aea4f236ccadc51590b38af0357a331d51fa2", + "version" : "6.10.2" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/weichsel/ZIPFoundation", "state" : { - "revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0", - "version" : "0.9.19" + "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", + "version" : "0.9.20" } } ], diff --git a/Package.swift b/Package.swift index c01d3ef..e9ae067 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "CommonUtils", defaultLocalization: "ko", - platforms: [.iOS(.v13), .macOS(.v10_14), .tvOS(.v13), .watchOS(.v5)], + platforms: [.iOS(.v13), .macOS(.v10_15), .tvOS(.v13), .watchOS(.v6)], products: [ .library( name: "CommonUtils", @@ -14,12 +14,12 @@ let package = Package( ], dependencies: [ // 패키지 추가 - .package(url: "https://github.com/Moya/Moya.git", .upToNextMajor(from: "15.0.0")), - .package(url: "https://github.com/SnapKit/SnapKit.git", .upToNextMajor(from: "5.7.0")), + .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/devxoul/Then", .upToNextMajor(from: "3.0.0")), - .package(url: "https://github.com/relatedcode/ProgressHUD.git", .upToNextMajor(from: "14.1.3")), - .package(url: "https://github.com/onevcat/Kingfisher", .upToNextMajor(from: "8.1.0")), - .package(url: "https://github.com/weichsel/ZIPFoundation", .upToNextMajor(from: "0.9.19")) + .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/weichsel/ZIPFoundation", .upToNextMajor(from: "0.9.20")) ], targets: [ From 86f3c92d588cfec53ecd0f2a32255802aeda3fbe Mon Sep 17 00:00:00 2001 From: "dongju.lim" Date: Tue, 23 Jun 2026 17:40:02 +0900 Subject: [PATCH 2/9] =?UTF-8?q?[20260623]=20=EB=B2=84=EA=B7=B8=EC=88=98?= =?UTF-8?q?=EC=A0=95:=20Combine=20=EA=B5=AC=EB=8F=85=20=ED=95=B4=EC=A0=9C?= =?UTF-8?q?=20=EC=8B=9C=20=ED=83=80=EA=B9=83=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/CombineCocoa/CombineControlEvent.swift | 3 +++ .../Controls/UIGestureRecognizer+Combine.swift | 11 +++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Sources/CombineCocoa/CombineControlEvent.swift b/Sources/CombineCocoa/CombineControlEvent.swift index d969026..6145e71 100644 --- a/Sources/CombineCocoa/CombineControlEvent.swift +++ b/Sources/CombineCocoa/CombineControlEvent.swift @@ -50,10 +50,12 @@ extension Combine.Publishers.ControlEvent { private final class Subscription: Combine.Subscription where S.Input == Void { private var subscriber: S? weak private var control: control? + private let event: control.Event init(subscriber: S, control: control, event: control.Event) { self.subscriber = subscriber self.control = control + self.event = event control.addTarget(self, action: #selector(processControlEvent), for: event) } @@ -63,6 +65,7 @@ extension Combine.Publishers.ControlEvent { } func cancel() { + self.control?.removeTarget(self, action: #selector(processControlEvent), for: event) subscriber = nil } diff --git a/Sources/CombineCocoa/Controls/UIGestureRecognizer+Combine.swift b/Sources/CombineCocoa/Controls/UIGestureRecognizer+Combine.swift index 62cb4e7..8577762 100644 --- a/Sources/CombineCocoa/Controls/UIGestureRecognizer+Combine.swift +++ b/Sources/CombineCocoa/Controls/UIGestureRecognizer+Combine.swift @@ -68,7 +68,8 @@ public enum GestureType { public class GestureSubscription: Subscription where S.Input == GestureType, S.Failure == Never { private var subscriber: S? private var gestureType: GestureType - private var view: UIView + private weak var view: UIView? + private var gesture: UIGestureRecognizer? init(subscriber: S, view: UIView, gestureType: GestureType) { self.subscriber = subscriber self.view = view @@ -77,11 +78,17 @@ public class GestureSubscription: Subscription where S.Input == G } private func configureGesture(_ gestureType: GestureType) { let gesture = gestureType.get() + self.gesture = gesture gesture.addTarget(self, action: #selector(handler)) - view.addGestureRecognizer(gesture) + view?.addGestureRecognizer(gesture) } public func request(_ demand: Subscribers.Demand) { } public func cancel() { + if let gesture, let view { + gesture.removeTarget(self, action: #selector(handler)) + view.removeGestureRecognizer(gesture) + } + gesture = nil subscriber = nil } @objc From 85a791ce5dd752d62a173613967a3c3a3d3e76ad Mon Sep 17 00:00:00 2001 From: "dongju.lim" Date: Tue, 23 Jun 2026 17:40:15 +0900 Subject: [PATCH 3/9] =?UTF-8?q?[20260623]=20=EB=B2=84=EA=B7=B8=EC=88=98?= =?UTF-8?q?=EC=A0=95:=20=EA=B8=B0=EB=B3=B8=20=EC=9C=A0=ED=8B=B8=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=EB=8F=99=EC=9E=91=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Extensions/Int+Extension.swift | 4 +- Sources/Extensions/String+Extension.swift | 39 +++++++++++++++---- Sources/Extensions/UIColor+Extension.swift | 4 +- .../CommonUtilsTests/IntExtensionTests.swift | 4 ++ .../StringExtensionTests.swift | 25 ++++++++++++ .../UIColorExtensionTests.swift | 5 +++ 6 files changed, 70 insertions(+), 11 deletions(-) diff --git a/Sources/Extensions/Int+Extension.swift b/Sources/Extensions/Int+Extension.swift index abcc7cd..8b66f75 100644 --- a/Sources/Extensions/Int+Extension.swift +++ b/Sources/Extensions/Int+Extension.swift @@ -12,10 +12,10 @@ public extension Int { let numberFormatter = NumberFormatter() numberFormatter.numberStyle = NumberFormatter.Style.decimal numberFormatter.locale = locale - if self < 1 { + if self == 0 { return "0" } - return (numberFormatter.string(from: NSNumber(value: self)) ?? "") + return (numberFormatter.string(from: NSNumber(value: self)) ?? "0") } func withCurrencySpellOut(_ locale: Locale = .current) -> String { diff --git a/Sources/Extensions/String+Extension.swift b/Sources/Extensions/String+Extension.swift index 197e4e3..1649869 100644 --- a/Sources/Extensions/String+Extension.swift +++ b/Sources/Extensions/String+Extension.swift @@ -101,23 +101,25 @@ public extension String { // 문자열으로 버전 비교할때 "1.0.1" > "1.0.0" static func == (lhs: String, rhs: String) -> Bool { - return lhs.compare(rhs, options: .numeric) == .orderedSame + return lhs.compare(rhs) == .orderedSame } static func < (lhs: String, rhs: String) -> Bool { - return lhs.compare(rhs, options: .numeric) == .orderedAscending + return lhs.compare(rhs) == .orderedAscending } static func <= (lhs: String, rhs: String) -> Bool { - return lhs.compare(rhs, options: .numeric) == .orderedAscending || lhs.compare(rhs, options: .numeric) == .orderedSame + let result = lhs.compare(rhs) + return result == .orderedAscending || result == .orderedSame } static func > (lhs: String, rhs: String) -> Bool { - return lhs.compare(rhs, options: .numeric) == .orderedDescending + return lhs.compare(rhs) == .orderedDescending } static func >= (lhs: String, rhs: String) -> Bool { - return lhs.compare(rhs, options: .numeric) == .orderedDescending || lhs.compare(rhs, options: .numeric) == .orderedSame + let result = lhs.compare(rhs) + return result == .orderedDescending || result == .orderedSame } private var decimalFilteredString: String { @@ -163,10 +165,11 @@ public extension String { func dateFormatChange(_ currentFormat: String = "yyyy-MM-dd HH:mm:ss", changeFormat: String) -> String { let dateFormatter = DateFormatter() dateFormatter.setFormatter(format: currentFormat) - let date = dateFormatter.date(from: self) + guard let date = dateFormatter.date(from: self) else { + return self + } dateFormatter.dateFormat = changeFormat - let dateStr = dateFormatter.string(from: date ?? Date()) - return dateStr + return dateFormatter.string(from: date) } var isParsableHTML: Bool { @@ -331,6 +334,26 @@ public extension String { return result } + func toPhoneNumberFormat() -> String { + let replaceStr = self.replacingOccurrences(of: "-", with: "") + + switch replaceStr.count { + case 0: + return "label_notEntered".localization + case 9: + return replaceStr.applyPatternOnNumbers(pattern: "##-###-####", replacmentCharacter: "#") + case 10: + if replaceStr.hasPrefix("02") { + return replaceStr.applyPatternOnNumbers(pattern: "##-####-####", replacmentCharacter: "#") + } + return replaceStr.applyPatternOnNumbers(pattern: "###-###-####", replacmentCharacter: "#") + case 11...15: + return replaceStr.applyPatternOnNumbers(pattern: "###-####-####", replacmentCharacter: "#") + default: + return replaceStr + } + } + // UTF-16 기반의 실제 커서 위치를 계산하는 방법 var utf16Count: Int { return utf16.count diff --git a/Sources/Extensions/UIColor+Extension.swift b/Sources/Extensions/UIColor+Extension.swift index 0ee0faa..1e78199 100644 --- a/Sources/Extensions/UIColor+Extension.swift +++ b/Sources/Extensions/UIColor+Extension.swift @@ -43,8 +43,10 @@ public extension UIColor { hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") guard hexSanitized.count == 6 else { return nil } // ← assert → guard + let hexCharacterSet = CharacterSet(charactersIn: "0123456789ABCDEFabcdef") + guard hexSanitized.rangeOfCharacter(from: hexCharacterSet.inverted) == nil else { return nil } var rgbValue: UInt64 = 0 - Scanner(string: hexSanitized).scanHexInt64(&rgbValue) + guard Scanner(string: hexSanitized).scanHexInt64(&rgbValue) else { return nil } self.init(red: CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0, green: CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0, diff --git a/Tests/CommonUtilsTests/IntExtensionTests.swift b/Tests/CommonUtilsTests/IntExtensionTests.swift index 43d2a56..9e139de 100644 --- a/Tests/CommonUtilsTests/IntExtensionTests.swift +++ b/Tests/CommonUtilsTests/IntExtensionTests.swift @@ -19,6 +19,10 @@ final class IntExtensionTests: XCTestCase { XCTAssertEqual(999.withCommas(Locale(identifier: "de_DE")), "999") } + func test_withCommas_negativeInteger_shouldKeepSign() throws { + XCTAssertEqual((-1_234).withCommas(), "-1,234") + } + // MARK: - withCurrencySpellOut func test_withCurrencySpellOut() throws { XCTAssertEqual(1.withCurrencySpellOut(Locale(identifier: "en_US")), "one") diff --git a/Tests/CommonUtilsTests/StringExtensionTests.swift b/Tests/CommonUtilsTests/StringExtensionTests.swift index 4ba10e5..c48f542 100644 --- a/Tests/CommonUtilsTests/StringExtensionTests.swift +++ b/Tests/CommonUtilsTests/StringExtensionTests.swift @@ -46,6 +46,18 @@ final class StringExtensionTests: XCTestCase { XCTAssertEqual(result, "") } + // MARK: - Global String Comparison Operator Overloads + func test_stringOperatorLessThan_shouldMatchDefaultLexicographicCompare() throws { + let lhs = "2" + let rhs = "10" + let defaultCompareResult = lhs.compare(rhs) == .orderedAscending + XCTAssertEqual(lhs < rhs, defaultCompareResult) + } + + func test_stringOperatorEqual_shouldNotTreatDifferentLiteralsAsEqual() throws { + XCTAssertFalse("01" == "1") + } + // MARK: - Tests for `toInt` func test_toInt_withPlainNumberString() throws { let input = "1234" @@ -267,6 +279,13 @@ final class StringExtensionTests: XCTestCase { XCTAssertNotNil(result) } + func test_dateFormatChange_withInvalidDateString_shouldNotFallbackToNow() throws { + let input = "invalid-date" + let result = input.dateFormatChange(changeFormat: "yyyy.MM.dd") + let nowFormatted = Date().toString(format: "yyyy.MM.dd") + XCTAssertNotEqual(result, nowFormatted) + } + func test_dateFormatChange_withCustomInputFormat() throws { let input = "2025년04월15일" let result = input.dateFormatChange("yyyy년MM월dd일", changeFormat: "yyyy-MM-dd") @@ -590,6 +609,12 @@ final class StringExtensionTests: XCTestCase { XCTAssertEqual("9876543210".applyPatternOnNumbers(pattern: "## ## ## ## ##", replacmentCharacter: "#"), "98 76 54 32 10") } + // MARK: - toPhoneNumberFormat + func test_toPhoneNumberFormat_seoulNumber_shouldUse02AreaCodePattern() throws { + let input = "0212345678" + XCTAssertEqual(input.toPhoneNumberFormat(), "02-1234-5678") + } + // MARK: - utf16Count func testUtf16Count() throws { XCTAssertEqual("hello".utf16Count, 5) diff --git a/Tests/CommonUtilsTests/UIColorExtensionTests.swift b/Tests/CommonUtilsTests/UIColorExtensionTests.swift index 6a909bf..9ee5147 100644 --- a/Tests/CommonUtilsTests/UIColorExtensionTests.swift +++ b/Tests/CommonUtilsTests/UIColorExtensionTests.swift @@ -57,6 +57,11 @@ final class UIColorHexExtensionTests: XCTestCase { XCTAssertNil(color) } + func testInitHex_invalidCharacters_shouldReturnNil() throws { + let color = UIColor(hex: "GGGGGG") + XCTAssertNil(color) + } + func testInitHex_nilInput() throws { let color = UIColor(hex: nil) XCTAssertNil(color) From a8cac4067a575211520deac648c8fece511136cc Mon Sep 17 00:00:00 2001 From: "dongju.lim" Date: Tue, 23 Jun 2026 17:40:28 +0900 Subject: [PATCH 4/9] =?UTF-8?q?[20260623]=20=EB=B2=84=EA=B7=B8=EC=88=98?= =?UTF-8?q?=EC=A0=95:=20=EC=86=8D=EC=84=B1=20=EB=AC=B8=EC=9E=90=EC=97=B4?= =?UTF-8?q?=20UTF16=20=EB=B2=94=EC=9C=84=20=EC=B2=98=EB=A6=AC=20=EB=B3=B4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NSAttributedString+Extension.swift | 134 +++++++++++------- Sources/Extensions/UIButton+Extension.swift | 7 +- .../NSAttributedStringExtensionTests.swift | 106 ++++++++++++++ .../UIButtonExtensionTests.swift | 35 +++++ 4 files changed, 226 insertions(+), 56 deletions(-) create mode 100644 Tests/CommonUtilsTests/UIButtonExtensionTests.swift diff --git a/Sources/Extensions/NSAttributedString+Extension.swift b/Sources/Extensions/NSAttributedString+Extension.swift index 3907460..d979a2a 100644 --- a/Sources/Extensions/NSAttributedString+Extension.swift +++ b/Sources/Extensions/NSAttributedString+Extension.swift @@ -11,34 +11,42 @@ import UIKit public extension NSAttributedString { func setStrikethroughStyle() -> NSMutableAttributedString { let attributedString = NSMutableAttributedString(attributedString: self) - attributedString.addAttribute(NSAttributedString.Key.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: NSMakeRange(0, self.string.count)) + attributedString.addAttribute(NSAttributedString.Key.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: NSRange(location: 0, length: self.length)) return attributedString } - + func changeTextColor(color: UIColor, text: String) -> NSMutableAttributedString { let attributedString = NSMutableAttributedString(attributedString: self) - var range = NSRange(location: 0, length: self.length) - while (range.location != NSNotFound) { - range = (self.string as NSString).range(of: text, options: [], range: range) - if (range.location != NSNotFound) { - attributedString.addAttribute(.foregroundColor, value: color, range: NSRange(location: range.location, length: text.count)) - range = NSRange(location: range.location + range.length, length: self.string.count - (range.location + range.length)) + let nsText = self.string as NSString + var searchRange = NSRange(location: 0, length: nsText.length) + while (searchRange.location != NSNotFound) { + let foundRange = nsText.range(of: text, options: [], range: searchRange) + if (foundRange.location != NSNotFound) { + attributedString.addAttribute(.foregroundColor, value: color, range: foundRange) + let nextLocation = foundRange.location + foundRange.length + searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation) + } else { + searchRange.location = NSNotFound } } return attributedString } - + func changeTextBackgroundColor(color: UIColor, text: String) -> NSMutableAttributedString { let attributedString = NSMutableAttributedString(attributedString: self) - var range = NSRange(location: 0, length: self.length) - while (range.location != NSNotFound) { - range = (self.string as NSString).range(of: text, options: [], range: range) - if (range.location != NSNotFound) { - attributedString.addAttribute(.backgroundColor, value: color, range: NSRange(location: range.location, length: text.count)) - range = NSRange(location: range.location + range.length, length: self.string.count - (range.location + range.length)) + let nsText = self.string as NSString + var searchRange = NSRange(location: 0, length: nsText.length) + while (searchRange.location != NSNotFound) { + let foundRange = nsText.range(of: text, options: [], range: searchRange) + if (foundRange.location != NSNotFound) { + attributedString.addAttribute(.backgroundColor, value: color, range: foundRange) + let nextLocation = foundRange.location + foundRange.length + searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation) + } else { + searchRange.location = NSNotFound } } @@ -48,12 +56,16 @@ public extension NSAttributedString { func changeTextFont(font: UIFont, text: String) -> NSMutableAttributedString { let attributedString = NSMutableAttributedString(attributedString: self) - var range = NSRange(location: 0, length: self.length) - while (range.location != NSNotFound) { - range = (self.string as NSString).range(of: text, options: [], range: range) - if (range.location != NSNotFound) { - attributedString.addAttribute(.font, value: font, range: NSRange(location: range.location, length: text.count)) - range = NSRange(location: range.location + range.length, length: self.string.count - (range.location + range.length)) + let nsText = self.string as NSString + var searchRange = NSRange(location: 0, length: nsText.length) + while (searchRange.location != NSNotFound) { + let foundRange = nsText.range(of: text, options: [], range: searchRange) + if (foundRange.location != NSNotFound) { + attributedString.addAttribute(.font, value: font, range: foundRange) + let nextLocation = foundRange.location + foundRange.length + searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation) + } else { + searchRange.location = NSNotFound } } @@ -63,67 +75,83 @@ public extension NSAttributedString { func changeParagraphStyle(style: NSMutableParagraphStyle, text: String) -> NSMutableAttributedString { let attributedString = NSMutableAttributedString(attributedString: self) - var range = NSRange(location: 0, length: self.length) - while (range.location != NSNotFound) { - range = (self.string as NSString).range(of: text, options: [], range: range) - if (range.location != NSNotFound) { - attributedString.addAttribute(.paragraphStyle, value: style, range: NSRange(location: range.location, length: text.count)) - range = NSRange(location: range.location + range.length, length: self.string.count - (range.location + range.length)) + let nsText = self.string as NSString + var searchRange = NSRange(location: 0, length: nsText.length) + while (searchRange.location != NSNotFound) { + let foundRange = nsText.range(of: text, options: [], range: searchRange) + if (foundRange.location != NSNotFound) { + attributedString.addAttribute(.paragraphStyle, value: style, range: foundRange) + let nextLocation = foundRange.location + foundRange.length + searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation) + } else { + searchRange.location = NSNotFound } } return attributedString } - + func changeTextsColor(color: UIColor, texts: [String]) -> NSMutableAttributedString { let attributedString = NSMutableAttributedString(attributedString: self) - + + let nsText = self.string as NSString texts.forEach { - var range = NSRange(location: 0, length: self.length) - while (range.location != NSNotFound) { - range = (self.string as NSString).range(of: $0, options: [], range: range) - if (range.location != NSNotFound) { - attributedString.addAttribute(.foregroundColor, value: color, range: NSRange(location: range.location, length: $0.count)) - range = NSRange(location: range.location + range.length, length: self.string.count - (range.location + range.length)) + var searchRange = NSRange(location: 0, length: nsText.length) + while (searchRange.location != NSNotFound) { + let foundRange = nsText.range(of: $0, options: [], range: searchRange) + if (foundRange.location != NSNotFound) { + attributedString.addAttribute(.foregroundColor, value: color, range: foundRange) + let nextLocation = foundRange.location + foundRange.length + searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation) + } else { + searchRange.location = NSNotFound } } } - + return attributedString } - + func changeTextsFont(font: UIFont, texts: [String]) -> NSMutableAttributedString { let attributedString = NSMutableAttributedString(attributedString: self) - + + let nsText = self.string as NSString texts.forEach { - var range = NSRange(location: 0, length: self.length) - while (range.location != NSNotFound) { - range = (self.string as NSString).range(of: $0, options: [], range: range) - if (range.location != NSNotFound) { - attributedString.addAttribute(.font, value: font, range: NSRange(location: range.location, length: $0.count)) - range = NSRange(location: range.location + range.length, length: self.string.count - (range.location + range.length)) + var searchRange = NSRange(location: 0, length: nsText.length) + while (searchRange.location != NSNotFound) { + let foundRange = nsText.range(of: $0, options: [], range: searchRange) + if (foundRange.location != NSNotFound) { + attributedString.addAttribute(.font, value: font, range: foundRange) + let nextLocation = foundRange.location + foundRange.length + searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation) + } else { + searchRange.location = NSNotFound } } } return attributedString } - + func changeTextUnderLine(text: String) -> NSMutableAttributedString { let attributedString = NSMutableAttributedString(attributedString: self) - - var range = NSRange(location: 0, length: self.length) - while (range.location != NSNotFound) { - range = (self.string as NSString).range(of: text, options: [], range: range) - if (range.location != NSNotFound) { - attributedString.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: NSRange(location: range.location, length: text.count)) - range = NSRange(location: range.location + range.length, length: self.string.count - (range.location + range.length)) + + let nsText = self.string as NSString + var searchRange = NSRange(location: 0, length: nsText.length) + while (searchRange.location != NSNotFound) { + let foundRange = nsText.range(of: text, options: [], range: searchRange) + if (foundRange.location != NSNotFound) { + attributedString.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: foundRange) + let nextLocation = foundRange.location + foundRange.length + searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation) + } else { + searchRange.location = NSNotFound } } return attributedString } - + func width(_ height: CGFloat) -> CGFloat { let constraintRect = CGSize(width: .greatestFiniteMagnitude, height: height) let boundingBox = self.boundingRect(with: constraintRect, diff --git a/Sources/Extensions/UIButton+Extension.swift b/Sources/Extensions/UIButton+Extension.swift index 3f86aad..16e70fe 100644 --- a/Sources/Extensions/UIButton+Extension.swift +++ b/Sources/Extensions/UIButton+Extension.swift @@ -20,14 +20,15 @@ public extension UIButton { guard let title = title(for: .normal), let titleColor = titleColor else { return } let titleString = NSMutableAttributedString(string: title) + let titleLength = (title as NSString).length titleString.addAttribute( .underlineStyle, value: NSUnderlineStyle.single.rawValue, - range: NSRange(location: 0, length: title.count) + range: NSRange(location: 0, length: titleLength) ) - titleString.addAttribute(NSAttributedString.Key.foregroundColor, value: titleColor, range: NSRange(location: 0, length: title.count)) - titleString.addAttribute(NSAttributedString.Key.font, value: font, range: NSRange(location: 0, length: title.count)) + titleString.addAttribute(NSAttributedString.Key.foregroundColor, value: titleColor, range: NSRange(location: 0, length: titleLength)) + titleString.addAttribute(NSAttributedString.Key.font, value: font, range: NSRange(location: 0, length: titleLength)) setAttributedTitle(titleString, for: .normal) } diff --git a/Tests/CommonUtilsTests/NSAttributedStringExtensionTests.swift b/Tests/CommonUtilsTests/NSAttributedStringExtensionTests.swift index ad86190..103ae98 100644 --- a/Tests/CommonUtilsTests/NSAttributedStringExtensionTests.swift +++ b/Tests/CommonUtilsTests/NSAttributedStringExtensionTests.swift @@ -135,6 +135,77 @@ final class NSAttributedStringExtensionsTests: XCTestCase { XCTAssertEqual(result.string, "Other words") } + // MARK: - UTF-16 (Emoji + ZWJ) style tests + func test_setStrikethroughStyle_withEmojiAndZWJ_shouldCoverEntireUTF16Range() throws { + let text = "😀A👨‍👩‍👧‍👦Z" + let base = NSAttributedString(string: text) + let result = base.setStrikethroughStyle() + + let firstStyle = result.attribute(.strikethroughStyle, at: 0, effectiveRange: nil) as? Int + let lastStyle = result.attribute(.strikethroughStyle, at: result.length - 1, effectiveRange: nil) as? Int + + XCTAssertEqual(firstStyle, NSUnderlineStyle.single.rawValue) + XCTAssertEqual(lastStyle, NSUnderlineStyle.single.rawValue) + } + + func test_changeStyleMethods_withEmojiAndZWJ_shouldApplyToAllEmojiOccurrences() throws { + let text = "😀A👨‍👩‍👧‍👦😀" + let base = NSAttributedString(string: text) + let target = "😀" + let targetRanges = ranges(of: target, in: text) + XCTAssertEqual(targetRanges.count, 2) + + let colorChanged = base.changeTextColor(color: .red, text: target) + assertColorAttribute(colorChanged, key: .foregroundColor, expected: .red, ranges: targetRanges) + + let backgroundChanged = base.changeTextBackgroundColor(color: .yellow, text: target) + assertColorAttribute(backgroundChanged, key: .backgroundColor, expected: .yellow, ranges: targetRanges) + + let font = UIFont.boldSystemFont(ofSize: 16) + let fontChanged = base.changeTextFont(font: font, text: target) + targetRanges.forEach { + let appliedFont = fontChanged.attribute(.font, at: $0.location, effectiveRange: nil) as? UIFont + XCTAssertEqual(appliedFont, font) + } + + let style = NSMutableParagraphStyle() + style.alignment = .center + let paragraphChanged = base.changeParagraphStyle(style: style, text: target) + targetRanges.forEach { + let appliedStyle = paragraphChanged.attribute(.paragraphStyle, at: $0.location, effectiveRange: nil) as? NSParagraphStyle + XCTAssertEqual(appliedStyle?.alignment, style.alignment) + } + } + + func test_changeStyleMethods_withEmojiAndZWJ_shouldApplyToAllHangulOccurrences() throws { + let text = "끝😀👨‍👩‍👧‍👦끝" + let base = NSAttributedString(string: text) + let target = "끝" + let targetRanges = ranges(of: target, in: text) + XCTAssertEqual(targetRanges.count, 2) + + let colorChanged = base.changeTextColor(color: .blue, text: target) + assertColorAttribute(colorChanged, key: .foregroundColor, expected: .blue, ranges: targetRanges) + + let backgroundChanged = base.changeTextBackgroundColor(color: .green, text: target) + assertColorAttribute(backgroundChanged, key: .backgroundColor, expected: .green, ranges: targetRanges) + + let font = UIFont.systemFont(ofSize: 15, weight: .medium) + let fontChanged = base.changeTextFont(font: font, text: target) + targetRanges.forEach { + let appliedFont = fontChanged.attribute(.font, at: $0.location, effectiveRange: nil) as? UIFont + XCTAssertEqual(appliedFont, font) + } + + let style = NSMutableParagraphStyle() + style.lineSpacing = 6 + let paragraphChanged = base.changeParagraphStyle(style: style, text: target) + targetRanges.forEach { + let appliedStyle = paragraphChanged.attribute(.paragraphStyle, at: $0.location, effectiveRange: nil) as? NSParagraphStyle + XCTAssertEqual(appliedStyle?.lineSpacing, style.lineSpacing) + } + } + // MARK: - width(height:) func test_width_calculatesCorrectWidth() throws { let attr = NSAttributedString(string: "Hello", attributes: [.font: UIFont.systemFont(ofSize: 16)]) @@ -765,4 +836,39 @@ final class NSAttributedStringExtensionsTests: XCTestCase { XCTAssertEqual(extracted.count, 1) XCTAssertTrue(attrString.string.isEmpty) } + + private func ranges(of target: String, in text: String) -> [NSRange] { + guard !target.isEmpty else { return [] } + + let nsText = text as NSString + var ranges: [NSRange] = [] + var searchRange = NSRange(location: 0, length: nsText.length) + + while true { + let foundRange = nsText.range(of: target, options: [], range: searchRange) + guard foundRange.location != NSNotFound else { break } + + ranges.append(foundRange) + + let nextLocation = foundRange.location + foundRange.length + if nextLocation >= nsText.length { break } + searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation) + } + + return ranges + } + + private func assertColorAttribute( + _ attributed: NSAttributedString, + key: NSAttributedString.Key, + expected: UIColor, + ranges: [NSRange], + file: StaticString = #filePath, + line: UInt = #line + ) { + ranges.forEach { + let appliedColor = attributed.attribute(key, at: $0.location, effectiveRange: nil) as? UIColor + XCTAssertEqual(appliedColor, expected, file: file, line: line) + } + } } diff --git a/Tests/CommonUtilsTests/UIButtonExtensionTests.swift b/Tests/CommonUtilsTests/UIButtonExtensionTests.swift new file mode 100644 index 0000000..dcabdd9 --- /dev/null +++ b/Tests/CommonUtilsTests/UIButtonExtensionTests.swift @@ -0,0 +1,35 @@ +// +// UIButtonExtensionTests.swift +// CommonUtils +// +// Created by Codex on 2026/03/04. +// + +import XCTest +import UIKit +@testable import CommonUtils + +final class UIButtonExtensionTests: XCTestCase { + + func test_underlineText_withEmojiTitle_shouldApplyAttributeAcrossUTF16Range() throws { + let button = UIButton(type: .system) + let title = "👨‍👩‍👧‍👦테스트" + button.setTitle(title, for: .normal) + + button.underlineText(titleColor: .red, font: .systemFont(ofSize: 14)) + + guard let attributed = button.attributedTitle(for: .normal) else { + XCTFail("Attributed title should be set") + return + } + + let emojiRange = (title as NSString).range(of: "👨‍👩‍👧‍👦") + XCTAssertNotEqual(emojiRange.location, NSNotFound) + + let startStyle = attributed.attribute(.underlineStyle, at: emojiRange.location, effectiveRange: nil) as? Int + let endStyle = attributed.attribute(.underlineStyle, at: emojiRange.location + emojiRange.length - 1, effectiveRange: nil) as? Int + + XCTAssertEqual(startStyle, NSUnderlineStyle.single.rawValue) + XCTAssertEqual(endStyle, NSUnderlineStyle.single.rawValue) + } +} From 7ecd2ca853c79f08174a0dfd32bb74fe1cd7865e Mon Sep 17 00:00:00 2001 From: "dongju.lim" Date: Tue, 23 Jun 2026 17:40:44 +0900 Subject: [PATCH 5/9] =?UTF-8?q?[20260623]=20=EA=B8=B0=EB=8A=A5=EC=88=98?= =?UTF-8?q?=EC=A0=95:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=A1=9C=EB=94=A9?= =?UTF-8?q?=20=EC=98=B5=EC=85=98=EA=B3=BC=20=EB=A1=9C=EA=B7=B8=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Extensions/UIImageView+Extension.swift | 37 +++++++++++-------- Sources/Function/DebugLoggerManager.swift | 6 +-- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/Sources/Extensions/UIImageView+Extension.swift b/Sources/Extensions/UIImageView+Extension.swift index 500a044..ac7dcd3 100644 --- a/Sources/Extensions/UIImageView+Extension.swift +++ b/Sources/Extensions/UIImageView+Extension.swift @@ -32,6 +32,7 @@ public extension UIImageView { placeholder: UIImage? = nil, options: KingfisherOptionsInfo? = [ .transition(.none)], imageResize: CGSize? = nil, + skipDownsampling: Bool = false, isIndicator: Bool = true, indicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium) ) { @@ -40,6 +41,7 @@ public extension UIImageView { placeholder: placeholder, options: options, imageResize: imageResize, + skipDownsampling: skipDownsampling, isIndicator: isIndicator, indicator: indicator, completionHandler: nil @@ -51,6 +53,7 @@ public extension UIImageView { placeholder: UIImage? = nil, options: KingfisherOptionsInfo? = [ .transition(.none)], imageResize: CGSize? = nil, + skipDownsampling: Bool = false, isIndicator: Bool = true, indicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium), completionHandler: @escaping ((Result) -> Void) @@ -60,6 +63,7 @@ public extension UIImageView { placeholder: placeholder, options: options, imageResize: imageResize, + skipDownsampling: skipDownsampling, isIndicator: isIndicator, indicator: indicator, completionHandler: completionHandler @@ -71,6 +75,7 @@ public extension UIImageView { placeholder: UIImage?, options: KingfisherOptionsInfo?, imageResize: CGSize?, + skipDownsampling: Bool, isIndicator: Bool, indicator: UIActivityIndicatorView, completionHandler: ((Result) -> Void)? @@ -93,22 +98,24 @@ public extension UIImageView { kf.indicator?.view.removeFromSuperview() var kfOptions = options ?? [] - // ✅ 리사이즈 적용 (없으면 imageView 크기 사용) - let targetSize: CGSize - if let resize = imageResize, resize.width > 0, resize.height > 0 { - targetSize = resize - } else { - targetSize = bounds.size // 뷰 크기 기준으로 다운샘플링 - } - var resizedCacheKey = url.absoluteString - if targetSize.width > 0 && targetSize.height > 0 { - let processor = DownsamplingImageProcessor(size: targetSize) - kfOptions.append(.processor(processor)) - kfOptions.append(.scaleFactor(UIScreen.main.scale)) - // ✅ 해상도별 cacheKey 생성 - let sizeKey = "w\(Int(targetSize.width))_h\(Int(targetSize.height))" - resizedCacheKey = "\(url.absoluteString)_\(sizeKey)" + if !skipDownsampling { + // ✅ 리사이즈 적용 (없으면 imageView 크기 사용) + let targetSize: CGSize + if let resize = imageResize, resize.width > 0, resize.height > 0 { + targetSize = resize + } else { + targetSize = bounds.size // 뷰 크기 기준으로 다운샘플링 + } + + if targetSize.width > 0 && targetSize.height > 0 { + let processor = DownsamplingImageProcessor(size: targetSize) + kfOptions.append(.processor(processor)) + kfOptions.append(.scaleFactor(UIScreen.main.scale)) + // ✅ 해상도별 cacheKey 생성 + let sizeKey = "w\(Int(targetSize.width))_h\(Int(targetSize.height))" + resizedCacheKey = "\(url.absoluteString)_\(sizeKey)" + } } kf.indicatorType = isIndicator ? .custom(indicator: CustomKfActivityIndicator(indicator)) : .none diff --git a/Sources/Function/DebugLoggerManager.swift b/Sources/Function/DebugLoggerManager.swift index 79c6755..bf70ede 100644 --- a/Sources/Function/DebugLoggerManager.swift +++ b/Sources/Function/DebugLoggerManager.swift @@ -43,9 +43,9 @@ public func DebugLog(_ message: Any? = "", if isDebugPrint { let fileName: String = (file as NSString).lastPathComponent var fullMessage = """ - [파일: \(fileName), 라인: \(line), 함수: \(funcName)] - \(String(describing: message)) - """ + [파일: \(fileName), 라인: \(line), 함수: \(funcName)] + \(String(describing: message)) + """ if !param.isEmpty { fullMessage += "\n[추가 정보: \(param.toJsonString)]" From a09e20b73df53a77c3fe09959504ab8379d8b3f5 Mon Sep 17 00:00:00 2001 From: "dongju.lim" Date: Tue, 23 Jun 2026 18:00:09 +0900 Subject: [PATCH 6/9] =?UTF-8?q?[20260623]=20CI:=20PR=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=A6=88=20=EC=9E=90=EB=8F=99=ED=99=94=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-description.yml | 151 ++++++++++++++++++++++++ .github/workflows/pr-tests.yml | 37 ++++++ .github/workflows/release.yml | 57 +++++++++ CHANGELOG.md | 128 ++++++++++++++++++++ README.md | 169 ++++++++++++++++++++------- 5 files changed, 497 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/pr-description.yml create mode 100644 .github/workflows/pr-tests.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md diff --git a/.github/workflows/pr-description.yml b/.github/workflows/pr-description.yml new file mode 100644 index 0000000..217b91b --- /dev/null +++ b/.github/workflows/pr-description.yml @@ -0,0 +1,151 @@ +name: PR Description + +on: + pull_request_target: + types: + - opened + - reopened + - synchronize + - ready_for_review + +permissions: + contents: read + pull-requests: write + +jobs: + update-description: + name: Update PR Description + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Generate PR body from commits + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + + gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}/commits?per_page=100" > commits.json + gh pr view "${PR_NUMBER}" --json body --jq '.body // ""' > current_body.md + + ruby <<'RUBY' > pr_body.md + require "json" + + commits = JSON.parse(File.read("commits.json")) + current_body = File.read("current_body.md") + title = ENV.fetch("PR_TITLE") + base_ref = ENV.fetch("BASE_REF") + head_ref = ENV.fetch("HEAD_REF") + + keyword_order = [ + "기능추가", + "기능수정", + "버그수정", + "리팩터링", + "성능개선", + "테스트", + "문서", + "빌드/설정", + "CI", + "기타" + ] + + groups = Hash.new { |hash, key| hash[key] = [] } + details = [] + + commits.each do |commit| + subject = commit.dig("commit", "message").to_s.lines.first.to_s.strip + next if subject.empty? + + if subject =~ /^\[\d{8}\]\s+([^:]+):\s*(.+)$/ + keyword = Regexp.last_match(1) + content = Regexp.last_match(2) + elsif subject =~ /^(feat|fix|refactor|perf|test|docs|chore|build|ci)(?:\([^)]+\))?:\s*(.+)$/i + type = Regexp.last_match(1).downcase + content = Regexp.last_match(2) + keyword = { + "feat" => "기능추가", + "fix" => "버그수정", + "refactor" => "리팩터링", + "perf" => "성능개선", + "test" => "테스트", + "docs" => "문서", + "chore" => "기타", + "build" => "빌드/설정", + "ci" => "CI" + }.fetch(type, "기타") + else + keyword = "기타" + content = subject + end + + groups[keyword] << content + details << content + end + + generated = [] + + generated << "" + generated << "## PR 요약" + generated << "" + generated << "- #{title}" + generated << "- 기준 브랜치: `#{base_ref}`" + generated << "- 작업 브랜치: `#{head_ref}`" + generated << "" + generated << "## 커밋 기반 요약" + generated << "" + + keyword_order.each do |keyword| + next if groups[keyword].empty? + + generated << "### #{keyword}" + groups[keyword].uniq.each do |content| + generated << "- #{content}" + end + generated << "" + end + + if groups.values.all?(&:empty?) + generated << "- 커밋 메시지에서 요약할 항목을 찾지 못했습니다." + generated << "" + end + + generated << "## 변경 사항 상세" + generated << "" + details.uniq.first(7).each do |content| + generated << "- #{content}" + end + generated << "- 자세한 변경 범위는 Files changed 탭을 확인해주세요." if details.length > 7 + generated << "" + generated << "## 테스트/검증" + generated << "" + generated << "- [ ] `xcodebuild test -scheme CommonUtils -destination 'platform=iOS Simulator,name=iPhone 16' CODE_SIGNING_ALLOWED=NO`" + generated << "- [ ] GitHub Actions PR Tests 통과" + generated << "" + generated << "## 릴리즈 메모" + generated << "" + generated << "- 릴리즈 대상 버전이 있으면 `CHANGELOG.md`에 해당 버전 섹션을 추가해주세요." + generated << "" + + block = generated.join("\n") + start_marker = "" + end_marker = "" + + if current_body.include?(start_marker) && current_body.include?(end_marker) + pattern = /#{Regexp.escape(start_marker)}.*?#{Regexp.escape(end_marker)}/m + puts current_body.sub(pattern, block) + elsif current_body.strip.empty? + puts block + else + puts current_body.rstrip + puts + puts block + end + RUBY + + gh pr edit "${PR_NUMBER}" --body-file pr_body.md diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml new file mode 100644 index 0000000..1c2116a --- /dev/null +++ b/.github/workflows/pr-tests.yml @@ -0,0 +1,37 @@ +name: PR Tests + +on: + pull_request: + +permissions: + contents: read + +jobs: + test: + name: iOS Simulator Tests + runs-on: macos-15 + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select iPhone simulator + run: | + SIMULATOR_ID="$( + xcrun simctl list devices available -j | ruby -rjson -e ' + devices = JSON.parse(STDIN.read)["devices"].values.flatten + simulator = devices.find { |device| device["name"].include?("iPhone") && device["isAvailable"] } + abort("No available iPhone simulator") unless simulator + puts simulator["udid"] + ' + )" + echo "SIMULATOR_ID=${SIMULATOR_ID}" >> "${GITHUB_ENV}" + echo "Selected simulator: ${SIMULATOR_ID}" + + - name: Run tests + run: | + xcodebuild test \ + -scheme CommonUtils \ + -destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \ + CODE_SIGNING_ALLOWED=NO diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e94deaa --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,57 @@ +name: Release + +on: + push: + tags: + - "[0-9]*.[0-9]*.[0-9]*" + - "v[0-9]*.[0-9]*.[0-9]*" + +permissions: + contents: write + +jobs: + create-release: + name: Create GitHub Release + runs-on: macos-15 + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create release + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + + VERSION="${TAG_NAME#v}" + NOTES_FILE="$(mktemp)" + + if [[ -f CHANGELOG.md ]]; then + awk -v version="${VERSION}" ' + $0 ~ "^## \\[" version "\\]" { + capture = 1 + next + } + capture && /^## \[/ { + capture = 0 + } + capture { + print + } + ' CHANGELOG.md | sed '/^[[:space:]]*$/d' > "${NOTES_FILE}" + fi + + if [[ -s "${NOTES_FILE}" ]]; then + gh release create "${TAG_NAME}" \ + --title "${TAG_NAME}" \ + --notes-file "${NOTES_FILE}" + else + gh release create "${TAG_NAME}" \ + --title "${TAG_NAME}" \ + --generate-notes + fi diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..749865b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,128 @@ +# Changelog + +이 파일은 태그 기반 GitHub Release 본문을 직접 관리할 때 사용합니다. + +## 작성 규칙 + +- 버전 태그는 `1.2.3` 또는 `v1.2.3` 형식을 사용할 수 있습니다. +- 섹션 제목은 `## [1.2.3] - YYYY-MM-DD` 형식으로 작성합니다. +- 릴리즈 워크플로는 태그에서 `v`를 제거한 버전과 일치하는 섹션을 찾아 GitHub Release 본문으로 사용합니다. +- 일치하는 섹션이 없으면 GitHub의 자동 생성 릴리즈 노트를 사용합니다. + +## [Unreleased] + +- 다음 릴리즈에 포함할 변경사항을 이곳에 정리합니다. + +## [1.0.17] - 2026-06-23 + +### 빌드/설정 + +- Moya, SnapKit, ProgressHUD, Kingfisher, ZIPFoundation의 최소 의존성 버전을 현재 resolved 버전에 맞춰 상향했습니다. +- Kingfisher 8.10.0 요구사항에 맞춰 macOS 최소 버전을 10.15, watchOS 최소 버전을 6.0으로 조정했습니다. + +### 기능수정 + +- UIImageView 이미지 로딩에서 다운샘플링을 건너뛸 수 있는 `skipDownsampling` 옵션을 추가했습니다. +- 디버그 로그 출력 포맷의 들여쓰기를 정리했습니다. + +### 버그수정 + +- Combine control event와 gesture publisher 구독 해제 시 target/gesture를 제거하도록 정리했습니다. +- `Int.withCommas()`가 음수 값을 `0`으로 처리하지 않도록 수정했습니다. +- 문자열 비교 연산자, 날짜 포맷 변환 실패 처리, 전화번호 포맷 변환 동작을 보정했습니다. +- `UIColor(hex:)`가 잘못된 hex 문자를 안전하게 거부하도록 수정했습니다. +- `NSAttributedString`/`UIButton`의 속성 범위 계산을 UTF-16 기준으로 맞춰 이모지, ZWJ, 한글 조합 문자열에서도 올바르게 적용되도록 수정했습니다. + +### 문서/자동화 + +- README를 현재 패키지 기능, 요구사항, 의존성, 테스트/릴리즈 절차에 맞게 갱신했습니다. +- PR 생성/갱신 시 iOS Simulator 테스트를 실행하는 GitHub Actions 워크플로를 추가했습니다. +- PR 본문을 커밋 메시지 기반으로 자동 작성하는 GitHub Actions 워크플로를 추가했습니다. +- 태그 push 시 GitHub Release를 생성하고, 이 changelog의 해당 버전 섹션을 릴리즈 본문으로 사용하는 워크플로를 추가했습니다. + +## [1.0.16] - 2025-11-12 + +- iOS 26 대응을 위한 UIApplication 관련 코드를 개선했습니다. +- 디스크 용량 체크와 네트워크 로깅 코드를 개선했습니다. +- 이미지 처리와 네비게이션바 관련 코드를 개선했습니다. + +## [1.0.15] - 2025-09-12 + +- 디스크 여유 공간 부족 시 로그 기록 과정에서 발생할 수 있는 크래시를 방지했습니다. +- 재사용 셀 환경에서 Kingfisher 인디케이터 뷰가 남지 않도록 정리했습니다. +- 이미지 표시 시 뷰 크기 기준 다운샘플링을 적용하고 캐시 키를 개선했습니다. +- HTML 관련 유틸을 추가했습니다. +- JSON 외 XML 타입 데이터 로깅 처리를 개선했습니다. + +## [1.0.14] - 2025-07-10 + +- 로그 관리 기능을 추가하고 파일 기반 로그 기록을 지원했습니다. +- 이미지 로드 및 JSON 처리 코드를 개선했습니다. +- ZIPFoundation 의존성을 추가했습니다. +- warning 코드를 정리했습니다. + +## [1.0.13] - 2025-05-29 + +- 버그 수정과 일부 기능 개선을 반영했습니다. +- 파일 처리 관련 신규 기능과 테스트 코드를 추가했습니다. +- CoreData 관련 extension을 추가했습니다. + +## [1.0.12] - 2025-05-12 + +- 동영상 압축 기능과 관련 테스트를 추가했습니다. +- 텍스트뷰 이미지 추출 기능을 추가했습니다. +- warning 코드를 정리했습니다. + +## [1.0.11] - 2025-04-29 + +- 테스트 코드를 추가했습니다. +- 불필요한 스킴 구성을 정리했습니다. + +## [1.0.10] - 2025-04-17 + +- README를 추가하고 문서 내용을 보완했습니다. + +## [1.0.9] - 2025-04-17 + +- Extension 테스트 코드를 작성했습니다. + +## [1.0.8] - 2025-03-28 + +- 비디오 인코딩 관련 기능을 추가했습니다. +- 다수 extension 기능을 추가했습니다. + +## [1.0.7] - 2024-12-20 + +- 네비게이션바 코드와 shadow 처리 코드를 개선했습니다. +- 숫자와 특수 문자가 이모지로 잘못 판정되는 문제를 수정했습니다. +- Debug extension 관련 코드를 정리했습니다. + +## [1.0.6] - 2024-11-26 + +- GIF data를 UIImage로 변환하는 코드와 Kingfisher SDK 버전을 개선했습니다. +- 이미지 처리 중 발생할 수 있는 크래시를 수정했습니다. + +## [1.0.5] - 2024-10-31 + +- 계산 유틸과 UUID 관련 기능을 추가했습니다. +- line 계산 관련 코드를 정리했습니다. + +## [1.0.4] - 2024-10-30 + +- 기존 유틸과 extension 기능을 개선했습니다. + +## [1.0.3] - 2024-10-08 + +- 기존 유틸과 extension 기능을 개선했습니다. + +## [1.0.2] - 2024-09-11 + +- 기존 유틸과 extension 기능을 개선했습니다. + +## [1.0.1] - 2024-08-14 + +- 초기 배포 이후 패키지 구성을 보완했습니다. + +## [1.0.0] - 2024-08-13 + +- CommonUtils 초기 버전을 배포했습니다. diff --git a/README.md b/README.md index 8bc239a..d85966c 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,150 @@ # CommonUtils -`CommonUtils`는 iOS 개발을 위한 유용한 유틸리티 함수와 확장 기능을 모아놓은 Swift 패키지입니다. -이 패키지는 코드의 재사용성을 높이고, 개발 시간을 단축하는 데 도움을 줍니다. -

+`CommonUtils`는 iOS 앱 개발에서 반복적으로 사용하는 확장 함수, UI 헬퍼, 네트워크 유틸, 미디어 처리 기능을 모아둔 Swift Package입니다. -## 🔧 특징 +## 주요 기능 -- 다양한 범용 유틸리티 함수 제공 -- Swift 5 이상 지원 -- Swift Package Manager(SPM)를 통한 간편한 통합 -

+- `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 컴포넌트 -## 📦 설치 방법 (Installation) +## 요구사항 -### Xcode를 통한 설치 +- Swift tools version: `5.10` +- Swift language version: `5` +- iOS 13.0+ +- macOS 10.15+ +- tvOS 13.0+ +- watchOS 6.0+ -1. Xcode에서 프로젝트를 열고, 메뉴에서 **File > Add Packages...** 를 선택합니다. -2. 다음 저장소 URL을 입력합니다: https://github.com/appsstudioio/CommonUtils.git -3. 버전을 선택하고 **Add Package** 를 클릭하여 설치를 완료합니다. +> 이 패키지는 UIKit 기반 기능과 `ProgressHUD` 의존성이 포함되어 있어 실제 테스트/빌드는 iOS 대상에서 검증하는 것을 권장합니다. -### Package.swift를 통한 설치 -````swift +## 의존성 + +| 패키지 | 버전 범위 | +| --- | --- | +| 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` | + +## 설치 + +### Xcode + +1. Xcode에서 프로젝트를 엽니다. +2. **File > Add Packages...** 를 선택합니다. +3. 저장소 URL을 입력합니다. + +```text +https://github.com/appsstudioio/CommonUtils.git +``` + +4. 사용할 버전을 선택한 뒤 `CommonUtils`를 앱 타겟에 추가합니다. + +### Package.swift + +```swift dependencies: [ - .package(url: "https://github.com/appsstudioio/CommonUtils.git", from: "1.0.0") + .package(url: "https://github.com/appsstudioio/CommonUtils.git", from: "1.0.0") ] -```` -그리고 필요한 타겟에 CommonUtils를 추가합니다: -````swift +``` + +```swift targets: [ .target( name: "YourTargetName", dependencies: ["CommonUtils"] ) ] -```` -
+``` -## 💡 사용 예시 +## 사용 예시 -````swift +```swift import CommonUtils +import UIKit + +let formatted = 1234567.withCommas() +print(formatted) // 1,234,567 + +let phoneNumber = "0212345678".toPhoneNumberFormat() +print(phoneNumber) // 02-1234-5678 + +let color = UIColor(hex: "#FF9900") +print(color?.toHex(isAlpha: false) ?? "") +``` + +### 이미지 로딩 + +```swift +imageView.setImage( + "https://example.com/image.jpg", + placeholder: UIImage(named: "placeholder"), + imageResize: CGSize(width: 300, height: 300) +) +``` + +원본 크기 캐시/표시가 필요하면 다운샘플링을 건너뛸 수 있습니다. + +```swift +imageView.setImage( + "https://example.com/image.jpg", + skipDownsampling: true +) +``` + +### Combine UIKit 이벤트 + +```swift +button.tapPublisher + .sink { + print("button tapped") + } + .store(in: &cancellables) +``` + +## 테스트 + +로컬에서 iOS 빌드를 확인하려면 다음 명령을 사용합니다. + +```bash +xcodebuild -scheme CommonUtils -destination generic/platform=iOS build +``` + +테스트는 iOS Simulator 대상에서 실행합니다. + +```bash +xcodebuild test -scheme CommonUtils -destination 'platform=iOS Simulator,name=iPhone 16' +``` + +Pull Request가 열리거나 갱신되면 GitHub Actions가 사용 가능한 iPhone 시뮬레이터를 선택해 테스트를 실행합니다. PR 본문은 커밋 메시지를 기준으로 자동 갱신됩니다. + +## 릴리즈 + +버전 태그를 push하면 GitHub Release가 자동으로 생성됩니다. 태그는 `1.2.3` 또는 `v1.2.3` 형식을 사용할 수 있습니다. -// 예: 문자열에 이모지가 있는지 -let emoji = "Hello world 😄" -if emoji.isEmoji { - print("이모지가 있습니다.") -} +```bash +git tag 1.2.3 +git push origin 1.2.3 +``` -// 예: 날짜 포맷 변환 -let dateString = "2024/04/15" -if let date = dateString.toDate(format: "yyyy-MM-dd") { - print("변환된 날짜: \(date)") -} +릴리즈 노트를 직접 관리하려면 `CHANGELOG.md`에 아래 형식으로 섹션을 추가합니다. -// 예: 색상을 Hex 코드로 변경 -let color = UIColor(red: 1, green: 0.5, blue: 0, alpha: 1) -print("\(color.toHex(isAlpha: false))") -```` -
+```markdown +## [1.2.3] - 2026-06-23 -## 🤝 기여 방법 +- 변경 내용 1 +- 변경 내용 2 +``` -기여를 환영합니다! -이슈를 등록하거나 Pull Request를 통해 개선사항을 제안해주세요. -

+태그와 같은 버전 섹션이 있으면 해당 내용을 릴리즈 본문으로 사용하고, 없으면 GitHub의 자동 생성 릴리즈 노트를 사용합니다. -## 📄 라이선스 +## 라이선스 -이 프로젝트는 MIT 라이선스 하에 배포됩니다. -자세한 내용은 LICENSE 파일을 참고해주세요. +이 프로젝트는 MIT 라이선스 하에 배포됩니다. 자세한 내용은 [LICENSE](LICENSE)를 참고하세요. From 370fa0dec5c7a15ea916ec1ee25fa07be4cc981f Mon Sep 17 00:00:00 2001 From: "dongju.lim" Date: Tue, 23 Jun 2026 18:16:35 +0900 Subject: [PATCH 7/9] =?UTF-8?q?[20260623]=20=ED=85=8C=EC=8A=A4=ED=8A=B8:?= =?UTF-8?q?=20=EB=82=A0=EC=A7=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EB=8C=80=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 + .../CommonUtilsTests/DateExtensionTests.swift | 102 +++++++++--------- .../StringExtensionTests.swift | 7 +- 3 files changed, 62 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 749865b..2ee103e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,10 @@ - PR 본문을 커밋 메시지 기반으로 자동 작성하는 GitHub Actions 워크플로를 추가했습니다. - 태그 push 시 GitHub Release를 생성하고, 이 changelog의 해당 버전 섹션을 릴리즈 본문으로 사용하는 워크플로를 추가했습니다. +### 테스트 + +- Date/String 테스트가 CI runner의 시간대와 locale에 영향받지 않도록 기대값을 명시했습니다. + ## [1.0.16] - 2025-11-12 - iOS 26 대응을 위한 UIApplication 관련 코드를 개선했습니다. diff --git a/Tests/CommonUtilsTests/DateExtensionTests.swift b/Tests/CommonUtilsTests/DateExtensionTests.swift index 9d3c86f..51cdd13 100644 --- a/Tests/CommonUtilsTests/DateExtensionTests.swift +++ b/Tests/CommonUtilsTests/DateExtensionTests.swift @@ -10,6 +10,8 @@ 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() @@ -19,16 +21,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(), .sunday) + XCTAssertEqual(date.dayOfWeek(timeZone: kstTimeZone, locale: koKRLocale), .sunday) let monday = formatter.date(from: "2024-04-15 12:00:00")! - XCTAssertEqual(monday.dayOfWeek(), .monday) + XCTAssertEqual(monday.dayOfWeek(timeZone: kstTimeZone, locale: koKRLocale), .monday) let wednesday = formatter.date(from: "2024-04-17 10:00:00")! - XCTAssertEqual(wednesday.dayOfWeek(), .wednesday) + XCTAssertEqual(wednesday.dayOfWeek(timeZone: kstTimeZone, locale: koKRLocale), .wednesday) let saturday = formatter.date(from: "2024-04-20 23:59:59")! - XCTAssertEqual(saturday.dayOfWeek(), .saturday) + XCTAssertEqual(saturday.dayOfWeek(timeZone: kstTimeZone, locale: koKRLocale), .saturday) let tz = TimeZone(abbreviation: "UTC")! let dateUTC = formatter.date(from: "2024-04-14 00:00:00")! @@ -38,11 +40,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"), "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") + 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") } // MARK: - betweenDates(toDate:component:) 테스트 @@ -68,24 +70,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"), "2024-04-01") - XCTAssertEqual(date.toLastDay.toString(format: "yyyy-MM-dd"), "2024-04-30") + 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") let febDate = formatter.date(from: "2024-02-10 00:00:00")! - XCTAssertEqual(febDate.toFirstDay.toString(format: "yyyy-MM-dd"), "2024-02-01") - XCTAssertEqual(febDate.toLastDay.toString(format: "yyyy-MM-dd"), "2024-02-29") + 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") let jan = formatter.date(from: "2024-01-05 00:00:00")! - XCTAssertEqual(jan.toFirstDay.toString(format: "yyyy-MM-dd"), "2024-01-01") - XCTAssertEqual(jan.toLastDay.toString(format: "yyyy-MM-dd"), "2024-01-31") + 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") let dec = formatter.date(from: "2023-12-31 00:00:00")! - XCTAssertEqual(dec.toFirstDay.toString(format: "yyyy-MM-dd"), "2023-12-01") - XCTAssertEqual(dec.toLastDay.toString(format: "yyyy-MM-dd"), "2023-12-31") + 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") let random = formatter.date(from: "2022-06-10 12:00:00")! - XCTAssertEqual(random.toFirstDay.toString(format: "yyyy-MM-dd"), "2022-06-01") - XCTAssertEqual(random.toLastDay.toString(format: "yyyy-MM-dd"), "2022-06-30") + 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") } // MARK: - toCalculateDateString(from nowDate: Date = Date()) -> String 테스트 @@ -150,41 +152,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"), "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") + 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") } // 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"), "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") + 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") } // 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"), "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") + 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") } // MARK: - toYearDate(_:) func test_toYearDate() throws { let date = formatter.date(from: "2024-04-15 00:00:00")! - 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") + 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") } // MARK: - getComponentValue(_:) @@ -192,11 +194,11 @@ final class DateExtensionTests: XCTestCase { guard let date = formatter.date(from: "2024-04-15 13:45:30") else { throw XCTSkip("데이트 생성 안됨. 패스!!") } - 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) + 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) } // MARK: - dateKST (한국 시간 변환) @@ -208,29 +210,29 @@ final class DateExtensionTests: XCTestCase { // KST 기준 포매터로 설정 let formatter = DateFormatter() - formatter.timeZone = TimeZone(identifier: "Asia/Seoul") + formatter.timeZone = kstTimeZone 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"), kstDateString) + XCTAssertEqual(kst.toString(format: "yyyy-MM-dd HH:mm:ss", timeZone: kstTimeZone, locale: koKRLocale), kstDateString) // ✅ 고정된 날짜 변환 확인 let sampleDate = formatter.date(from: "2024-04-15 00:00:00")! - XCTAssertEqual(sampleDate.dateKST?.toString(format: "yyyy-MM-dd HH:mm:ss"), "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") // ✅ 음수 타임스탬프 확인 (-1시간) let negativeDate = Date(timeIntervalSince1970: -3600) let expectedNegativeString = formatter.string(from: negativeDate) - XCTAssertEqual(negativeDate.dateKST?.toString(format: "yyyy-MM-dd HH:mm:ss"), expectedNegativeString) + XCTAssertEqual(negativeDate.dateKST?.toString(format: "yyyy-MM-dd HH:mm:ss", timeZone: kstTimeZone, locale: koKRLocale), expectedNegativeString) // ✅ 현재 시간 기준 KST 변환도 날짜로 비교 let now = Date() let expectedNowKST = formatter.string(from: now) - XCTAssertEqual(now.dateKST?.toString(format: "yyyy-MM-dd HH:mm:ss"), expectedNowKST) + XCTAssertEqual(now.dateKST?.toString(format: "yyyy-MM-dd HH:mm:ss", timeZone: kstTimeZone, locale: koKRLocale), expectedNowKST) // ✅ 시간 컴포넌트만 추출해서 KST 기준으로 9시 확인 (1970-01-01 09:00:00) - let kstHour = Calendar(identifier: .gregorian).dateComponents(in: TimeZone(identifier: "Asia/Seoul")!, from: kst).hour + let kstHour = Calendar(identifier: .gregorian).dateComponents(in: kstTimeZone, from: kst).hour XCTAssertEqual(kstHour, 9) } } diff --git a/Tests/CommonUtilsTests/StringExtensionTests.swift b/Tests/CommonUtilsTests/StringExtensionTests.swift index c48f542..9cc27fe 100644 --- a/Tests/CommonUtilsTests/StringExtensionTests.swift +++ b/Tests/CommonUtilsTests/StringExtensionTests.swift @@ -264,7 +264,12 @@ 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") - XCTAssertEqual(result, "1월 1, 2024") + + let formatter = DateFormatter() + formatter.setFormatter() + let date = formatter.date(from: input)! + formatter.dateFormat = "MMM d, yyyy" + XCTAssertEqual(result, formatter.string(from: date)) } func test_dateFormatChange_withInvalidDateString() throws { From 4349ac5a540a5c0cd4ce0c955a1232a42e731caf Mon Sep 17 00:00:00 2001 From: "dongju.lim" Date: Wed, 24 Jun 2026 10:01:38 +0900 Subject: [PATCH 8/9] =?UTF-8?q?[20260624]=20=ED=85=8C=EC=8A=A4=ED=8A=B8:?= =?UTF-8?q?=20CI=20=EB=B9=84=EB=94=94=EC=98=A4=20=EC=95=95=EC=B6=95=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-tests.yml | 35 +++++++++++++++++++ CHANGELOG.md | 1 + .../CommonUtilsFunctionTests.swift | 7 +++- .../CommonUtilsTests/DateExtensionTests.swift | 2 +- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 1c2116a..72f862a 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -2,6 +2,7 @@ name: PR Tests on: pull_request: + workflow_dispatch: permissions: contents: read @@ -34,4 +35,38 @@ jobs: xcodebuild test \ -scheme CommonUtils \ -destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \ + -skip-testing:CommonUtilsTests/VideoCompressionTests/testCompressVideo_NoAudioTrack_Success \ + CODE_SIGNING_ALLOWED=NO + + video-compression-test: + name: Video Compression Integration Test + runs-on: macos-15 + timeout-minutes: 20 + if: github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'run-video-compression-tests') + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select iPhone simulator + run: | + SIMULATOR_ID="$( + xcrun simctl list devices available -j | ruby -rjson -e ' + devices = JSON.parse(STDIN.read)["devices"].values.flatten + simulator = devices.find { |device| device["name"].include?("iPhone") && device["isAvailable"] } + abort("No available iPhone simulator") unless simulator + puts simulator["udid"] + ' + )" + echo "SIMULATOR_ID=${SIMULATOR_ID}" >> "${GITHUB_ENV}" + echo "Selected simulator: ${SIMULATOR_ID}" + + - name: Run no-audio compression test + env: + RUN_VIDEO_COMPRESSION_TESTS: "1" + run: | + xcodebuild test \ + -scheme CommonUtils \ + -destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \ + -only-testing:CommonUtilsTests/VideoCompressionTests/testCompressVideo_NoAudioTrack_Success \ CODE_SIGNING_ALLOWED=NO diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ee103e..d2fa829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ ### 테스트 - Date/String 테스트가 CI runner의 시간대와 locale에 영향받지 않도록 기대값을 명시했습니다. +- 오디오 없는 비디오 압축 테스트를 CI 기본 테스트에서 분리하고, 별도 조건부 job에서 실행하도록 조정했습니다. ## [1.0.16] - 2025-11-12 diff --git a/Tests/CommonUtilsTests/CommonUtilsFunctionTests.swift b/Tests/CommonUtilsTests/CommonUtilsFunctionTests.swift index 01f374b..3766c96 100644 --- a/Tests/CommonUtilsTests/CommonUtilsFunctionTests.swift +++ b/Tests/CommonUtilsTests/CommonUtilsFunctionTests.swift @@ -223,6 +223,11 @@ final class VideoCompressionTests: XCTestCase { // MARK: - 오디오 없는 비디오도 정상 압축 처리 func testCompressVideo_NoAudioTrack_Success() throws { + if ProcessInfo.processInfo.environment["CI"] == "true", + ProcessInfo.processInfo.environment["RUN_VIDEO_COMPRESSION_TESTS"] != "1" { + throw XCTSkip("CI 기본 테스트에서는 비디오 압축 통합 테스트를 별도 job으로 분리합니다.") + } + // 오디오 트랙이 없는 샘플 파일을 미리 프로젝트에 포함시켜야 함 (예: "no_audio.mp4") guard let videoPath = loadTestVideo(named: "video_no_audio") else { XCTFail("video_no_audio.mp4 파일이 필요합니다") @@ -247,7 +252,7 @@ final class VideoCompressionTests: XCTestCase { expectation.fulfill() } - wait(for: [expectation], timeout: 30) + wait(for: [expectation], timeout: 90) } // MARK: - validateHTML Tests diff --git a/Tests/CommonUtilsTests/DateExtensionTests.swift b/Tests/CommonUtilsTests/DateExtensionTests.swift index 51cdd13..2199b8d 100644 --- a/Tests/CommonUtilsTests/DateExtensionTests.swift +++ b/Tests/CommonUtilsTests/DateExtensionTests.swift @@ -111,7 +111,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()) + XCTAssertEqual(hundredDaysAgo.toCalculateDateString(from: now), "오래".localization() + "전".localization()) } // MARK: - toUnixTimeStamp From 8501ea2649b0205b4a0e2f5106b7586257f65879 Mon Sep 17 00:00:00 2001 From: "dongju.lim" Date: Wed, 24 Jun 2026 10:13:56 +0900 Subject: [PATCH 9/9] =?UTF-8?q?[20260624]=20CI:=20=EB=B9=84=EB=94=94?= =?UTF-8?q?=EC=98=A4=20=EC=95=95=EC=B6=95=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=EB=B6=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-tests.yml | 6 +++++- CHANGELOG.md | 2 +- .../CommonUtilsFunctionTests.swift | 16 ++++++++++++---- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 72f862a..e276e0c 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -35,6 +35,8 @@ jobs: xcodebuild test \ -scheme CommonUtils \ -destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \ + -skip-testing:CommonUtilsTests/VideoCompressionTests/testCompressVideo_Success \ + -skip-testing:CommonUtilsTests/VideoCompressionTests/testCompressVideo_TooSmallMaxFileSize_ShouldFail \ -skip-testing:CommonUtilsTests/VideoCompressionTests/testCompressVideo_NoAudioTrack_Success \ CODE_SIGNING_ALLOWED=NO @@ -61,12 +63,14 @@ jobs: echo "SIMULATOR_ID=${SIMULATOR_ID}" >> "${GITHUB_ENV}" echo "Selected simulator: ${SIMULATOR_ID}" - - name: Run no-audio compression test + - name: Run video compression integration tests env: RUN_VIDEO_COMPRESSION_TESTS: "1" run: | xcodebuild test \ -scheme CommonUtils \ -destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \ + -only-testing:CommonUtilsTests/VideoCompressionTests/testCompressVideo_Success \ + -only-testing:CommonUtilsTests/VideoCompressionTests/testCompressVideo_TooSmallMaxFileSize_ShouldFail \ -only-testing:CommonUtilsTests/VideoCompressionTests/testCompressVideo_NoAudioTrack_Success \ CODE_SIGNING_ALLOWED=NO diff --git a/CHANGELOG.md b/CHANGELOG.md index d2fa829..fa382ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,7 @@ ### 테스트 - Date/String 테스트가 CI runner의 시간대와 locale에 영향받지 않도록 기대값을 명시했습니다. -- 오디오 없는 비디오 압축 테스트를 CI 기본 테스트에서 분리하고, 별도 조건부 job에서 실행하도록 조정했습니다. +- 비디오 압축 통합 테스트를 CI 기본 테스트에서 분리하고, 별도 조건부 job에서 실행하도록 조정했습니다. ## [1.0.16] - 2025-11-12 diff --git a/Tests/CommonUtilsTests/CommonUtilsFunctionTests.swift b/Tests/CommonUtilsTests/CommonUtilsFunctionTests.swift index 3766c96..6c95ff3 100644 --- a/Tests/CommonUtilsTests/CommonUtilsFunctionTests.swift +++ b/Tests/CommonUtilsTests/CommonUtilsFunctionTests.swift @@ -149,6 +149,13 @@ 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) @@ -180,6 +187,8 @@ final class VideoCompressionTests: XCTestCase { // MARK: - 기본 압축 성공 케이스 func testCompressVideo_Success() throws { + try skipVideoCompressionIntegrationTestInDefaultCI() + let expectation = XCTestExpectation(description: "비디오 압축 성공") CommonUtils.compressVideo(inputURL: inputVideoURL, @@ -202,6 +211,8 @@ final class VideoCompressionTests: XCTestCase { // MARK: - 너무 작은 maxFileSize로 인해 압축 실패 func testCompressVideo_TooSmallMaxFileSize_ShouldFail() throws { + try skipVideoCompressionIntegrationTestInDefaultCI() + let expectation = XCTestExpectation(description: "너무 작은 파일 크기로 압축 실패") CommonUtils.compressVideo(inputURL: inputVideoURL, @@ -223,10 +234,7 @@ final class VideoCompressionTests: XCTestCase { // MARK: - 오디오 없는 비디오도 정상 압축 처리 func testCompressVideo_NoAudioTrack_Success() throws { - if ProcessInfo.processInfo.environment["CI"] == "true", - ProcessInfo.processInfo.environment["RUN_VIDEO_COMPRESSION_TESTS"] != "1" { - throw XCTSkip("CI 기본 테스트에서는 비디오 압축 통합 테스트를 별도 job으로 분리합니다.") - } + try skipVideoCompressionIntegrationTestInDefaultCI() // 오디오 트랙이 없는 샘플 파일을 미리 프로젝트에 포함시켜야 함 (예: "no_audio.mp4") guard let videoPath = loadTestVideo(named: "video_no_audio") else {