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