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..e276e0c
--- /dev/null
+++ b/.github/workflows/pr-tests.yml
@@ -0,0 +1,76 @@
+name: PR Tests
+
+on:
+ pull_request:
+ workflow_dispatch:
+
+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}" \
+ -skip-testing:CommonUtilsTests/VideoCompressionTests/testCompressVideo_Success \
+ -skip-testing:CommonUtilsTests/VideoCompressionTests/testCompressVideo_TooSmallMaxFileSize_ShouldFail \
+ -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 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/.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..fa382ea
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,133 @@
+# 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의 해당 버전 섹션을 릴리즈 본문으로 사용하는 워크플로를 추가했습니다.
+
+### 테스트
+
+- Date/String 테스트가 CI runner의 시간대와 locale에 영향받지 않도록 기대값을 명시했습니다.
+- 비디오 압축 통합 테스트를 CI 기본 테스트에서 분리하고, 별도 조건부 job에서 실행하도록 조정했습니다.
+
+## [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/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: [
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)를 참고하세요.
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
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/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/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/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/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/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)]"
diff --git a/Tests/CommonUtilsTests/CommonUtilsFunctionTests.swift b/Tests/CommonUtilsTests/CommonUtilsFunctionTests.swift
index 01f374b..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,6 +234,8 @@ 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 파일이 필요합니다")
@@ -247,7 +260,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 9d3c86f..2199b8d 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 테스트
@@ -109,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
@@ -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/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/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/StringExtensionTests.swift b/Tests/CommonUtilsTests/StringExtensionTests.swift
index 4ba10e5..9cc27fe 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"
@@ -252,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 {
@@ -267,6 +284,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 +614,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/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)
+ }
+}
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)