docs(recommendation): 서브 배너 리뷰와 QA 증거를 보존한다

This commit is contained in:
Yu Sung
2026-10-30 12:19:58 +09:00
parent 7a42db66bc
commit 32ab7797e0
206 changed files with 21989 additions and 0 deletions
@@ -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를 대신하지 않는다.
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

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"
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

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"
}
Binary file not shown.

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"
}