docs(recommendation): 서브 배너 리뷰와 QA 증거를 보존한다
@@ -0,0 +1,72 @@
|
||||
# 추천 서브 배너 Native QA
|
||||
|
||||
**결과: 실행한 native 배치·비율·empty·자동 순환·이미지 실패 격리 시나리오 PASS. 물리 탭/swipe, Reduce Motion ON, 실제 앱 전체 통합은 NOT RUN이다.** 제품 파일, 부모 PRD/계획은 수정하지 않았다.
|
||||
|
||||
## 실행 Surface와 출처
|
||||
|
||||
- 부모 소유 iPhone SE3/iOS 18.3, UDID `482F7827-2E3A-4559-BAF4-48B68CC5725B`, 375×667pt, 2x. Xcode 26 SDK로 `x86_64-apple-ios16.6-simulator` 대상 독립 UIKit `UIHostingController` 앱을 컴파일했다. iOS 16.6 런타임 실행은 아니다.
|
||||
- **production `SubBanner.swift`, `SubBannerLinkOpener.swift`, `SubBannerCarousel.swift`, `MainHomeRecommendationView.swift`, `MainContentRecommendationView.swift`를 수정·추출·복제하지 않고 원본 경로 그대로 컴파일했다.** 실제 spacing/color/hex/font 소스도 동일하게 포함했다.
|
||||
- Kingfisher는 prebuilt `Kingfisher.o`와 x86_64 module을 링크했다. workspace/project `Package.resolved`는 `7.12.0`이다. `KFImage`를 대역으로 바꾸지 않았다. [dependency-hashes.json](dependency-hashes.json)에 링크 입력 해시가 있다.
|
||||
- Kingfisher의 실제 downloader에 `URLSessionConfiguration.ephemeral.protocolClasses = [FixtureProtocol.self]`를 설정했다. QA host만 생성 PNG로 응답하고 그 외 요청은 로컬에서 실패시킨다. 인터넷·서버·계정은 사용하지 않았다. 1200×400 teal A, 1200×600 orange B는 explicit QA 이미지이며 이미지 안 글자는 fixture 내용이다.
|
||||
- [source-hashes-before.json](source-hashes-before.json), [source-hashes-after.json](source-hashes-after.json), [source-hashes-final.json](source-hashes-final.json)은 production 입력 9개 모두 동일하다. [source-stability.json](source-stability.json)의 `unchanged=true`. 따라서 이번 실행 중 재캡처가 필요한 제품 변경은 없었다.
|
||||
|
||||
## 명시적인 대역과 한계
|
||||
|
||||
[source/Doubles.swift](source/Doubles.swift)에 대체 목록 전체가 있다. 두 recommendation **View composition 자체는 실제 production**이며, 하위 섹션은 `QA ...`라고 적힌 48pt 회색 bar 또는 `EmptyView`로 대체했다. VM은 네트워크를 호출하지 않는 fixture VM, DTO는 최소 `FixtureResponse`, refresh wrapper는 native `ScrollView`, toast/purchase/media player는 무효 대역이다. 실제 기존 메인 배너 carousel은 이 하네스에서 실행하지 않았다.
|
||||
|
||||
따라서 원문 View의 조건부 삽입 위치·`VStack(s24)`·부모 수평 padding 없음은 검증했지만, 실제 추천 카드 높이/로딩/refresh/toast/구매·API·인증·추천 response 필터 및 콘텐츠 `isEmpty`는 이 QA로 검증하지 않았다. `FixtureResponse.eligibleSubBanners`는 `isEligible`만 사용한다. placement 분리·실서버 목록은 부모 focused checks 범위다. 오류 격리에서 주변 **대역 bar 유지**를 관찰했으며 실제 콘텐츠의 탭 동작을 증명하지 않는다.
|
||||
|
||||
## 증거와 판정
|
||||
|
||||
[captures/actions.json](captures/actions.json)에 monotonic timestamp, 입력 id 전체, 이미지 요청, scene phase, OS Reduce Motion 값, 주변 섹션/direct carousel 실제 geometry와 callback driver 결과가 있다. 모든 41개 native PNG는 앱 window `drawHierarchy` 캡처이며 별도 [native-os-final.png](native-os-final.png)는 `simctl io screenshot`이다. 42개 원본은 750×1334 PNG다. [contact-1.png](contact-1.png)~[contact-4.png](contact-4.png)는 원본을 축소 배치한 검토용 contact sheet이지 별도 UI 구현이 아니다.
|
||||
|
||||
| 시나리오 | 판정 | 실제 관측/증거 |
|
||||
|---|---|---|
|
||||
| 홈 지정 위치 | PASS | [home-first](captures/home-first.png): QA active creators 다음, debut 이전. 기존 main banner 앞뒤 순서 유지 |
|
||||
| 콘텐츠 지정 위치 | PASS | [content-first](captures/content-first.png): QA main banner 다음, exclusive 이전 |
|
||||
| 전체 폭·원본 비율 | PASS | 양 화면 A는 x=0,width=375,height=125pt, B는 x=0,width=375,height=187.5pt. [home-second](captures/home-second.png), [content-second](captures/content-second.png) |
|
||||
| s24·empty 추가 gap 없음 | PASS | 이미지 위/아래 각각 24pt. empty는 인접 bar 사이 일반 24pt만 남고 배너 추가 간격 0pt. [home-empty](captures/home-empty.png), [content-empty](captures/content-empty.png) |
|
||||
| 실제 5초 sleep 자동 전환 | PASS, 정밀도 한정 | production sleep 값 변경 없음. Home은 scenario 시작 후 4.928s 기록까지 A, 5.807s 기록의 transition에서 B 이동 중, 6.553s 기록에 B 정착. Content는 4.632s 기록 A, 5.526s 기록 transition, 6.306s 기록 B. 기록은 screenshot 완료 뒤라 transition 시각은 정확한 시작점이 아니다 |
|
||||
| 2항목 wrap | PASS | [home-wrap](captures/home-wrap.png), [content-wrap](captures/content-wrap.png): 다시 A 01/02 |
|
||||
| 단일 항목 고정 | PASS, timer 생성 여부 직접 계측 아님 | 양 화면 one/one-after-6s PNG SHA-256 동일, 01/01 유지. production count guard도 보존 |
|
||||
| 21개·20개 제한 없음 | PASS | many-first 01/21 → [many-twenty-first](captures/many-twenty-first.png)는 실제 **20/21** → [many-wrap](captures/many-wrap.png)는 실제 **21/21** → OS final 01/21. 실제 약 106초 기다렸고 timer/선택 state를 직접 바꾸지 않음 |
|
||||
| 컨테이너 resize | PASS | 양 화면 375→320pt에서 x=0,width=320,height=106.6667pt. [home-resize-320](captures/home-resize-320.png), [content-resize-320](captures/content-resize-320.png). 기기 회전/실제 화면 resize가 아니라 부모 SwiftUI frame 변경 |
|
||||
| 지연 이미지·현재 비율 격리 | PASS | 첫 A 요청 8초 지연, 초기 carousel height=0. [delayed-next-valid](captures/delayed-next-valid.png)에서 B height187.5. 늦은 A 성공 후 [delayed-old-image-arrived](captures/delayed-old-image-arrived.png)도 B/187.5 유지 |
|
||||
| 실패 이미지 국소 처리 | PASS | A→실패 B에서 height125 회색 placeholder와 02/02, 주변 QA bar 유지. [failure-placeholder-other-content](captures/failure-placeholder-other-content.png). 이후 [failure-wrap-valid](captures/failure-wrap-valid.png)에서 A 정상 |
|
||||
| inactive 중지/active 재개 | PASS, 주입 환경 | SwiftUI scenePhase `.inactive` 실제 전달 probe 후 6.094초 두 PNG SHA 동일 B 유지. active 전달 후 4.790초 기록 B, 6.276초 기록 A. [inactive-after-6s](captures/inactive-after-6s.png), [active-next](captures/active-next.png). OS background 실험은 NOT RUN |
|
||||
| unmount/remount·축소 | PASS | 6초 unmount 후 [remount-first](captures/remount-first.png) A, 이후 [remount-next](captures/remount-next.png) B. 2→1은 [shrink-one](captures/shrink-one.png) A/01/01 |
|
||||
| light/dark | PASS, 제품 black 유지 | 각 화면 light capture와 dark first 동일한 검은 화면/legible indicator. production black 배경과 하네스 명시 override 사용. System theme 전환 전체 앱은 미검증 |
|
||||
| 링크 fallback driver | PASS, 실제 탭 아님 | production opener에 false completion 주입: `sodaqa://banner/900` 후 `https://subbanner.qa.invalid/destination` 각 1회. `opener-driver-not-physical-tap` 로그. `UIApplication.open` 실제 실행 없음 |
|
||||
| physical tap/swipe·hit testing | NOT RUN | native 좌표 입력 도구 없음. 캐러셀의 `onTap` callback은 physical input으로 실행하지 않음. gesture 정의 확인을 동작 PASS로 취급하지 않음 |
|
||||
| Reduce Motion ON | NOT RUN | `accessibilityReduceMotion`은 read-only environment라 setter 주입 compile 실패. 해당 modifier를 QA 소스에서 제거 후 성공. OS 값 false trace만 수집. 부모 simulator 접근성 설정은 바꾸지 않음. `reduce-*` 이름은 ON 증거가 아님 |
|
||||
|
||||
`many-twenty-first`/`many-wrap`/`reduce-transition` 등 이름은 예정 촬영 시점명일 뿐 실제 상태 판정이 아니다. 실제 페이지/상태는 위 관측과 PNG를 따른다. scheduler 및 이미지 준비 시간으로 21번째 예정 시점에는 20번째였고 다음 캡처에 21번째가 확인됐다. 엄밀한 5.000초 정확도, 프레임률, 애니메이션 매끄러움·인터럽트·장시간 중복 timer 부재는 보증하지 않는다.
|
||||
|
||||
## Geometry와 검증 방법
|
||||
|
||||
[verification.json](verification.json)은 주변 QA bar의 SwiftUI global frame 차이와 PNG의 양끝 fixture 색상 scan을 교차 검증한다. Home A/B는 y280pt, Content A/B는 y136pt. Home 뒤 bar y429→491.5, Content 뒤 bar y285→347.5. Empty 뒤 bar는 각각 y280/y136으로 올라와 원래 섹션 간격만 남았다. Direct context는 `.background(GeometryReader)`로 실제 carousel geometry를 기록한다. production carousel에 modifier를 삽입하지 않았다.
|
||||
|
||||
[source/assess.py](source/assess.py) 실행 exit0: product 해시 동일, 모든 native PNG signature/750×1334/소스보다 신선함, 두 화면 전체 폭/비율/s24/empty/320pt, single 고정, inactive 고정, 지연 image 높이 격리, 21개 입력, opener 순서 assert 모두 PASS. 원본 주요 PNG와 전체 contact sheet를 직접 열어 겹침·잘림·페이지 번호·fixture 순서를 확인했다. 독립 리뷰는 요청에 따라 호출하지 않았다.
|
||||
|
||||
## 실제 명령과 진단
|
||||
|
||||
정확한 확장 인자·stdout/stderr/exit는 [commands.json](commands.json)에 있다. 전체 앱 `xcodebuild`는 실행하지 않았다.
|
||||
|
||||
```bash
|
||||
export TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native.tstn99
|
||||
export NODE_COMPILE_CACHE="$TMPDIR"
|
||||
export BUN_RUNTIME_TRANSPILER_CACHE_PATH="$TMPDIR"
|
||||
python3 docs/20261029_추천_서브_배너/reviews/evidence/native/source/run.py
|
||||
python3 docs/20261029_추천_서브_배너/reviews/evidence/native/source/assess.py
|
||||
python3 docs/20261029_추천_서브_배너/reviews/evidence/native/source/cleanup.py
|
||||
```
|
||||
|
||||
핵심 compile: `xcrun --sdk iphonesimulator swiftc -swift-version 5 -target x86_64-apple-ios16.6-simulator -sdk <iPhoneSimulator26.0.sdk> -I <Debug-iphonesimulator> -module-cache-path <own-root>/module-cache <production 9 sources> <Doubles.swift> <Harness.swift> <Kingfisher.o> -o <own-root>/SubBannerQA.app/SubBannerQA`. Install/launch는 지정 부모 UDID와 독립 bundle `qa.sodalive.subbanner.native.tstn99`만 사용했다.
|
||||
|
||||
초기 컴파일은 QA의 read-only Reduce Motion setter 사용으로 exit1, modifier 제거 후 동일 native 전략의 두 번째 컴파일 exit0. 실패 명령도 receipt에 보존했다. LSP 진단 도구는 제공되지 않아 실행하지 못했으며 실제 swiftc compile 진단으로 대체했다. QA는 현재 소스를 그대로 소비했고 production 수정은 하지 않았다.
|
||||
|
||||
## 소유권과 정리
|
||||
|
||||
[ownership.json](ownership.json)에 앱 bundle, PID `93117`, own temporary root를 launch 직후 기록했다. [cleanup.json](cleanup.json)은 **own app 종료·uninstall, PID 부재, own root 삭제, 부모 device Booted 유지, product 해시 동일**을 확인한다. 공유 cache 삭제, simulator shutdown/delete/reset, worktree, commit/push, 외부 서비스 호출은 하지 않았다.
|
||||
|
||||
남은 확인: physical tap/swipe, Reduce Motion ON, OS background/foreground, 실기기/iOS16.6/Dynamic Type/VoiceOver, 전체 SodaLive 앱 통합·실서버는 미검증이다. 이 결과는 native 부분 수용 증거이며 전체 제품 최종 승인이나 독립 리뷰 PASS를 대신하지 않는다.
|
||||
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"commands": [
|
||||
{
|
||||
"args": [
|
||||
"xcrun",
|
||||
"simctl",
|
||||
"terminate",
|
||||
"482F7827-2E3A-4559-BAF4-48B68CC5725B",
|
||||
"qa.sodalive.subbanner.native.tstn99"
|
||||
],
|
||||
"exit": 0,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
"xcrun",
|
||||
"simctl",
|
||||
"uninstall",
|
||||
"482F7827-2E3A-4559-BAF4-48B68CC5725B",
|
||||
"qa.sodalive.subbanner.native.tstn99"
|
||||
],
|
||||
"exit": 0,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
"xcrun",
|
||||
"simctl",
|
||||
"get_app_container",
|
||||
"482F7827-2E3A-4559-BAF4-48B68CC5725B",
|
||||
"qa.sodalive.subbanner.native.tstn99",
|
||||
"data"
|
||||
],
|
||||
"exit": 2,
|
||||
"stdout": "",
|
||||
"stderr": "An error was encountered processing the command (domain=NSPOSIXErrorDomain, code=2):\nThe operation couldn\u2019t be completed. No such file or directory\nNo such file or directory\n"
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
"ps",
|
||||
"-p",
|
||||
"93117",
|
||||
"-o",
|
||||
"pid=,comm="
|
||||
],
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
"xcrun",
|
||||
"simctl",
|
||||
"list",
|
||||
"devices",
|
||||
"booted",
|
||||
"-j"
|
||||
],
|
||||
"exit": 0,
|
||||
"stdout": "{\n \"devices\" : {\n \"com.apple.CoreSimulator.SimRuntime.iOS-17-2\" : [\n\n ],\n \"com.apple.CoreSimulator.SimRuntime.iOS-26-0\" : [\n\n ],\n \"com.apple.CoreSimulator.SimRuntime.iOS-18-3\" : [\n {\n \"lastBootedAt\" : \"2026-10-29T14:27:43Z\",\n \"dataPath\" : \"\\/Users\\/klaus\\/Library\\/Developer\\/CoreSimulator\\/Devices\\/482F7827-2E3A-4559-BAF4-48B68CC5725B\\/data\",\n \"dataPathSize\" : 1427083264,\n \"logPath\" : \"\\/Users\\/klaus\\/Library\\/Logs\\/CoreSimulator\\/482F7827-2E3A-4559-BAF4-48B68CC5725B\",\n \"udid\" : \"482F7827-2E3A-4559-BAF4-48B68CC5725B\",\n \"isAvailable\" : true,\n \"logPathSize\" : 249856,\n \"deviceTypeIdentifier\" : \"com.apple.CoreSimulator.SimDeviceType.iPhone-SE-3rd-generation\",\n \"state\" : \"Booted\",\n \"name\" : \"SubBannerQA-20261029\"\n }\n ],\n \"com.apple.CoreSimulator.SimRuntime.xrOS-1-0\" : [\n\n ],\n \"com.apple.CoreSimulator.SimRuntime.iOS-14-4\" : [\n\n ],\n \"com.apple.CoreSimulator.SimRuntime.iOS-15-0\" : [\n\n ],\n \"com.apple.CoreSimulator.SimRuntime.iOS-15-5\" : [\n\n ],\n \"com.apple.CoreSimulator.SimRuntime.iOS-17-5\" : [\n\n ],\n \"com.apple.CoreSimulator.SimRuntime.iOS-13-7\" : [\n\n ]\n }\n}\n",
|
||||
"stderr": ""
|
||||
}
|
||||
],
|
||||
"appAbsent": true,
|
||||
"pidAbsent": true,
|
||||
"rootAbsent": true,
|
||||
"parentDeviceStillBooted": true,
|
||||
"productHashesUnchanged": true,
|
||||
"sharedCacheDeleted": false
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
[
|
||||
{
|
||||
"args": [
|
||||
"xcrun",
|
||||
"--sdk",
|
||||
"iphonesimulator",
|
||||
"--show-sdk-path"
|
||||
],
|
||||
"exit": 0,
|
||||
"stdout": "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator26.0.sdk\n",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
"xcrun",
|
||||
"--sdk",
|
||||
"iphonesimulator",
|
||||
"swiftc",
|
||||
"-swift-version",
|
||||
"5",
|
||||
"-target",
|
||||
"x86_64-apple-ios16.6-simulator",
|
||||
"-sdk",
|
||||
"/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator26.0.sdk",
|
||||
"-I",
|
||||
"/Users/klaus/Library/Developer/Xcode/DerivedData/SodaLive-clzdtvqrsfonoxcijatzkrqdzrsu/Build/Products/Debug-iphonesimulator",
|
||||
"-module-cache-path",
|
||||
"/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native.tstn99/module-cache",
|
||||
"SodaLive/Sources/V2/Component/Banner/SubBanner.swift",
|
||||
"SodaLive/Sources/V2/Component/Banner/SubBannerLinkOpener.swift",
|
||||
"SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift",
|
||||
"SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift",
|
||||
"SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift",
|
||||
"SodaLive/Sources/UI/Theme/Spacing.swift",
|
||||
"SodaLive/Sources/UI/Theme/Color.swift",
|
||||
"SodaLive/Sources/Extensions/ColorExtension.swift",
|
||||
"SodaLive/Sources/Extensions/FontModifier.swift",
|
||||
"/Users/klaus/Develop/sodalive/iOS/SodaLive/docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source/Doubles.swift",
|
||||
"/Users/klaus/Develop/sodalive/iOS/SodaLive/docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source/Harness.swift",
|
||||
"/Users/klaus/Library/Developer/Xcode/DerivedData/SodaLive-clzdtvqrsfonoxcijatzkrqdzrsu/Build/Products/Debug-iphonesimulator/Kingfisher.o",
|
||||
"-o",
|
||||
"/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native.tstn99/SubBannerQA.app/SubBannerQA"
|
||||
],
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "/Users/klaus/Develop/sodalive/iOS/SodaLive/docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source/Harness.swift:93:30: error: cannot convert value of type 'KeyPath<EnvironmentValues, Bool>' to expected argument type 'WritableKeyPath<EnvironmentValues, Bool>'\n 91 | .background(PhaseProbe())\n 92 | .environment(\\.scenePhase, state.phase)\n 93 | .environment(\\.accessibilityReduceMotion, state.reduceMotion)\n | `- error: cannot convert value of type 'KeyPath<EnvironmentValues, Bool>' to expected argument type 'WritableKeyPath<EnvironmentValues, Bool>'\n 94 | }\n 95 | Spacer(minLength: 0)\n"
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
"xcrun",
|
||||
"--sdk",
|
||||
"iphonesimulator",
|
||||
"--show-sdk-path"
|
||||
],
|
||||
"exit": 0,
|
||||
"stdout": "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator26.0.sdk\n",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
"xcrun",
|
||||
"--sdk",
|
||||
"iphonesimulator",
|
||||
"swiftc",
|
||||
"-swift-version",
|
||||
"5",
|
||||
"-target",
|
||||
"x86_64-apple-ios16.6-simulator",
|
||||
"-sdk",
|
||||
"/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator26.0.sdk",
|
||||
"-I",
|
||||
"/Users/klaus/Library/Developer/Xcode/DerivedData/SodaLive-clzdtvqrsfonoxcijatzkrqdzrsu/Build/Products/Debug-iphonesimulator",
|
||||
"-module-cache-path",
|
||||
"/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native.tstn99/module-cache",
|
||||
"SodaLive/Sources/V2/Component/Banner/SubBanner.swift",
|
||||
"SodaLive/Sources/V2/Component/Banner/SubBannerLinkOpener.swift",
|
||||
"SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift",
|
||||
"SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift",
|
||||
"SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift",
|
||||
"SodaLive/Sources/UI/Theme/Spacing.swift",
|
||||
"SodaLive/Sources/UI/Theme/Color.swift",
|
||||
"SodaLive/Sources/Extensions/ColorExtension.swift",
|
||||
"SodaLive/Sources/Extensions/FontModifier.swift",
|
||||
"/Users/klaus/Develop/sodalive/iOS/SodaLive/docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source/Doubles.swift",
|
||||
"/Users/klaus/Develop/sodalive/iOS/SodaLive/docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source/Harness.swift",
|
||||
"/Users/klaus/Library/Developer/Xcode/DerivedData/SodaLive-clzdtvqrsfonoxcijatzkrqdzrsu/Build/Products/Debug-iphonesimulator/Kingfisher.o",
|
||||
"-o",
|
||||
"/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native.tstn99/SubBannerQA.app/SubBannerQA"
|
||||
],
|
||||
"exit": 0,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
"xcrun",
|
||||
"simctl",
|
||||
"install",
|
||||
"482F7827-2E3A-4559-BAF4-48B68CC5725B",
|
||||
"/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native.tstn99/SubBannerQA.app"
|
||||
],
|
||||
"exit": 0,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
"xcrun",
|
||||
"simctl",
|
||||
"launch",
|
||||
"482F7827-2E3A-4559-BAF4-48B68CC5725B",
|
||||
"qa.sodalive.subbanner.native.tstn99"
|
||||
],
|
||||
"exit": 0,
|
||||
"stdout": "qa.sodalive.subbanner.native.tstn99: 93117\n",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
"xcrun",
|
||||
"simctl",
|
||||
"get_app_container",
|
||||
"482F7827-2E3A-4559-BAF4-48B68CC5725B",
|
||||
"qa.sodalive.subbanner.native.tstn99",
|
||||
"data"
|
||||
],
|
||||
"exit": 0,
|
||||
"stdout": "/Users/klaus/Library/Developer/CoreSimulator/Devices/482F7827-2E3A-4559-BAF4-48B68CC5725B/data/Containers/Data/Application/BB5A2FB5-0655-49EF-862F-7DC3EC07B3BE\n",
|
||||
"stderr": ""
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
"xcrun",
|
||||
"simctl",
|
||||
"io",
|
||||
"482F7827-2E3A-4559-BAF4-48B68CC5725B",
|
||||
"screenshot",
|
||||
"/Users/klaus/Develop/sodalive/iOS/SodaLive/docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/native-os-final.png"
|
||||
],
|
||||
"exit": 0,
|
||||
"stdout": "",
|
||||
"stderr": "Detected file type 'PNG' from extension\nWrote screenshot to: /Users/klaus/Develop/sodalive/iOS/SodaLive/docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/native-os-final.png\n"
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"/Users/klaus/Library/Developer/Xcode/DerivedData/SodaLive-clzdtvqrsfonoxcijatzkrqdzrsu/Build/Products/Debug-iphonesimulator/Kingfisher.o": "ab9481b5935132edf5744692d88ff4c3e34f1a68f4014f88267a5ad5d95f31b8",
|
||||
"/Users/klaus/Library/Developer/Xcode/DerivedData/SodaLive-clzdtvqrsfonoxcijatzkrqdzrsu/Build/Products/Debug-iphonesimulator/Kingfisher.swiftmodule/x86_64-apple-ios-simulator.swiftmodule": "b155d956bfaa735f7c72f4630b5be0e2dc82fcb768c2c0ce930ab17faff22fdc"
|
||||
}
|
||||
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"root": "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native.tstn99",
|
||||
"bundle": "qa.sodalive.subbanner.native.tstn99",
|
||||
"pid": 93117,
|
||||
"device": "482F7827-2E3A-4559-BAF4-48B68CC5725B",
|
||||
"deviceOwner": "parent"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"SodaLive/Sources/V2/Component/Banner/SubBanner.swift": "875bcdb953e47e9c3e157a7d5adc8b39c271691af2b02437fc738ac727d5b7e9",
|
||||
"SodaLive/Sources/V2/Component/Banner/SubBannerLinkOpener.swift": "0aa860b206d0dff737cb5847741a2b531dbe633728b5fa5f2eeea5410834dab3",
|
||||
"SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift": "1bf03b8b1e05310acce09f12b2f09b35af3587f4a7d486b22945ec502cfb506d",
|
||||
"SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift": "c49150d7b504b711c249458a3e2fb96c1f6a75d2b83d97ff29f674d9b928b48f",
|
||||
"SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift": "2d0240fb8a150e7e365cc0daff67fa027c31ce709e12f518c1b52f0cec56a0b8",
|
||||
"SodaLive/Sources/UI/Theme/Spacing.swift": "372a73b821236c15449fa022cd628f61556f1381a16f234a4b77e833c2e588c1",
|
||||
"SodaLive/Sources/UI/Theme/Color.swift": "0f50ec7d3e77e0813fd1a806440c01baf96f8fdfa2a41faccd6dc4df52cf647a",
|
||||
"SodaLive/Sources/Extensions/ColorExtension.swift": "747347d86ade888fe9c1d30629285b4a71991bc0f21189a0d4b76ad314c32364",
|
||||
"SodaLive/Sources/Extensions/FontModifier.swift": "086fb0a19d884124a4cca638f7c8483de724ee31a61603b2cbd489aabf26a6c6"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"SodaLive/Sources/V2/Component/Banner/SubBanner.swift": "875bcdb953e47e9c3e157a7d5adc8b39c271691af2b02437fc738ac727d5b7e9",
|
||||
"SodaLive/Sources/V2/Component/Banner/SubBannerLinkOpener.swift": "0aa860b206d0dff737cb5847741a2b531dbe633728b5fa5f2eeea5410834dab3",
|
||||
"SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift": "1bf03b8b1e05310acce09f12b2f09b35af3587f4a7d486b22945ec502cfb506d",
|
||||
"SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift": "c49150d7b504b711c249458a3e2fb96c1f6a75d2b83d97ff29f674d9b928b48f",
|
||||
"SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift": "2d0240fb8a150e7e365cc0daff67fa027c31ce709e12f518c1b52f0cec56a0b8",
|
||||
"SodaLive/Sources/UI/Theme/Spacing.swift": "372a73b821236c15449fa022cd628f61556f1381a16f234a4b77e833c2e588c1",
|
||||
"SodaLive/Sources/UI/Theme/Color.swift": "0f50ec7d3e77e0813fd1a806440c01baf96f8fdfa2a41faccd6dc4df52cf647a",
|
||||
"SodaLive/Sources/Extensions/ColorExtension.swift": "747347d86ade888fe9c1d30629285b4a71991bc0f21189a0d4b76ad314c32364",
|
||||
"SodaLive/Sources/Extensions/FontModifier.swift": "086fb0a19d884124a4cca638f7c8483de724ee31a61603b2cbd489aabf26a6c6"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"SodaLive/Sources/V2/Component/Banner/SubBanner.swift": "875bcdb953e47e9c3e157a7d5adc8b39c271691af2b02437fc738ac727d5b7e9",
|
||||
"SodaLive/Sources/V2/Component/Banner/SubBannerLinkOpener.swift": "0aa860b206d0dff737cb5847741a2b531dbe633728b5fa5f2eeea5410834dab3",
|
||||
"SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift": "1bf03b8b1e05310acce09f12b2f09b35af3587f4a7d486b22945ec502cfb506d",
|
||||
"SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift": "c49150d7b504b711c249458a3e2fb96c1f6a75d2b83d97ff29f674d9b928b48f",
|
||||
"SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift": "2d0240fb8a150e7e365cc0daff67fa027c31ce709e12f518c1b52f0cec56a0b8",
|
||||
"SodaLive/Sources/UI/Theme/Spacing.swift": "372a73b821236c15449fa022cd628f61556f1381a16f234a4b77e833c2e588c1",
|
||||
"SodaLive/Sources/UI/Theme/Color.swift": "0f50ec7d3e77e0813fd1a806440c01baf96f8fdfa2a41faccd6dc4df52cf647a",
|
||||
"SodaLive/Sources/Extensions/ColorExtension.swift": "747347d86ade888fe9c1d30629285b4a71991bc0f21189a0d4b76ad314c32364",
|
||||
"SodaLive/Sources/Extensions/FontModifier.swift": "086fb0a19d884124a4cca638f7c8483de724ee31a61603b2cbd489aabf26a6c6"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"unchanged": true,
|
||||
"changed": []
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
enum LanguageOption: String { case system, ko, en, ja }
|
||||
struct QAItem { let creatorId = 1; let postId = 1; let price = 1 }
|
||||
struct RecommendationBannerResponse {}
|
||||
struct AudioBannerResponse {}
|
||||
struct FixtureResponse {
|
||||
var subBanners: [SubBanner]
|
||||
var eligibleSubBanners: [SubBanner] { subBanners.filter(\.isEligible) }
|
||||
let lives: [QAItem] = [.init()]
|
||||
let banners: [QAItem] = [.init()]
|
||||
let recentlyActiveCreators: [QAItem] = [.init()]
|
||||
let recentDebutCreators: [QAItem] = [.init()]
|
||||
let aiCharacters: [QAItem] = []
|
||||
let cheerCreators: [QAItem] = []
|
||||
let popularCommunityPosts: [QAItem] = []
|
||||
let originalSeries: [QAItem] = [.init()]
|
||||
let newAndHotAudios: [QAItem] = []
|
||||
let latestAudios: [QAItem] = []
|
||||
let freeAudios: [QAItem] = []
|
||||
let pointAudios: [QAItem] = []
|
||||
let mostCommentedAudios: [QAItem] = []
|
||||
let recommendedAudios: [QAItem] = []
|
||||
}
|
||||
final class MainHomeRecommendationViewModel: ObservableObject {
|
||||
static weak var current: MainHomeRecommendationViewModel?
|
||||
@Published var recommendations: FixtureResponse? = .init(subBanners: [])
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage = ""
|
||||
@Published var isShowPopup = false
|
||||
@Published var isShowCommunityPostPurchaseDialog = false
|
||||
var purchasingCommunityPostPrice = 0
|
||||
init() { Self.current = self; recommendations = .init(subBanners: HarnessState.shared.items) }
|
||||
func refresh() async {}
|
||||
func fetchRecommendations() {}
|
||||
func isFollowAllCompleted(_ key: String) -> Bool { false }
|
||||
func isFollowAllLoading(_ key: String) -> Bool { false }
|
||||
func followAll(creatorIds: [Int], completionKey: String) {}
|
||||
func likeCommunityPost(postId: Int) {}
|
||||
func openCommunityPostPurchase(postId: Int, price: Int) {}
|
||||
func purchaseCommunityPost() {}
|
||||
}
|
||||
final class MainContentRecommendationViewModel: ObservableObject {
|
||||
static weak var current: MainContentRecommendationViewModel?
|
||||
@Published var recommendations: FixtureResponse? = .init(subBanners: [])
|
||||
@Published var isLoading = false
|
||||
@Published var isShowPopup = false
|
||||
var errorMessage = ""
|
||||
let hasLoaded = true
|
||||
let shouldShowEmptyState = false
|
||||
let emptyStateMessage = "QA empty"
|
||||
init() { Self.current = self; recommendations = .init(subBanners: HarnessState.shared.items) }
|
||||
func refresh() async {}
|
||||
func fetchRecommendations() {}
|
||||
}
|
||||
final class CreatorCommunityMediaPlayerManager: ObservableObject {
|
||||
static let shared = CreatorCommunityMediaPlayerManager()
|
||||
@Published var isShowPopup = false
|
||||
let errorMessage = ""
|
||||
let isLoading = false
|
||||
}
|
||||
enum I18n {
|
||||
enum MainContentRecommendation {
|
||||
static let sodaLiveOnlySectionTitle = "QA exclusive series"
|
||||
static let newAndHotSectionTitle = "QA new and hot"
|
||||
static let latestAudioSectionTitle = "QA latest"
|
||||
static let freeAudioSectionTitle = "QA free"
|
||||
static let pointAudioSectionTitle = "QA point"
|
||||
static let mostCommentedAudioSectionTitle = "QA comments"
|
||||
static let recommendedAudioSectionTitle = "QA recommended"
|
||||
}
|
||||
}
|
||||
struct Rectangles: PreferenceKey {
|
||||
static var defaultValue: [String: CGRect] = [:]
|
||||
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) { value.merge(nextValue(), uniquingKeysWith: { _, new in new }) }
|
||||
}
|
||||
extension View {
|
||||
func probe(_ name: String) -> some View {
|
||||
background(GeometryReader { proxy in Color.clear.preference(key: Rectangles.self, value: [name: proxy.frame(in: .global)]) })
|
||||
}
|
||||
func sodaToast(isPresented: Binding<Bool>, message: String, autohideIn: Double) -> some View { self }
|
||||
}
|
||||
struct QABar: View {
|
||||
let name: String
|
||||
var body: some View {
|
||||
Text(name).appFont(.body3).foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity).frame(height: SodaSpacing.s48)
|
||||
.background(Color.gray900).probe(name)
|
||||
}
|
||||
}
|
||||
struct LottieRefreshableScrollView<Content: View>: View {
|
||||
let action: () async -> Void
|
||||
@ViewBuilder let content: () -> Content
|
||||
var body: some View { ScrollView { content() } }
|
||||
}
|
||||
struct MainHomeLiveSection: View {
|
||||
let items: [QAItem]; let onTapLive: (Int) -> Void; let onTapAll: () -> Void
|
||||
var body: some View { QABar(name: "QA home live") }
|
||||
}
|
||||
struct MainHomeBannerSection: View {
|
||||
let items: [QAItem]; let onTapBanner: (RecommendationBannerResponse) -> Void
|
||||
var body: some View { QABar(name: "QA home main banner") }
|
||||
}
|
||||
struct MainHomeActiveCreatorSection: View {
|
||||
let items: [QAItem]; let onTapLive: (Int) -> Void; let onTapCreator: (Int) -> Void
|
||||
let onTapContent: (Int) -> Void; let onTapCommunityPost: (Int) -> Void
|
||||
var body: some View { QABar(name: "QA active creators") }
|
||||
}
|
||||
struct MainHomeRecentDebutCreatorSection: View {
|
||||
let items: [QAItem]; let onTapCreator: (Int) -> Void
|
||||
var body: some View { QABar(name: "QA debut creators") }
|
||||
}
|
||||
struct MainHomeAiCharacterSection: View {
|
||||
let items: [QAItem]; let onTapCharacter: (Int) -> Void
|
||||
var body: some View { EmptyView() }
|
||||
}
|
||||
struct MainHomeCheerCreatorSection: View {
|
||||
let items: [QAItem]; let isFollowAllCompleted: Bool; let isFollowAllLoading: Bool
|
||||
let onTapCreator: (Int) -> Void; let onTapFollowAll: () -> Void
|
||||
var body: some View { EmptyView() }
|
||||
}
|
||||
struct MainHomePopularCommunitySection: View {
|
||||
let items: [QAItem]; let onTapCommunityPost: (Int) -> Void; let onTapLike: (Int) -> Void; let onTapPurchase: (QAItem) -> Void
|
||||
var body: some View { EmptyView() }
|
||||
}
|
||||
struct MainHomeBusinessInfoSection: View { var body: some View { QABar(name: "QA other home content") } }
|
||||
struct CommunityPostPurchaseDialog: View {
|
||||
@Binding var isShowing: Bool; let can: Int; let confirmAction: () -> Void
|
||||
var body: some View { EmptyView() }
|
||||
}
|
||||
struct LoadingView: View { var body: some View { ProgressView() } }
|
||||
struct MainContentAudioBannerSection: View {
|
||||
let items: [QAItem]; let onTapBanner: (AudioBannerResponse) -> Void
|
||||
var body: some View { QABar(name: "QA content main banner") }
|
||||
}
|
||||
struct MainContentVoiceOnOnlySection: View {
|
||||
let title: String; let items: [QAItem]; let action: () -> Void; let onTapSeries: (Int) -> Void
|
||||
var body: some View { QABar(name: "QA exclusive series") }
|
||||
}
|
||||
struct MainContentAudioListCarouselSection: View {
|
||||
let title: String; let items: [QAItem]; let action: () -> Void; let onTapContent: (Int) -> Void
|
||||
var body: some View { QABar(name: "QA other content") }
|
||||
}
|
||||
struct MainContentAudioHorizontalCardSection: View {
|
||||
let title: String; let items: [QAItem]; let action: () -> Void; let onTapContent: (Int) -> Void
|
||||
var body: some View { EmptyView() }
|
||||
}
|
||||
struct MainContentMostCommentedAudioSection: View {
|
||||
let title: String; let items: [QAItem]; let onTapContent: (Int) -> Void
|
||||
var body: some View { EmptyView() }
|
||||
}
|
||||
struct MainContentRecommendedAudioGridSection: View {
|
||||
let title: String; let items: [QAItem]; let onTapContent: (Int) -> Void
|
||||
var body: some View { EmptyView() }
|
||||
}
|
||||
struct MainContentAudioEmptyStateView: View {
|
||||
let message: String
|
||||
var body: some View { Text(message) }
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import Kingfisher
|
||||
|
||||
final class HarnessState: ObservableObject {
|
||||
static let shared = HarnessState()
|
||||
@Published var items: [SubBanner] = []
|
||||
@Published var context = "home"
|
||||
@Published var mounted = true
|
||||
@Published var width: CGFloat = 375
|
||||
@Published var phase = ScenePhase.active
|
||||
@Published var reduceMotion = false
|
||||
var frames: [String: CGRect] = [:]
|
||||
var scenario = "preparing"
|
||||
func update(_ items: [SubBanner]) {
|
||||
self.items = items
|
||||
MainHomeRecommendationViewModel.current?.recommendations = .init(subBanners: items)
|
||||
MainContentRecommendationViewModel.current?.recommendations = .init(subBanners: items)
|
||||
}
|
||||
}
|
||||
|
||||
final class FixtureProtocol: URLProtocol {
|
||||
static var requests: [[String: Any]] = []
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
override func startLoading() {
|
||||
let url = request.url!
|
||||
DispatchQueue.main.async {
|
||||
Self.requests.append(["url": url.absoluteString, "uptime": ProcessInfo.processInfo.systemUptime])
|
||||
}
|
||||
guard url.host == "subbanner.qa.invalid" else {
|
||||
client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet)); return
|
||||
}
|
||||
if url.path.contains("fail") {
|
||||
client?.urlProtocol(self, didFailWithError: URLError(.cannotDecodeContentData)); return
|
||||
}
|
||||
let tall = url.path.contains("tall")
|
||||
let size = CGSize(width: 1200, height: tall ? 600 : 400)
|
||||
let format = UIGraphicsImageRendererFormat(); format.scale = 1
|
||||
let image = UIGraphicsImageRenderer(size: size, format: format).image { context in
|
||||
(tall ? UIColor.systemOrange : UIColor.systemTeal).setFill()
|
||||
context.fill(CGRect(origin: .zero, size: size))
|
||||
let title = tall ? "B 1200 x 600" : "A 1200 x 400"
|
||||
title.draw(at: CGPoint(x: 64, y: 96), withAttributes: [.font: UIFont.boldSystemFont(ofSize: 70), .foregroundColor: UIColor.black])
|
||||
url.lastPathComponent.draw(at: CGPoint(x: 64, y: 210), withAttributes: [.font: UIFont.systemFont(ofSize: 42), .foregroundColor: UIColor.black])
|
||||
}
|
||||
let deliver = {
|
||||
self.client?.urlProtocol(self, didReceive: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "image/png"])!, cacheStoragePolicy: .notAllowed)
|
||||
self.client?.urlProtocol(self, didLoad: image.pngData()!)
|
||||
self.client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
if url.path.contains("delay") { DispatchQueue.main.asyncAfter(deadline: .now() + 8, execute: deliver) }
|
||||
else { deliver() }
|
||||
}
|
||||
override func stopLoading() {}
|
||||
}
|
||||
|
||||
struct PhaseProbe: View {
|
||||
@Environment(\.scenePhase) private var phase
|
||||
@Environment(\.accessibilityReduceMotion) private var reduce
|
||||
var body: some View {
|
||||
Color.clear.onAppear { HarnessDelegate.current?.record("environment", ["phase": String(describing: phase), "reduceMotion": reduce]) }
|
||||
.onChange(of: phase) { HarnessDelegate.current?.record("phase", ["value": String(describing: $0)]) }
|
||||
.onChange(of: reduce) { HarnessDelegate.current?.record("reduceMotion", ["value": $0]) }
|
||||
}
|
||||
}
|
||||
struct HarnessRoot: View {
|
||||
@ObservedObject var state = HarnessState.shared
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Text("NATIVE QA / " + state.context).appFont(.caption2).foregroundColor(.white).frame(height: SodaSpacing.s24)
|
||||
if state.mounted {
|
||||
Group {
|
||||
if state.context == "home" {
|
||||
MainHomeRecommendationView(onTapLive: { _ in }, onTapCreator: { _ in }, onTapContent: { _ in }, onTapCharacter: { _ in }, onTapCommunityPost: { _ in }, onTapBanner: { _ in }, onTapLiveAll: {}, onTapFollowAll: { $0() })
|
||||
} else if state.context == "content" {
|
||||
MainContentRecommendationView(onTapContent: { _ in }, onTapBanner: { _ in }, onTapSeries: { _ in }, onTapLatestAudioAll: {}, onTapNewAndHotAll: {}, onTapSodaLiveOnlyAll: {}, onTapFreeAudioAll: {}, onTapPointAudioAll: {})
|
||||
} else {
|
||||
VStack(spacing: SodaSpacing.s24) {
|
||||
QABar(name: "QA direct before")
|
||||
if !state.items.isEmpty {
|
||||
SubBannerCarousel(items: state.items) { item in HarnessDelegate.current?.record("carousel-onTap", ["id": item.id]) }.probe("carousel")
|
||||
}
|
||||
QABar(name: "QA direct after")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: state.width)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(PhaseProbe())
|
||||
.environment(\.scenePhase, state.phase)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.background(Color.black)
|
||||
.onPreferenceChange(Rectangles.self) { state.frames = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
final class HarnessDelegate: NSObject, UIApplicationDelegate {
|
||||
static weak var current: HarnessDelegate?
|
||||
var window: UIWindow?
|
||||
let state = HarnessState.shared
|
||||
let started = ProcessInfo.processInfo.systemUptime
|
||||
var events: [[String: Any]] = []
|
||||
let output = URL.documentsDirectory.appendingPathComponent("native", isDirectory: true)
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
Self.current = self
|
||||
UserDefaults.standard.set("en", forKey: "app.language")
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.protocolClasses = [FixtureProtocol.self]
|
||||
ImageDownloader.default.sessionConfiguration = config
|
||||
try! FileManager.default.createDirectory(at: output, withIntermediateDirectories: true)
|
||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||
window.rootViewController = UIHostingController(rootView: HarnessRoot())
|
||||
window.overrideUserInterfaceStyle = .dark
|
||||
self.window = window
|
||||
window.makeKeyAndVisible()
|
||||
after(1) { Task { @MainActor in await self.run() } }
|
||||
return true
|
||||
}
|
||||
func after(_ seconds: Double, _ action: @escaping () -> Void) { DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: action) }
|
||||
@MainActor func wait(_ seconds: Double) async { try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) }
|
||||
func record(_ event: String, _ details: [String: Any] = [:]) {
|
||||
var entry = details
|
||||
entry["event"] = event; entry["time"] = ProcessInfo.processInfo.systemUptime - started; entry["scenario"] = state.scenario
|
||||
events.append(entry)
|
||||
try! JSONSerialization.data(withJSONObject: events, options: [.prettyPrinted, .sortedKeys]).write(to: output.appendingPathComponent("actions.json"))
|
||||
}
|
||||
func sample(_ name: String) {
|
||||
guard let window else { return }
|
||||
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) }
|
||||
try! image.pngData()!.write(to: output.appendingPathComponent(name + ".png"))
|
||||
let frames = state.frames.mapValues { ["x": $0.minX, "y": $0.minY, "width": $0.width, "height": $0.height] }
|
||||
record(name, ["frames": frames, "inputIDs": state.items.map(\.id), "width": state.width, "phase": String(describing: state.phase), "osReduceMotion": UIAccessibility.isReduceMotionEnabled, "requests": FixtureProtocol.requests, "png": name + ".png"])
|
||||
}
|
||||
func banner(_ id: Int, _ path: String, _ context: String = "home") -> SubBanner {
|
||||
SubBanner(id: id, placement: context == "content" ? .contentRecommendation : .homeRecommendation, imageUrl: "https://subbanner.qa.invalid/" + path, webUrl: "https://subbanner.qa.invalid/destination", androidDeepLink: nil, iosDeepLink: "sodaqa://banner/\(id)")
|
||||
}
|
||||
@MainActor func setup(_ name: String, context: String, items: [SubBanner]) async {
|
||||
state.mounted = false
|
||||
await wait(0.15)
|
||||
state.scenario = name; state.context = context; state.width = 375; state.phase = .active; state.reduceMotion = false
|
||||
state.update(items); state.mounted = true
|
||||
record("scenario-start", ["context": context, "count": items.count])
|
||||
}
|
||||
@MainActor func run() async {
|
||||
for context in ["home", "content"] {
|
||||
let pair = [banner(1, context + "-wide.png", context), banner(2, context + "-tall.png", context)]
|
||||
await setup(context + "-pair", context: context, items: pair)
|
||||
await wait(0.8); sample(context + "-first")
|
||||
await wait(3.6); sample(context + "-before-5s")
|
||||
await wait(0.75); sample(context + "-transition")
|
||||
await wait(0.65); sample(context + "-second")
|
||||
await wait(4.8); sample(context + "-wrap")
|
||||
window?.overrideUserInterfaceStyle = .light
|
||||
await wait(0.3); sample(context + "-light")
|
||||
window?.overrideUserInterfaceStyle = .dark
|
||||
state.update([]); await wait(0.4); sample(context + "-empty")
|
||||
state.update([banner(3, context + "-single-wide.png", context)])
|
||||
await wait(0.6); sample(context + "-one")
|
||||
await wait(6); sample(context + "-one-after-6s")
|
||||
state.width = 320; await wait(0.5); sample(context + "-resize-320")
|
||||
state.width = 375; await wait(0.3)
|
||||
}
|
||||
await setup("delayed-current", context: "direct", items: [banner(10, "delay-wide.png"), banner(11, "valid-tall.png")])
|
||||
await wait(0.6); sample("delayed-no-invented-height")
|
||||
await wait(5.3); sample("delayed-next-valid")
|
||||
await wait(2.7); sample("delayed-old-image-arrived")
|
||||
await setup("failure-isolation", context: "content", items: [banner(20, "valid-wide.png"), banner(21, "fail.png")])
|
||||
await wait(0.8); sample("failure-valid-first")
|
||||
await wait(5); sample("failure-placeholder-other-content")
|
||||
await wait(5); sample("failure-wrap-valid")
|
||||
await setup("inactive-remount", context: "direct", items: [banner(30, "lifecycle-wide.png"), banner(31, "lifecycle-tall.png")])
|
||||
await wait(5.8); sample("inactive-before")
|
||||
state.phase = .inactive; record("inject-inactive")
|
||||
await wait(0.3); sample("inactive-start")
|
||||
await wait(6); sample("inactive-after-6s")
|
||||
state.phase = .active; record("inject-active")
|
||||
await wait(4.4); sample("active-before-5s")
|
||||
await wait(1.3); sample("active-next")
|
||||
state.mounted = false; record("unmount")
|
||||
await wait(6); sample("unmounted-after-6s")
|
||||
state.mounted = true; record("remount")
|
||||
await wait(0.6); sample("remount-first")
|
||||
await wait(5.1); sample("remount-next")
|
||||
state.update([banner(32, "shrink-wide.png")]); await wait(0.6); sample("shrink-one")
|
||||
await setup("reduced-motion", context: "direct", items: [banner(40, "reduce-wide.png"), banner(41, "reduce-tall.png")])
|
||||
record("reduce-motion-observation-only", ["osValue": UIAccessibility.isReduceMotionEnabled, "enabledTest": "NOT RUN: read-only SwiftUI environment; parent device preferences unchanged"])
|
||||
await wait(0.7); sample("reduce-first")
|
||||
await wait(4.45); sample("reduce-transition")
|
||||
await wait(0.7); sample("reduce-second")
|
||||
await setup("twenty-one", context: "direct", items: (1...21).map { banner(100 + $0, "many-\($0)-wide.png") })
|
||||
await wait(0.8); sample("many-first")
|
||||
await wait(99.8); sample("many-twenty-first")
|
||||
await wait(5); sample("many-wrap")
|
||||
let target = banner(900, "action-wide.png")
|
||||
var attempts: [String] = []
|
||||
SubBannerLinkOpener.open(target) { url, completion in attempts.append(url.absoluteString); completion(false) }
|
||||
record("opener-driver-not-physical-tap", ["attempts": attempts])
|
||||
record("finished")
|
||||
}
|
||||
}
|
||||
@main struct Entry {
|
||||
static func main() { UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, nil, NSStringFromClass(HarnessDelegate.self)) }
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
evidence = Path('docs/20261029_추천_서브_배너/reviews/evidence/native')
|
||||
capture = evidence / 'captures'
|
||||
events = json.loads((capture / 'actions.json').read_text())
|
||||
samples = {event['event']: event for event in events if 'png' in event}
|
||||
before = json.loads((evidence / 'source-hashes-before.json').read_text())
|
||||
current = {path: hashlib.sha256(Path(path).read_bytes()).hexdigest() for path in before}
|
||||
assert current == before, 'Product source changed; rerun native capture'
|
||||
assert events[-1]['event'] == 'finished'
|
||||
image_info = {}
|
||||
for path in sorted(capture.glob('*.png')):
|
||||
assert path.read_bytes().startswith(b'\x89PNG\r\n\x1a\n')
|
||||
image = Image.open(path).convert('RGB')
|
||||
assert image.size == (750, 1334)
|
||||
assert path.stat().st_mtime > max(Path(source).stat().st_mtime for source in before)
|
||||
image_info[path.name] = {'sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'width': image.width, 'height': image.height, 'fresh': True}
|
||||
os_capture = evidence / 'native-os-final.png'
|
||||
assert os_capture.read_bytes().startswith(b'\x89PNG\r\n\x1a\n')
|
||||
assert Image.open(os_capture).size == (750, 1334)
|
||||
assert os_capture.stat().st_mtime > max(Path(source).stat().st_mtime for source in before)
|
||||
|
||||
def frame(name, key):
|
||||
return samples[name]['frames'][key]
|
||||
|
||||
def pixels(name):
|
||||
image = Image.open(capture / (name + '.png')).convert('RGB')
|
||||
rows = []
|
||||
for position in range(image.height):
|
||||
left, right = image.getpixel((0, position)), image.getpixel((image.width - 1, position))
|
||||
def colored(value):
|
||||
red, green, blue = value
|
||||
return (blue > 140 and green > 110 and red < 100) or (red > 200 and 80 < green < 200 and blue < 60)
|
||||
if colored(left) and colored(right):
|
||||
rows.append(position)
|
||||
if not rows:
|
||||
return None
|
||||
return {'x': 0, 'width': image.width / 2, 'y': min(rows) / 2, 'height': len(rows) / 2}
|
||||
|
||||
geometry = {}
|
||||
for context, preceding, following in [('home', 'QA active creators', 'QA debut creators'), ('content', 'QA content main banner', 'QA exclusive series')]:
|
||||
for suffix, expected in [('first', 125), ('second', 187.5)]:
|
||||
name = context + '-' + suffix
|
||||
previous = frame(name, preceding)
|
||||
next_frame = frame(name, following)
|
||||
image = pixels(name)
|
||||
derived_height = next_frame['y'] - previous['y'] - previous['height'] - 48
|
||||
assert derived_height == expected
|
||||
assert image['height'] == expected and image['width'] == 375 and image['x'] == 0
|
||||
assert image['y'] - previous['y'] - previous['height'] == 24
|
||||
assert next_frame['y'] - image['y'] - image['height'] == 24
|
||||
geometry[name] = {'pixelFramePt': image, 'precedingGapPt': 24, 'followingGapPt': 24, 'derivedHeightPt': derived_height}
|
||||
empty = context + '-empty'
|
||||
previous, next_frame = frame(empty, preceding), frame(empty, following)
|
||||
assert next_frame['y'] - previous['y'] - previous['height'] == 24
|
||||
assert pixels(empty) is None
|
||||
geometry[empty] = {'remainingOrdinarySectionGapPt': 24, 'extraBannerGapPt': 0}
|
||||
resized = pixels(context + '-resize-320')
|
||||
image = Image.open(capture / (context + '-resize-320.png')).convert('RGB')
|
||||
line = [image.getpixel((position, 600 if context == 'home' else 320)) for position in range(image.width)]
|
||||
colored_positions = [position for position, (red, green, blue) in enumerate(line) if blue > 140 and green > 110 and red < 100]
|
||||
assert min(colored_positions) == 0 and max(colored_positions) == 639
|
||||
previous = frame(context + '-resize-320', preceding)
|
||||
next_frame = frame(context + '-resize-320', following)
|
||||
height = next_frame['y'] - previous['y'] - previous['height'] - 48
|
||||
assert abs(height - 320 / 3) < 0.51
|
||||
geometry[context + '-resize-320'] = {'pixelWidthPt': 320, 'geometryHeightPt': height, 'roundingTolerancePt': 0.51}
|
||||
assert image_info[context + '-one.png']['sha256'] == image_info[context + '-one-after-6s.png']['sha256']
|
||||
|
||||
assert frame('delayed-no-invented-height', 'carousel')['height'] == 0
|
||||
assert frame('delayed-next-valid', 'carousel')['height'] == 187.5
|
||||
assert frame('delayed-old-image-arrived', 'carousel')['height'] == 187.5
|
||||
assert image_info['inactive-start.png']['sha256'] == image_info['inactive-after-6s.png']['sha256']
|
||||
assert len(samples['many-wrap']['inputIDs']) == 21
|
||||
attempts = next(event['attempts'] for event in events if event['event'] == 'opener-driver-not-physical-tap')
|
||||
assert attempts == ['sodaqa://banner/900', 'https://subbanner.qa.invalid/destination']
|
||||
|
||||
manifest = list(image_info)
|
||||
for sheet_index in range(0, len(manifest), 12):
|
||||
sheet = Image.new('RGB', (1000, 1422), 'white')
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
for index, name in enumerate(manifest[sheet_index:sheet_index + 12]):
|
||||
image = Image.open(capture / name).convert('RGB')
|
||||
image.thumbnail((250, 445))
|
||||
column, row = index % 4, index // 4
|
||||
draw.text((column * 250 + 4, row * 474 + 4), name, fill='black')
|
||||
sheet.paste(image, (column * 250, row * 474 + 25))
|
||||
sheet.save(evidence / ('contact-' + str(sheet_index // 12 + 1) + '.png'))
|
||||
|
||||
result = {'nativeCaptureCount': len(image_info), 'additionalOSCapture': {'path': 'native-os-final.png', 'sha256': hashlib.sha256(os_capture.read_bytes()).hexdigest(), 'width': 750, 'height': 1334, 'fresh': True}, 'productHashesUnchanged': current == before, 'geometry': geometry, 'pngs': image_info, 'assertions': 'PASS', 'manualFilenameCorrection': {'many-twenty-first.png': 'Observed 20/21', 'many-wrap.png': 'Observed 21/21', 'native-os-final.png': 'Observed 01/21 after wrap'}, 'reduceMotionEnabledTest': 'NOT RUN; OS false; read-only environment; parent preferences unchanged', 'physicalTapAndSwipe': 'NOT RUN; no native coordinate input tool'}
|
||||
(evidence / 'verification.json').write_text(json.dumps(result, indent=2))
|
||||
(evidence / 'source-hashes-final.json').write_text(json.dumps(current, indent=2))
|
||||
print(json.dumps({'nativeCaptures': len(image_info), 'geometry': geometry, 'assertions': 'PASS'}, indent=2))
|
||||
@@ -0,0 +1,42 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
evidence = Path('docs/20261029_추천_서브_배너/reviews/evidence/native')
|
||||
ownership = json.loads((evidence / 'ownership.json').read_text())
|
||||
root = Path(ownership['root'])
|
||||
for key in ['TMPDIR', 'NODE_COMPILE_CACHE', 'BUN_RUNTIME_TRANSPILER_CACHE_PATH']:
|
||||
os.environ[key] = str(root)
|
||||
commands = []
|
||||
def run(args):
|
||||
result = subprocess.run(args, text=True, capture_output=True)
|
||||
receipt = {'args': args, 'exit': result.returncode, 'stdout': result.stdout, 'stderr': result.stderr}
|
||||
commands.append(receipt)
|
||||
return receipt
|
||||
|
||||
device, bundle = ownership['device'], ownership['bundle']
|
||||
assert root.name == 'sub-banner-native.tstn99'
|
||||
assert bundle == 'qa.sodalive.subbanner.native.tstn99'
|
||||
assert run(['xcrun', 'simctl', 'terminate', device, bundle])['exit'] == 0
|
||||
assert run(['xcrun', 'simctl', 'uninstall', device, bundle])['exit'] == 0
|
||||
missing = run(['xcrun', 'simctl', 'get_app_container', device, bundle, 'data'])
|
||||
assert missing['exit'] != 0
|
||||
process = run(['ps', '-p', str(ownership['pid']), '-o', 'pid=,comm='])
|
||||
assert process['exit'] != 0
|
||||
shutil.rmtree(root)
|
||||
devices = json.loads(run(['xcrun', 'simctl', 'list', 'devices', 'booted', '-j'])['stdout'])
|
||||
parent = next(item for items in devices['devices'].values() for item in items if item['udid'] == device)
|
||||
assert parent['state'] == 'Booted'
|
||||
before = json.loads((evidence / 'source-hashes-before.json').read_text())
|
||||
current = {path: hashlib.sha256(Path(path).read_bytes()).hexdigest() for path in before}
|
||||
assert current == before
|
||||
products = Path('/Users/klaus/Library/Developer/Xcode/DerivedData/SodaLive-clzdtvqrsfonoxcijatzkrqdzrsu/Build/Products/Debug-iphonesimulator')
|
||||
dependencies = ['Kingfisher.o', 'Kingfisher.swiftmodule/x86_64-apple-ios-simulator.swiftmodule']
|
||||
dependency_hashes = {str(products / path): hashlib.sha256((products / path).read_bytes()).hexdigest() for path in dependencies}
|
||||
(evidence / 'dependency-hashes.json').write_text(json.dumps(dependency_hashes, indent=2))
|
||||
receipt = {'commands': commands, 'appAbsent': True, 'pidAbsent': True, 'rootAbsent': not root.exists(), 'parentDeviceStillBooted': True, 'productHashesUnchanged': current == before, 'sharedCacheDeleted': False}
|
||||
(evidence / 'cleanup.json').write_text(json.dumps(receipt, indent=2))
|
||||
print(json.dumps({key: value for key, value in receipt.items() if key != 'commands'}, indent=2))
|
||||
@@ -0,0 +1,68 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import plistlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
repo = Path.cwd()
|
||||
evidence = repo / 'docs/20261029_추천_서브_배너/reviews/evidence/native'
|
||||
root = Path('/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native.tstn99')
|
||||
for key in ['TMPDIR', 'NODE_COMPILE_CACHE', 'BUN_RUNTIME_TRANSPILER_CACHE_PATH']:
|
||||
os.environ[key] = str(root)
|
||||
device = '482F7827-2E3A-4559-BAF4-48B68CC5725B'
|
||||
bundle = 'qa.sodalive.subbanner.native.tstn99'
|
||||
products = Path('/Users/klaus/Library/Developer/Xcode/DerivedData/SodaLive-clzdtvqrsfonoxcijatzkrqdzrsu/Build/Products/Debug-iphonesimulator')
|
||||
sources = [
|
||||
'SodaLive/Sources/V2/Component/Banner/SubBanner.swift',
|
||||
'SodaLive/Sources/V2/Component/Banner/SubBannerLinkOpener.swift',
|
||||
'SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift',
|
||||
'SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift',
|
||||
'SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift',
|
||||
'SodaLive/Sources/UI/Theme/Spacing.swift',
|
||||
'SodaLive/Sources/UI/Theme/Color.swift',
|
||||
'SodaLive/Sources/Extensions/ColorExtension.swift',
|
||||
'SodaLive/Sources/Extensions/FontModifier.swift',
|
||||
]
|
||||
previous = evidence / 'commands.json'
|
||||
commands = json.loads(previous.read_text()) if previous.exists() else []
|
||||
def run(args, timeout=120):
|
||||
result = subprocess.run([str(arg) for arg in args], text=True, capture_output=True, timeout=timeout)
|
||||
commands.append({'args': [str(arg) for arg in args], 'exit': result.returncode, 'stdout': result.stdout, 'stderr': result.stderr})
|
||||
(evidence / 'commands.json').write_text(json.dumps(commands, indent=2))
|
||||
print(result.stdout, result.stderr, flush=True)
|
||||
result.check_returncode()
|
||||
return result.stdout.strip()
|
||||
|
||||
hashes = {path: hashlib.sha256((repo / path).read_bytes()).hexdigest() for path in sources}
|
||||
(evidence / 'source-hashes-before.json').write_text(json.dumps(hashes, indent=2))
|
||||
app = root / 'SubBannerQA.app'
|
||||
app.mkdir(exist_ok=True)
|
||||
with (app / 'Info.plist').open('wb') as stream:
|
||||
plistlib.dump({'CFBundleIdentifier': bundle, 'CFBundleName': 'SubBannerQA', 'CFBundleExecutable': 'SubBannerQA', 'CFBundlePackageType': 'APPL', 'CFBundleVersion': '1', 'CFBundleShortVersionString': '1', 'MinimumOSVersion': '16.6', 'LSRequiresIPhoneOS': True, 'UIDeviceFamily': [1], 'UILaunchScreen': {}}, stream)
|
||||
sdk = run(['xcrun', '--sdk', 'iphonesimulator', '--show-sdk-path'])
|
||||
run(['xcrun', '--sdk', 'iphonesimulator', 'swiftc', '-swift-version', '5', '-target', 'x86_64-apple-ios16.6-simulator', '-sdk', sdk, '-I', products, '-module-cache-path', root / 'module-cache', *sources, evidence / 'source/Doubles.swift', evidence / 'source/Harness.swift', products / 'Kingfisher.o', '-o', app / 'SubBannerQA'], timeout=180)
|
||||
run(['xcrun', 'simctl', 'install', device, app])
|
||||
launch = run(['xcrun', 'simctl', 'launch', device, bundle])
|
||||
(evidence / 'ownership.json').write_text(json.dumps({'root': str(root), 'bundle': bundle, 'pid': int(launch.split(':')[-1]), 'device': device, 'deviceOwner': 'parent'}, indent=2))
|
||||
container = Path(run(['xcrun', 'simctl', 'get_app_container', device, bundle, 'data']))
|
||||
native = container / 'Documents/native'
|
||||
deadline = time.monotonic() + 330
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(5)
|
||||
action_file = native / 'actions.json'
|
||||
if action_file.exists():
|
||||
events = json.loads(action_file.read_text())
|
||||
print(events[-1]['event'], events[-1]['time'], flush=True)
|
||||
if events[-1]['event'] == 'finished':
|
||||
break
|
||||
else:
|
||||
print('TIMEOUT; preserving partial output', flush=True)
|
||||
shutil.copytree(native, evidence / 'captures', dirs_exist_ok=True)
|
||||
after = {path: hashlib.sha256((repo / path).read_bytes()).hexdigest() for path in sources}
|
||||
(evidence / 'source-hashes-after.json').write_text(json.dumps(after, indent=2))
|
||||
(evidence / 'source-stability.json').write_text(json.dumps({'unchanged': hashes == after, 'changed': [path for path in sources if hashes[path] != after[path]]}, indent=2))
|
||||
run(['xcrun', 'simctl', 'io', device, 'screenshot', evidence / 'native-os-final.png'])
|
||||
print('CAPTURES COPIED; OWN APP STILL INSTALLED UNTIL ASSESSMENT', flush=True)
|
||||
@@ -0,0 +1,331 @@
|
||||
{
|
||||
"nativeCaptureCount": 41,
|
||||
"additionalOSCapture": {
|
||||
"path": "native-os-final.png",
|
||||
"sha256": "a79e5e44bbee7a123266c9391c8bf2138d52028a9674c0aa8633cc8ce2d84dd4",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"productHashesUnchanged": true,
|
||||
"geometry": {
|
||||
"home-first": {
|
||||
"pixelFramePt": {
|
||||
"x": 0,
|
||||
"width": 375.0,
|
||||
"y": 280.0,
|
||||
"height": 125.0
|
||||
},
|
||||
"precedingGapPt": 24,
|
||||
"followingGapPt": 24,
|
||||
"derivedHeightPt": 125
|
||||
},
|
||||
"home-second": {
|
||||
"pixelFramePt": {
|
||||
"x": 0,
|
||||
"width": 375.0,
|
||||
"y": 280.0,
|
||||
"height": 187.5
|
||||
},
|
||||
"precedingGapPt": 24,
|
||||
"followingGapPt": 24,
|
||||
"derivedHeightPt": 187.5
|
||||
},
|
||||
"home-empty": {
|
||||
"remainingOrdinarySectionGapPt": 24,
|
||||
"extraBannerGapPt": 0
|
||||
},
|
||||
"home-resize-320": {
|
||||
"pixelWidthPt": 320,
|
||||
"geometryHeightPt": 106.66666666666669,
|
||||
"roundingTolerancePt": 0.51
|
||||
},
|
||||
"content-first": {
|
||||
"pixelFramePt": {
|
||||
"x": 0,
|
||||
"width": 375.0,
|
||||
"y": 136.0,
|
||||
"height": 125.0
|
||||
},
|
||||
"precedingGapPt": 24,
|
||||
"followingGapPt": 24,
|
||||
"derivedHeightPt": 125
|
||||
},
|
||||
"content-second": {
|
||||
"pixelFramePt": {
|
||||
"x": 0,
|
||||
"width": 375.0,
|
||||
"y": 136.0,
|
||||
"height": 187.5
|
||||
},
|
||||
"precedingGapPt": 24,
|
||||
"followingGapPt": 24,
|
||||
"derivedHeightPt": 187.5
|
||||
},
|
||||
"content-empty": {
|
||||
"remainingOrdinarySectionGapPt": 24,
|
||||
"extraBannerGapPt": 0
|
||||
},
|
||||
"content-resize-320": {
|
||||
"pixelWidthPt": 320,
|
||||
"geometryHeightPt": 106.66666666666669,
|
||||
"roundingTolerancePt": 0.51
|
||||
}
|
||||
},
|
||||
"pngs": {
|
||||
"active-before-5s.png": {
|
||||
"sha256": "f7bf598d63d31ebffac09bcb5d3cd57183fe3f9da9f4120e14ccd22983c27ca5",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"active-next.png": {
|
||||
"sha256": "4880e28aeae451cddbda8f2ba58ec147b60440f26cfa86d75dbf0e6fa381b01f",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"content-before-5s.png": {
|
||||
"sha256": "e03bdd3d19802929920672f7f39d26df4bef465d9b13981250cc5fb02f2111a9",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"content-empty.png": {
|
||||
"sha256": "ee561f7ce93a7cd7c719dfb32eda4b40e6d0d7998bbb71cfdfa356cdc60e7bcb",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"content-first.png": {
|
||||
"sha256": "e03bdd3d19802929920672f7f39d26df4bef465d9b13981250cc5fb02f2111a9",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"content-light.png": {
|
||||
"sha256": "e03bdd3d19802929920672f7f39d26df4bef465d9b13981250cc5fb02f2111a9",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"content-one-after-6s.png": {
|
||||
"sha256": "c8c2eeee836d90b3422fe1cf7e8646f094e0db78e7bde73880e499317ca1c84d",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"content-one.png": {
|
||||
"sha256": "c8c2eeee836d90b3422fe1cf7e8646f094e0db78e7bde73880e499317ca1c84d",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"content-resize-320.png": {
|
||||
"sha256": "a6313fb5db500af6e6880ed4f62d42ed2f8e3d2c75f2adaa7d85d8186fffa0cc",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"content-second.png": {
|
||||
"sha256": "3558fe0a407f41360c69b241dc5c7bb63d5e2a5b73450db5918b2c87dcfaff13",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"content-transition.png": {
|
||||
"sha256": "22e35cf56b149fa74b0cc5c602a64fbb738ffde4ef933acc83ba01be4e04ab6d",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"content-wrap.png": {
|
||||
"sha256": "e03bdd3d19802929920672f7f39d26df4bef465d9b13981250cc5fb02f2111a9",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"delayed-next-valid.png": {
|
||||
"sha256": "6c20a5f54dc012c9a7d9259c9a7d0628f250262291bced052ab92652e98342c3",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"delayed-no-invented-height.png": {
|
||||
"sha256": "60643fc2ef02e26153272782cc4a97422d6ab0b08a0ca5422a94906c1fa4ff5a",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"delayed-old-image-arrived.png": {
|
||||
"sha256": "6c20a5f54dc012c9a7d9259c9a7d0628f250262291bced052ab92652e98342c3",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"failure-placeholder-other-content.png": {
|
||||
"sha256": "217c258f4a1b83f9c9bf3564cf615a5ef2d0600f1a62819cc7fc2a7becedaa9e",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"failure-valid-first.png": {
|
||||
"sha256": "96b2051aedb2733a5620b2de5305cac9532cf9f266b3292d91f550c63550f60b",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"failure-wrap-valid.png": {
|
||||
"sha256": "96b2051aedb2733a5620b2de5305cac9532cf9f266b3292d91f550c63550f60b",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"home-before-5s.png": {
|
||||
"sha256": "8fc8502f06140d7f04bfb6defea3c235639e874172014611ead622d1ac5306ca",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"home-empty.png": {
|
||||
"sha256": "34af8cfecd132f08666cdabd7972a44a536977e5ebee4244a61d1c697b7184a0",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"home-first.png": {
|
||||
"sha256": "8fc8502f06140d7f04bfb6defea3c235639e874172014611ead622d1ac5306ca",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"home-light.png": {
|
||||
"sha256": "8fc8502f06140d7f04bfb6defea3c235639e874172014611ead622d1ac5306ca",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"home-one-after-6s.png": {
|
||||
"sha256": "6dd493128e266ec896e9f5bc2fec1756fad5f5673e88235a7dc342b989515448",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"home-one.png": {
|
||||
"sha256": "6dd493128e266ec896e9f5bc2fec1756fad5f5673e88235a7dc342b989515448",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"home-resize-320.png": {
|
||||
"sha256": "78760d7c64bcaa360e7147f3f9fefba038ed177e223cbbd4c0c4377ab6e91399",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"home-second.png": {
|
||||
"sha256": "8ce24e8f55ea474bd74822282cf180571e6142bfc0942614acc2c6f4d4ac8ac0",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"home-transition.png": {
|
||||
"sha256": "765df410019493e09f234a9b92cc2e6d358280938c0cdd28624a8f3ac11d2981",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"home-wrap.png": {
|
||||
"sha256": "8fc8502f06140d7f04bfb6defea3c235639e874172014611ead622d1ac5306ca",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"inactive-after-6s.png": {
|
||||
"sha256": "f7bf598d63d31ebffac09bcb5d3cd57183fe3f9da9f4120e14ccd22983c27ca5",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"inactive-before.png": {
|
||||
"sha256": "f7bf598d63d31ebffac09bcb5d3cd57183fe3f9da9f4120e14ccd22983c27ca5",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"inactive-start.png": {
|
||||
"sha256": "f7bf598d63d31ebffac09bcb5d3cd57183fe3f9da9f4120e14ccd22983c27ca5",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"many-first.png": {
|
||||
"sha256": "44a42bfedbf994063463e774e4c6d397ea33a71e1443489e3e5a2a0f7f48a1a9",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"many-twenty-first.png": {
|
||||
"sha256": "c4173138625345685ed9a945fbd359c2cae59b9265cba158f9c563722680133d",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"many-wrap.png": {
|
||||
"sha256": "3844369f5cf4ea0fa2a2a148c06dac9ada3b3838664f5d4a97fbdaa9c3bdbfdb",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"reduce-first.png": {
|
||||
"sha256": "c88339572c7bee2dc6c952958ce148331782f28d4b455d2ba9c4a746c64a4ba9",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"reduce-second.png": {
|
||||
"sha256": "77c8bcbcb1c693bc5d713fc3e97b7c81c9ed83999eecb3f6858966acf1cbe4dd",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"reduce-transition.png": {
|
||||
"sha256": "c88339572c7bee2dc6c952958ce148331782f28d4b455d2ba9c4a746c64a4ba9",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"remount-first.png": {
|
||||
"sha256": "4880e28aeae451cddbda8f2ba58ec147b60440f26cfa86d75dbf0e6fa381b01f",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"remount-next.png": {
|
||||
"sha256": "f7bf598d63d31ebffac09bcb5d3cd57183fe3f9da9f4120e14ccd22983c27ca5",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"shrink-one.png": {
|
||||
"sha256": "23ba00bf778494937ecd3f4f15dd36007127cdf412cb33d5c1f54f2f3a7b95e0",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
},
|
||||
"unmounted-after-6s.png": {
|
||||
"sha256": "8fc8aa79c74ce769ce96965823337521634feb015d742ecf1b5a21cabd275e8b",
|
||||
"width": 750,
|
||||
"height": 1334,
|
||||
"fresh": true
|
||||
}
|
||||
},
|
||||
"assertions": "PASS",
|
||||
"manualFilenameCorrection": {
|
||||
"many-twenty-first.png": "Observed 20/21",
|
||||
"many-wrap.png": "Observed 21/21",
|
||||
"native-os-final.png": "Observed 01/21 after wrap"
|
||||
},
|
||||
"reduceMotionEnabledTest": "NOT RUN; OS false; read-only environment; parent preferences unchanged",
|
||||
"physicalTapAndSwipe": "NOT RUN; no native coordinate input tool"
|
||||
}
|
||||