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,74 @@
# 최종 소스 Native QA 재실행
**현재 production 캐러셀 SHA256 `7e25db3076d545ffc61ea3a1c5585e7b8a617964ef406d7570286d75a75e010a`로 기존 native matrix 재실행과 평가·정리를 완료했다. 실행한 시나리오는 PASS이며 physical tap/swipe와 Reduce Motion ON은 NOT RUN이다.** REV-P1-001의 현재 소스와 캡처 연결을 보완하는 증거이며 독립 reviewer의 종료 판정을 대신하지 않는다.
## 출처와 변경 범위
- 실행일: 2026-10-30 KST. 부모 소유 iPhone SE3/iOS 18.3 `482F7827-2E3A-4559-BAF4-48B68CC5725B`, 375×667pt, 2x를 재사용했다. Xcode 26 SDK, Swift 5, `x86_64-apple-ios16.6-simulator` compile이다. iOS 16.6 런타임 실행은 아니다.
- 기존 [native/source](../native/source)의 `run.py`, `assess.py`, `cleanup.py`, `Doubles.swift`, `Harness.swift`를 그대로 재사용했다. 변경은 evidence root, own temporary root, own bundle뿐이다. [provenance-before.json](provenance-before.json)과 [provenance-after.json](provenance-after.json)의 `harnessCloneChecks`가 경로 정규화 후 5개 파일 동일성을 assert했다. 시나리오, 대기 시간, 입력 fixture, geometry 검사는 변경하지 않았다.
- 실제 production 9개 파일의 원본 경로를 `swiftc` 입력으로 사용했다. [production-source-snapshot](production-source-snapshot/)은 전체 입력 9개의 byte-identical 보존본이며 컴파일 입력은 이 보존본이 아니다. [commands.json](commands.json)에 원본 경로와 실제 native 명령·stdout/stderr·exit가 있다.
- [source-hashes-before.json](source-hashes-before.json), [source-hashes-after.json](source-hashes-after.json), [source-hashes-final.json](source-hashes-final.json), teardown 이후 provenance까지 9개 입력과 snapshot의 해시가 일치한다. [source-stability.json](source-stability.json)은 `unchanged=true`, `changed=[]`이다.
- 기존 native 최종 해시 `1bf03b8b1e05310acce09f12b2f09b35af3587f4a7d486b22945ec502cfb506d`와 비교하면 9개 입력 중 캐러셀만 다르고 나머지 8개는 같다. 전달받은 delta는 `@GestureState isSwiping`, tap guard, `highPriorityGesture` → `simultaneousGesture`, swiping 중 자동 전환 생략, 수평 우세 drag 및 `onEnded` guard이다. 현재 전체 snapshot에서 이 처리를 확인했다. 과거 캐러셀 전체 소스는 기존 evidence에 없으므로 과거 byte 단위 diff를 재구성했다고 주장하지 않는다. 배치·비율 변경은 요청되지 않았고 이번 실행에서 제품 수정은 없었다.
기존 `native/` 62개 파일과 PRD·plan·기존 리뷰 3개, 합계 65개 보호 파일의 해시 및 파일 목록이 전후 동일하다. 기존 증거·해시를 덮어쓰지 않았다. 추가한 `source/provenance.py`는 snapshot·보호 파일·의존성 해시와 소유권을 기록하는 증거 보조 도구이며 UI 하네스 동작은 바꾸지 않는다.
## 실제 실행 Surface와 명시적 대역
production `SubBanner.swift`, `SubBannerLinkOpener.swift`, `SubBannerCarousel.swift`, 두 recommendation View composition, 실제 spacing/color/hex/font 소스를 원본 경로에서 컴파일했다. 기존 prebuilt Kingfisher object/module을 읽기 전용으로 링크했고 전후 해시 동일성을 확인했다. 실제 `KFImage`와 downloader를 사용한다. [dependency-hashes.json](dependency-hashes.json) 참조.
기존 `FixtureProtocol`을 실제 Kingfisher의 ephemeral URLSession에 연결했다. `subbanner.qa.invalid`만 로컬 생성 PNG로 응답하고 다른 요청은 로컬에서 실패한다. 인터넷·계정·서버는 사용하지 않았다. A=1200×400 teal, B=1200×600 orange이다.
[source/Doubles.swift](source/Doubles.swift)의 하위 추천 섹션은 48pt `QA ...` bar 또는 `EmptyView`, VM/DTO는 fixture, refresh wrapper는 `ScrollView`, toast/purchase/media player는 무효 대역이다. production 두 View의 조건부 삽입과 `VStack(s24)`는 실행하지만 실제 추천 카드·기존 메인 배너 carousel·API/인증/filter/콘텐츠 isEmpty·구매·refresh 전체를 검증하지 않는다. 오류 격리는 주변 **대역 bar 유지**에 한정한다.
## 매트릭스와 실제 관측
[captures/actions.json](captures/actions.json)의 `finished`는 launch 기준 monotonic 210.953초에 기록됐다. 41개 앱 window `drawHierarchy` PNG와 별도 [native-os-final.png](native-os-final.png), 총 42개 native 원본 모두 750×1334이고 production 소스보다 신선하다. [verification.json](verification.json)에 PNG별 hash·크기·freshness와 geometry가 있다. [contact-1.png](contact-1.png)~[contact-4.png](contact-4.png)는 41개 앱 캡처 전체를 포함하는 검토용 축소 배치이며 새로운 UI가 아니다.
| 시나리오 | 판정 | 이번 실행에서 확인한 증거 |
|---|---|---|
| 홈 지정 위치 | PASS | [home-first](captures/home-first.png): QA active creators 뒤, debut 앞 |
| 콘텐츠 지정 위치 | PASS | [content-first](captures/content-first.png): QA main banner 뒤, exclusive 앞 |
| 전체 폭·원본 비율 | PASS | 양 화면 A: x0/w375/h125pt, B: x0/w375/h187.5pt. Home y280, Content y136 |
| s24·empty 추가 gap0 | PASS | 이미지 위아래 24pt. [home-empty](captures/home-empty.png), [content-empty](captures/content-empty.png)는 일반 섹션 간격 24pt만 유지 |
| 실제 5초 자동 전환·2항목 wrap | PASS, 정밀도 한정 | Home 시작 후 4.523초 A → 5.374초 transition → 6.118초 B. Content 4.791초 A → 5.640초 transition → 6.387초 B. 두 wrap 캡처는 A/01/02 |
| 단일 고정 | PASS | 양 화면 one과 one-after-6s의 PNG SHA 동일, 01/01. 실제 관측 간격 Home 6.048초, Content 6.069초 |
| 21개·20개 제한 없음 | PASS | [many-first](captures/many-first.png) 01/21 → [many-twenty-first](captures/many-twenty-first.png) **20/21** → [many-wrap](captures/many-wrap.png) **21/21** → OS final **02/21**. timer/index를 직접 바꾸지 않음 |
| 컨테이너 resize | PASS | 양 화면 부모 frame 375→320pt, 배너 x0/w320/h106.6667pt. 실제 회전/기기 resize는 아님 |
| 지연 이미지 비율 격리 | PASS | 초기 direct 높이0 → 유효 B 높이187.5 → 지연된 A 도착 뒤에도 B/187.5 유지 |
| 실패 이미지 국소 처리 | PASS | [failure-placeholder-other-content](captures/failure-placeholder-other-content.png): 회색125pt/02/02와 주변 QA bar 유지 → [failure-wrap-valid](captures/failure-wrap-valid.png) A |
| inactive/active | PASS, 주입 환경 | 실제 scenePhase probe inactive 확인, 6.512초 간격 PNG SHA 동일 B. active 주입 후 4.837초 B, 6.345초 A |
| unmount/remount·목록 축소 | PASS | unmounted 캡처는 하네스 제목만 유지. remount-first A → remount-next B → shrink-one A/01/01 |
| light/dark | PASS, 범위 한정 | 두 화면 light 캡처가 dark first와 동일한 검정 배경·읽을 수 있는 indicator 유지. 하네스 window override이며 전체 앱 system theme 검증 아님 |
| 링크 fallback driver | PASS, 탭 아님 | production opener에 false completion 주입, `sodaqa://banner/900` 뒤 `https://subbanner.qa.invalid/destination` 각 1회 |
| physical tap/swipe·세로 scroll 공존 | NOT RUN | 좌표 입력/hit testing 미실행. gesture 소스 존재와 compile을 실제 제스처 성공으로 취급하지 않음 |
| Reduce Motion ON | NOT RUN | OS 관측값 false, 부모 접근성 설정 변경 없음. `reduce-*` 파일명은 ON 증거가 아님 |
파일명은 예정 촬영 시점명이다. 현재 원본을 직접 열어 many-twenty-first=20/21, many-wrap=21/21, native-os-final=02/21 및 `many-2-wide.png`를 확인했다. 기존 `assess.py`에는 이전 실행 관측 `native-os-final=01/21`이 하드코딩되어 있으므로 script 복제 자체는 유지하고 **이번 `verification.json`의 해당 설명만 실제 캡처에 맞게 수동 정정했다.** OS final은 복사와 screenshot 사이에도 timer가 계속 동작한 뒤의 상태다. 21→1 순간 프레임은 없고 21 이후 2가 확인된 범위에서 wrap을 판단한다.
모든 contact sheet와 주요 full-size PNG(두 화면 first/second/transition, 21개 후반 2장, OS final)를 직접 열어 배너·주변 bar 배치, clipping/겹침, fixture 순서와 페이지 번호를 확인했다. 전환 프레임은 A 일부와 B가 함께 보이는 이동 중 상태이며 결함이나 정착 프레임으로 오인하지 않았다. timestamp는 screenshot 완료 후 기록이므로 5.000초 정확도·프레임률·매끄러움·인터럽트·중복 timer 부재를 보증하지 않는다.
## 명령과 평가
아래 순서로 각 명령 exit0을 확인했다. `provenance.py`는 Python AST와 clone 동일성도 검사한다. native compile stderr는 비어 있었다. LSP 도구는 제공되지 않았으며 native `swiftc` 진단으로 대체했다. 전체 앱 build, 집중 CLI checks 재실행, 독립 reviewer 호출은 이번 위임 범위에 따라 하지 않았다.
```bash
export TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native-final.97cQKj
export NODE_COMPILE_CACHE="$TMPDIR"
export BUN_RUNTIME_TRANSPILER_CACHE_PATH="$TMPDIR"
export PYTHONDONTWRITEBYTECODE=1
python3 docs/20261029_추천_서브_배너/reviews/evidence/native-final/source/provenance.py
python3 docs/20261029_추천_서브_배너/reviews/evidence/native-final/source/run.py
python3 docs/20261029_추천_서브_배너/reviews/evidence/native-final/source/assess.py
python3 docs/20261029_추천_서브_배너/reviews/evidence/native-final/source/cleanup.py
python3 docs/20261029_추천_서브_배너/reviews/evidence/native-final/source/provenance.py --after
```
`assess.py`의 assertions PASS: 41 PNG와 OS PNG signature/750×1334/freshness, 원본 소스 해시, 양 화면 전체 폭·A/B 비율·s24·empty·320pt, single 고정, inactive 고정, 지연 이미지 높이 격리, 21개 input, opener 순서. 자동 geometry와 양끝 fixture pixel scan을 교차 검증한다. 페이지 번호 관측은 위 직접 검사와 구분했다.
## 소유권과 정리
[ownership.json](ownership.json)에 own bundle `qa.sodalive.subbanner.native.final.97cQKj`, PID `10884`, own root, 부모 device 소유권을 launch 직후 기록했다. cache는 own root 아래 `module-cache`; shell TMPDIR/NODE/BUN cache 환경은 provenance에 기록했다.
[cleanup.json](cleanup.json): own app terminate/uninstall exit0, app container 조회 exit2, PID 조회 exit1로 부재 확인, own root와 module-cache 제거, 부모 simulator Booted 유지, production 해시 불변, shared cache 삭제 없음. [provenance-after.json](provenance-after.json)은 teardown 이후에도 source/snapshot·기존 native 62개 파일·PRD/plan/review 3개·Kingfisher 입력 해시 불변과 root 부재를 다시 확인했다. device shutdown/delete/reset, 공유 cache 삭제, worktree, commit/push, 의존성 변경은 하지 않았다.
남은 한계: physical tap/swipe/세로 scroll 공존, Reduce Motion ON, 실제 UIApplication.open, OS background/foreground, 전체 SodaLive 앱·실서비스, 실기기/iOS16.6/Dynamic Type/VoiceOver는 미검증이다. 현재 증거 범위의 실행 차단 문제는 없으며 REV-P1-001의 공식 해소 판정은 상위 실행자와 기존 reviewer의 후속 범위다.
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: 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: 67 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.final.97cQKj"
],
"exit": 0,
"stdout": "",
"stderr": ""
},
{
"args": [
"xcrun",
"simctl",
"uninstall",
"482F7827-2E3A-4559-BAF4-48B68CC5725B",
"qa.sodalive.subbanner.native.final.97cQKj"
],
"exit": 0,
"stdout": "",
"stderr": ""
},
{
"args": [
"xcrun",
"simctl",
"get_app_container",
"482F7827-2E3A-4559-BAF4-48B68CC5725B",
"qa.sodalive.subbanner.native.final.97cQKj",
"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",
"10884",
"-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\" : 1322459136,\n \"logPath\" : \"\\/Users\\/klaus\\/Library\\/Logs\\/CoreSimulator\\/482F7827-2E3A-4559-BAF4-48B68CC5725B\",\n \"udid\" : \"482F7827-2E3A-4559-BAF4-48B68CC5725B\",\n \"isAvailable\" : true,\n \"logPathSize\" : 258048,\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,98 @@
[
{
"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-final.97cQKj/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-final/source/Doubles.swift",
"/Users/klaus/Develop/sodalive/iOS/SodaLive/docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native-final/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-final.97cQKj/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-final.97cQKj/SubBannerQA.app"
],
"exit": 0,
"stdout": "",
"stderr": ""
},
{
"args": [
"xcrun",
"simctl",
"launch",
"482F7827-2E3A-4559-BAF4-48B68CC5725B",
"qa.sodalive.subbanner.native.final.97cQKj"
],
"exit": 0,
"stdout": "qa.sodalive.subbanner.native.final.97cQKj: 10884\n",
"stderr": ""
},
{
"args": [
"xcrun",
"simctl",
"get_app_container",
"482F7827-2E3A-4559-BAF4-48B68CC5725B",
"qa.sodalive.subbanner.native.final.97cQKj",
"data"
],
"exit": 0,
"stdout": "/Users/klaus/Library/Developer/CoreSimulator/Devices/482F7827-2E3A-4559-BAF4-48B68CC5725B/data/Containers/Data/Application/5DBF90FA-A107-4B83-B182-48D78D5F081E\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-final/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-final/native-os-final.png\n"
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 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: 59 KiB

@@ -0,0 +1,7 @@
{
"root": "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native-final.97cQKj",
"bundle": "qa.sodalive.subbanner.native.final.97cQKj",
"pid": 10884,
"device": "482F7827-2E3A-4559-BAF4-48B68CC5725B",
"deviceOwner": "parent"
}
@@ -0,0 +1,40 @@
//
// ColorExtension.swift
// SodaLive
//
// Created by klaus on 2023/08/09.
//
// https://seons-dev.tistory.com/174
//
import SwiftUI
extension Color {
init(hex: String) {
let scanner = Scanner(string: hex)
_ = scanner.scanString("#")
var rgb: UInt64 = 0
scanner.scanHexInt64(&rgb)
let r = Double((rgb >> 16) & 0xFF) / 255.0
let g = Double((rgb >> 8) & 0xFF) / 255.0
let b = Double((rgb >> 0) & 0xFF) / 255.0
self.init(red: r, green: g, blue: b)
}
}
extension UIColor {
convenience init(hex: String, alpha: CGFloat = 1.0) {
let scanner = Scanner(string: hex)
_ = scanner.scanString("#")
var rgb: UInt64 = 0
scanner.scanHexInt64(&rgb)
let r = Double((rgb >> 16) & 0xFF) / 255.0
let g = Double((rgb >> 8) & 0xFF) / 255.0
let b = Double((rgb >> 0) & 0xFF) / 255.0
self.init(red: r, green: g, blue: b, alpha: alpha)
}
}
@@ -0,0 +1,164 @@
//
// FontModifier.swift
// SodaLive
//
// Created by klaus on 1/23/26.
//
import SwiftUI
enum SodaTypography {
case heading1
case heading2
case heading3
case heading4
case body1
case body2
case body3
case body4
case body5
case body6
case caption1
case caption2
case caption3
var size: CGFloat {
switch self {
case .heading1:
return 24
case .heading2:
return 22
case .heading3:
return 20
case .heading4:
return 18
case .body1, .body2, .body3:
return 16
case .body4, .body5, .body6:
return 14
case .caption1, .caption2, .caption3:
return 12
}
}
func localizedFontName(locale: Locale = .autoupdatingCurrent) -> String? {
fontName(for: .localized, weight: weight, locale: locale)
}
var weight: SwiftUI.Font.Weight {
switch self {
case .heading1, .heading2, .heading3, .heading4, .body1, .body4, .caption1:
return .bold
case .body2, .body5, .caption2:
return .medium
case .body3, .body6, .caption3:
return .regular
}
}
}
enum SodaFontFamily {
case localized
case pattaya
case phosphate
}
private func resolvedLanguageCode(currentLocale: Locale) -> String? {
if let raw = UserDefaults.standard.string(forKey: "app.language"),
let option = LanguageOption(rawValue: raw),
option != .system {
return option.rawValue
}
return currentLocale.language.languageCode?.identifier
}
private func koreanFontName(for weight: SwiftUI.Font.Weight) -> String {
switch weight {
case .bold:
return "Pretendard-Bold"
case .medium:
return "Pretendard-Medium"
case .light:
return "Pretendard-Light"
default:
return "Pretendard-Regular"
}
}
private func japaneseFontName(for weight: SwiftUI.Font.Weight) -> String {
switch weight {
case .bold:
return "NotoSansJP-Bold"
case .medium:
return "NotoSansJP-Medium"
case .light:
return "NotoSansJP-Light"
default:
return "NotoSansJP-Regular"
}
}
private func fontName(for family: SodaFontFamily, weight: SwiftUI.Font.Weight, locale: Locale) -> String? {
switch family {
case .localized:
switch resolvedLanguageCode(currentLocale: locale) {
case "ko":
return koreanFontName(for: weight)
case "ja":
return japaneseFontName(for: weight)
default:
return nil
}
case .pattaya:
return "Pattaya-Regular"
case .phosphate:
return "PhosphateSolid"
}
}
struct FontModifier: ViewModifier {
@Environment(\.locale) private var locale
let size: CGFloat
let weight: SwiftUI.Font.Weight
let family: SodaFontFamily
func body(content: Content) -> some View {
if let fontName = fontName(for: family, weight: weight, locale: locale) {
content.font(.custom(fontName, size: size))
} else {
content.font(.system(size: size, weight: weight))
}
}
}
extension View {
func appFont(
size: CGFloat,
weight: SwiftUI.Font.Weight = .regular,
family: SodaFontFamily = .localized
) -> some View {
self.modifier(FontModifier(size: size, weight: weight, family: family))
}
func appFont(_ typography: SodaTypography, family: SodaFontFamily = .localized) -> some View {
appFont(size: typography.size, weight: typography.weight, family: family)
}
}
extension Text {
func appFont(
size: CGFloat,
weight: SwiftUI.Font.Weight = .regular,
family: SodaFontFamily = .localized
) -> Text {
if let fontName = fontName(for: family, weight: weight, locale: .autoupdatingCurrent) {
return self.font(.custom(fontName, size: size))
}
return self.font(.system(size: size, weight: weight))
}
func appFont(_ typography: SodaTypography, family: SodaFontFamily = .localized) -> Text {
appFont(size: typography.size, weight: typography.weight, family: family)
}
}
@@ -0,0 +1,72 @@
//
// Color.swift
// SodaLive
//
// Created by klaus on 2024/01/17.
//
import Foundation
import SwiftUI
extension Color {
static let main = Color(hex: "80D8FF")
static let sub = Color(hex: "1313BC")
static let button = Color(hex: "3bb9f1")
static let bg = Color(hex: "13181B")
static let gray11 = Color(hex: "111111")
static let gray16 = Color(hex: "161616")
static let gray22 = Color(hex: "222222")
static let gray23 = Color(hex: "232323")
static let gray30 = Color(hex: "303030")
static let gray33 = Color(hex: "333333")
static let gray52 = Color(hex: "525252")
static let gray55 = Color(hex: "555555")
static let gray59 = Color(hex: "595959")
static let gray77 = Color(hex: "777777")
static let gray90 = Color(hex: "909090")
static let gray97 = Color(hex: "979797")
static let graybb = Color(hex: "bbbbbb")
static let grayd2 = Color(hex: "d2d2d2")
static let grayd8 = Color(hex: "d8d8d8")
static let graye2 = Color(hex: "e2e2e2")
static let grayee = Color(hex: "eeeeee")
static let mainRed = Color(hex: "ff5c49")
static let mainRed2 = Color(hex: "ea3a25")
static let mainRed3 = Color(hex: "dd4500")
static let mainYellow = Color(hex: "ffdc00")
static let soda50 = Color(hex: "E6F9FF")
static let soda100 = Color(hex: "B8EEFD")
static let soda200 = Color(hex: "85E1FB")
static let soda300 = Color(hex: "4FD2F9")
static let soda400 = Color(hex: "00BDF7")
static let soda500 = Color(hex: "00A0DF")
static let soda600 = Color(hex: "0081C2")
static let soda700 = Color(hex: "006BA4")
static let soda800 = Color(hex: "004F7F")
static let soda900 = Color(hex: "052742")
static let red50 = Color(hex: "FFF1F2")
static let red100 = Color(hex: "FFD7D9")
static let red200 = Color(hex: "FFB3B7")
static let red300 = Color(hex: "FF858B")
static let red400 = Color(hex: "FF4C3C")
static let green50 = Color(hex: "F5FBEF")
static let green100 = Color(hex: "E9FADB")
static let green200 = Color(hex: "C0F595")
static let green300 = Color(hex: "9FFB56")
static let green400 = Color(hex: "73FF01")
static let gray50 = Color(hex: "FAFAFA")
static let gray100 = Color(hex: "F2F2F2")
static let gray200 = Color(hex: "E2E2E2")
static let gray300 = Color(hex: "CCCCCC")
static let gray400 = Color(hex: "B5B5B5")
static let gray500 = Color(hex: "959595")
static let gray600 = Color(hex: "787878")
static let gray700 = Color(hex: "494949")
static let gray800 = Color(hex: "343434")
static let gray900 = Color(hex: "202020")
}
@@ -0,0 +1,20 @@
//
// Spacing.swift
// SodaLive
//
import SwiftUI
enum SodaSpacing {
static let s4: CGFloat = 4
static let s6: CGFloat = 6
static let s8: CGFloat = 8
static let s12: CGFloat = 12
static let s14: CGFloat = 14
static let s16: CGFloat = 16
static let s20: CGFloat = 20
static let s24: CGFloat = 24
static let s28: CGFloat = 28
static let s32: CGFloat = 32
static let s48: CGFloat = 48
}
@@ -0,0 +1,42 @@
import Foundation
struct SubBanner: Decodable, Hashable, Identifiable {
enum Placement: String, Decodable {
case homeRecommendation = "HOME_RECOMMENDATION"
case contentRecommendation = "CONTENT_RECOMMENDATION"
}
let id: Int
let placement: Placement
let imageUrl: String
let webUrl: String?
let androidDeepLink: String?
let iosDeepLink: String?
var imageURL: URL? { Self.webURL(imageUrl) }
var webURL: URL? { Self.webURL(webUrl) }
var iosURL: URL? {
guard let url = Self.absoluteURL(iosDeepLink),
let scheme = url.scheme?.lowercased(),
!["javascript", "data", "file", "content"].contains(scheme) else { return nil }
return url
}
var isEligible: Bool {
imageURL != nil && (iosURL != nil || webURL != nil)
}
private static func absoluteURL(_ raw: String?) -> URL? {
guard let raw, let url = URL(string: raw),
url.scheme != nil, url.absoluteString == raw else { return nil }
return url
}
private static func webURL(_ raw: String?) -> URL? {
guard let url = absoluteURL(raw),
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https",
let host = url.host, !host.isEmpty else { return nil }
return url
}
}
@@ -0,0 +1,147 @@
import SwiftUI
import Kingfisher
struct SubBannerCarousel: View {
let items: [SubBanner]
let onTap: (SubBanner) -> Void
@Environment(\.scenePhase) private var scenePhase
@State private var currentIndex = 0
@State private var containerWidth: CGFloat = 0
@State private var imageRatios: [String: CGFloat] = [:]
@State private var lastKnownRatio: CGFloat?
@GestureState private var isSwiping = false
@GestureState private var dragOffset: CGFloat = 0
private static let autoScrollNanoseconds: UInt64 = 5_000_000_000
private static let transitionDuration: TimeInterval = 0.35
private var safeIndex: Int { min(currentIndex, max(0, items.count - 1)) }
private var currentRatio: CGFloat? {
guard !items.isEmpty else { return nil }
return imageRatios[items[safeIndex].imageUrl]
}
private var height: CGFloat {
guard !items.isEmpty, let ratio = currentRatio ?? lastKnownRatio else { return 0 }
return containerWidth / ratio
}
var body: some View {
GeometryReader { geometry in
HStack(spacing: 0) {
ForEach(items) { item in
Button {
guard !isSwiping else { return }
onTap(item)
} label: {
KFImage(item.imageURL)
.placeholder { Color.gray800 }
.onSuccess { result in
let size = result.image.size
guard size.width > 0, size.height > 0 else { return }
imageRatios[item.imageUrl] = size.width / size.height
}
.startLoadingBeforeViewAppear()
.resizable()
.scaledToFit()
.frame(width: geometry.size.width, height: height)
.background(Color.gray800)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("\(safeIndex + 1) / \(items.count)"))
.accessibilityHidden(items[safeIndex].id != item.id)
}
}
.offset(x: -CGFloat(safeIndex) * geometry.size.width + dragOffset)
.frame(width: geometry.size.width, height: height, alignment: .leading)
.contentShape(Rectangle())
.simultaneousGesture(dragGesture(width: geometry.size.width))
.clipped()
.overlay(alignment: .topTrailing) {
if !items.isEmpty && height > 0 {
pageIndicator
.padding(SodaSpacing.s14)
}
}
.onAppear { containerWidth = geometry.size.width }
.onChange(of: geometry.size.width) { containerWidth = $0 }
}
.frame(height: height)
.onChange(of: currentRatio) { ratio in
if let ratio { lastKnownRatio = ratio }
}
.onChange(of: items) { _ in
currentIndex = 0
if items.isEmpty { lastKnownRatio = nil }
}
.task(id: scenePhase == .active && items.count > 1 ? items : []) {
guard scenePhase == .active, items.count > 1 else { return }
while !Task.isCancelled {
do {
try await Task.sleep(nanoseconds: Self.autoScrollNanoseconds)
} catch {
return
}
guard !Task.isCancelled else { return }
if !isSwiping { move(by: 1) }
}
}
.accessibilityAdjustableAction { direction in
switch direction {
case .increment: move(by: 1)
case .decrement: move(by: -1)
@unknown default: break
}
}
}
private var pageIndicator: some View {
HStack(spacing: 0) {
Text(String(format: "%02d", safeIndex + 1))
.foregroundColor(.white)
Text("/" + String(format: "%02d", items.count))
.foregroundColor(.gray400)
}
.appFont(.body3)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.padding(.horizontal, SodaSpacing.s6)
.padding(.vertical, SodaSpacing.s4)
.background(Color.gray900)
.clipShape(Capsule())
.accessibilityHidden(true)
}
private func dragGesture(width: CGFloat) -> some Gesture {
DragGesture(minimumDistance: SodaSpacing.s12)
.updating($dragOffset) { value, offset, _ in
if items.count > 1, abs(value.translation.width) > abs(value.translation.height) {
offset = value.translation.width
}
}
.updating($isSwiping) { _, swiping, _ in swiping = true }
.onEnded { value in
guard abs(value.translation.width) > abs(value.translation.height) else { return }
if value.translation.width < -width * 0.2 {
move(by: 1)
} else if value.translation.width > width * 0.2 {
move(by: -1)
}
}
}
private func move(by step: Int) {
guard items.count > 1 else { return }
let next = safeIndex + step
if items.indices.contains(next) {
withAnimation(.easeOut(duration: Self.transitionDuration)) { currentIndex = next }
} else {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) { currentIndex = next < 0 ? items.count - 1 : 0 }
}
}
}
@@ -0,0 +1,28 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
enum SubBannerLinkOpener {
static func open(_ banner: SubBanner, using opener: @escaping (URL, @escaping (Bool) -> Void) -> Void) {
let webURL = banner.webURL
if let iosURL = banner.iosURL {
opener(iosURL) { opened in
if !opened, let webURL {
opener(webURL) { _ in }
}
}
} else if let webURL {
opener(webURL) { _ in }
}
}
#if canImport(UIKit)
static func open(_ banner: SubBanner) {
open(banner) { url, completion in
UIApplication.shared.open(url, options: [:], completionHandler: completion)
}
}
#endif
}
@@ -0,0 +1,128 @@
import SwiftUI
struct MainContentRecommendationView: View {
let onTapContent: (Int) -> Void
let onTapBanner: (AudioBannerResponse) -> Void
let onTapSeries: (Int) -> Void
let onTapLatestAudioAll: () -> Void
let onTapNewAndHotAll: () -> Void
let onTapSodaLiveOnlyAll: () -> Void
let onTapFreeAudioAll: () -> Void
let onTapPointAudioAll: () -> Void
@StateObject private var viewModel = MainContentRecommendationViewModel()
var body: some View {
ZStack {
Color.black
.ignoresSafeArea()
if viewModel.isLoading && !viewModel.hasLoaded {
loadingContent
} else if viewModel.shouldShowEmptyState {
emptyContent
} else {
LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
VStack(alignment: .leading, spacing: SodaSpacing.s24) {
if let recommendations = viewModel.recommendations {
MainContentAudioBannerSection(
items: recommendations.banners,
onTapBanner: onTapBanner
)
if !recommendations.eligibleSubBanners.isEmpty {
SubBannerCarousel(items: recommendations.eligibleSubBanners) {
SubBannerLinkOpener.open($0)
}
}
MainContentVoiceOnOnlySection(
title: I18n.MainContentRecommendation.sodaLiveOnlySectionTitle,
items: recommendations.originalSeries,
action: onTapSodaLiveOnlyAll,
onTapSeries: onTapSeries
)
MainContentAudioListCarouselSection(
title: I18n.MainContentRecommendation.newAndHotSectionTitle,
items: recommendations.newAndHotAudios,
action: onTapNewAndHotAll,
onTapContent: onTapContent
)
MainContentAudioHorizontalCardSection(
title: I18n.MainContentRecommendation.latestAudioSectionTitle,
items: recommendations.latestAudios,
action: onTapLatestAudioAll,
onTapContent: onTapContent
)
MainContentAudioHorizontalCardSection(
title: I18n.MainContentRecommendation.freeAudioSectionTitle,
items: recommendations.freeAudios,
action: onTapFreeAudioAll,
onTapContent: onTapContent
)
MainContentAudioHorizontalCardSection(
title: I18n.MainContentRecommendation.pointAudioSectionTitle,
items: recommendations.pointAudios,
action: onTapPointAudioAll,
onTapContent: onTapContent
)
MainContentMostCommentedAudioSection(
title: I18n.MainContentRecommendation.mostCommentedAudioSectionTitle,
items: recommendations.mostCommentedAudios,
onTapContent: onTapContent
)
MainContentRecommendedAudioGridSection(
title: I18n.MainContentRecommendation.recommendedAudioSectionTitle,
items: recommendations.recommendedAudios,
onTapContent: onTapContent
)
}
}
.padding(.vertical, SodaSpacing.s20)
}
}
}
.onAppear {
if !viewModel.hasLoaded {
viewModel.fetchRecommendations()
}
}
.sodaToast(
isPresented: $viewModel.isShowPopup,
message: viewModel.errorMessage,
autohideIn: 2
)
}
private var loadingContent: some View {
GeometryReader { geometry in
LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(maxWidth: .infinity)
.frame(minHeight: geometry.size.height)
}
}
}
private var emptyContent: some View {
GeometryReader { geometry in
LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
MainContentAudioEmptyStateView(message: viewModel.emptyStateMessage)
.frame(minHeight: geometry.size.height)
}
}
}
}
@@ -0,0 +1,150 @@
import SwiftUI
struct MainHomeRecommendationView: View {
let onTapLive: (Int) -> Void
let onTapCreator: (Int) -> Void
let onTapContent: (Int) -> Void
let onTapCharacter: (Int) -> Void
let onTapCommunityPost: (Int) -> Void
let onTapBanner: (RecommendationBannerResponse) -> Void
let onTapLiveAll: () -> Void
let onTapFollowAll: (@escaping () -> Void) -> Void
@StateObject private var viewModel = MainHomeRecommendationViewModel()
@StateObject private var communityMediaPlayer = CreatorCommunityMediaPlayerManager.shared
var body: some View {
ZStack {
Color.black
.ignoresSafeArea()
if viewModel.isLoading && viewModel.recommendations == nil && viewModel.errorMessage.isEmpty {
loadingContent
} else {
LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
VStack(alignment: .leading, spacing: SodaSpacing.s24) {
MainHomeLiveSection(
items: viewModel.recommendations?.lives ?? [],
onTapLive: onTapLive,
onTapAll: onTapLiveAll
)
MainHomeBannerSection(
items: viewModel.recommendations?.banners ?? [],
onTapBanner: onTapBanner
)
MainHomeActiveCreatorSection(
items: viewModel.recommendations?.recentlyActiveCreators ?? [],
onTapLive: onTapLive,
onTapCreator: onTapCreator,
onTapContent: onTapContent,
onTapCommunityPost: onTapCommunityPost
)
if let items = viewModel.recommendations?.eligibleSubBanners, !items.isEmpty {
SubBannerCarousel(items: items) { SubBannerLinkOpener.open($0) }
}
MainHomeRecentDebutCreatorSection(
items: viewModel.recommendations?.recentDebutCreators ?? [],
onTapCreator: onTapCreator
)
MainHomeAiCharacterSection(
items: viewModel.recommendations?.aiCharacters ?? [],
onTapCharacter: onTapCharacter
)
MainHomeCheerCreatorSection(
items: viewModel.recommendations?.cheerCreators ?? [],
isFollowAllCompleted: viewModel.isFollowAllCompleted("cheer"),
isFollowAllLoading: viewModel.isFollowAllLoading("cheer"),
onTapCreator: onTapCreator,
onTapFollowAll: {
onTapFollowAll {
viewModel.followAll(
creatorIds: viewModel.recommendations?.cheerCreators.map(\.creatorId) ?? [],
completionKey: "cheer"
)
}
}
)
MainHomePopularCommunitySection(
items: viewModel.recommendations?.popularCommunityPosts ?? [],
onTapCommunityPost: onTapCommunityPost,
onTapLike: { postId in
viewModel.likeCommunityPost(postId: postId)
},
onTapPurchase: { item in
viewModel.openCommunityPostPurchase(postId: item.postId, price: item.price)
}
)
MainHomeBusinessInfoSection()
}
.padding(.vertical, SodaSpacing.s20)
}
}
}
.onAppear {
if viewModel.recommendations == nil {
viewModel.fetchRecommendations()
}
}
.sodaToast(
isPresented: $viewModel.isShowPopup,
message: viewModel.errorMessage,
autohideIn: 2
)
.sodaToast(
isPresented: $communityMediaPlayer.isShowPopup,
message: communityMediaPlayer.errorMessage,
autohideIn: 2
)
.overlay {
if viewModel.isShowCommunityPostPurchaseDialog {
CommunityPostPurchaseDialog(
isShowing: $viewModel.isShowCommunityPostPurchaseDialog,
can: viewModel.purchasingCommunityPostPrice,
confirmAction: viewModel.purchaseCommunityPost
)
}
if communityMediaPlayer.isLoading {
LoadingView()
}
}
}
private var loadingContent: some View {
GeometryReader { geometry in
LottieRefreshableScrollView(action: {
await viewModel.refresh()
}) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(maxWidth: .infinity)
.frame(minHeight: geometry.size.height)
}
}
}
}
struct MainHomeRecommendationView_Previews: PreviewProvider {
static var previews: some View {
MainHomeRecommendationView(
onTapLive: { _ in },
onTapCreator: { _ in },
onTapContent: { _ in },
onTapCharacter: { _ in },
onTapCommunityPost: { _ in },
onTapBanner: { _ in },
onTapLiveAll: {},
onTapFollowAll: { action in action() }
)
}
}
@@ -0,0 +1,137 @@
{
"recordedAtUTC": "2026-10-29T15:27:41.981415+00:00",
"expectedCarouselSHA256": "7e25db3076d545ffc61ea3a1c5585e7b8a617964ef406d7570286d75a75e010a",
"sourceHashes": {
"SodaLive/Sources/V2/Component/Banner/SubBanner.swift": "875bcdb953e47e9c3e157a7d5adc8b39c271691af2b02437fc738ac727d5b7e9",
"SodaLive/Sources/V2/Component/Banner/SubBannerLinkOpener.swift": "0aa860b206d0dff737cb5847741a2b531dbe633728b5fa5f2eeea5410834dab3",
"SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift": "7e25db3076d545ffc61ea3a1c5585e7b8a617964ef406d7570286d75a75e010a",
"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"
},
"snapshotHashes": {
"SodaLive/Sources/V2/Component/Banner/SubBanner.swift": "875bcdb953e47e9c3e157a7d5adc8b39c271691af2b02437fc738ac727d5b7e9",
"SodaLive/Sources/V2/Component/Banner/SubBannerLinkOpener.swift": "0aa860b206d0dff737cb5847741a2b531dbe633728b5fa5f2eeea5410834dab3",
"SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift": "7e25db3076d545ffc61ea3a1c5585e7b8a617964ef406d7570286d75a75e010a",
"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"
},
"sourceMtimes": {
"SodaLive/Sources/V2/Component/Banner/SubBanner.swift": 1793283861.7506907,
"SodaLive/Sources/V2/Component/Banner/SubBannerLinkOpener.swift": 1793283861.7525754,
"SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift": 1793285589.998333,
"SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift": 1793284151.8665268,
"SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift": 1793284151.9246447,
"SodaLive/Sources/UI/Theme/Spacing.swift": 1778841466.1905165,
"SodaLive/Sources/UI/Theme/Color.swift": 1778841466.1895542,
"SodaLive/Sources/Extensions/ColorExtension.swift": 1691563626.4298327,
"SodaLive/Sources/Extensions/FontModifier.swift": 1791429315.4954295
},
"protectedHashes": {
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/README.md": "ddfee9078ffe8ae973b461b4616abe38b58a9eff13b1d55b072e930a3ddfcec5",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/actions.json": "22e801ceb991e92820654416c221da99dbfb4892afc28d8e3646c52e32847263",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/active-before-5s.png": "f7bf598d63d31ebffac09bcb5d3cd57183fe3f9da9f4120e14ccd22983c27ca5",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/active-next.png": "4880e28aeae451cddbda8f2ba58ec147b60440f26cfa86d75dbf0e6fa381b01f",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/content-before-5s.png": "e03bdd3d19802929920672f7f39d26df4bef465d9b13981250cc5fb02f2111a9",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/content-empty.png": "ee561f7ce93a7cd7c719dfb32eda4b40e6d0d7998bbb71cfdfa356cdc60e7bcb",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/content-first.png": "e03bdd3d19802929920672f7f39d26df4bef465d9b13981250cc5fb02f2111a9",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/content-light.png": "e03bdd3d19802929920672f7f39d26df4bef465d9b13981250cc5fb02f2111a9",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/content-one-after-6s.png": "c8c2eeee836d90b3422fe1cf7e8646f094e0db78e7bde73880e499317ca1c84d",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/content-one.png": "c8c2eeee836d90b3422fe1cf7e8646f094e0db78e7bde73880e499317ca1c84d",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/content-resize-320.png": "a6313fb5db500af6e6880ed4f62d42ed2f8e3d2c75f2adaa7d85d8186fffa0cc",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/content-second.png": "3558fe0a407f41360c69b241dc5c7bb63d5e2a5b73450db5918b2c87dcfaff13",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/content-transition.png": "22e35cf56b149fa74b0cc5c602a64fbb738ffde4ef933acc83ba01be4e04ab6d",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/content-wrap.png": "e03bdd3d19802929920672f7f39d26df4bef465d9b13981250cc5fb02f2111a9",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/delayed-next-valid.png": "6c20a5f54dc012c9a7d9259c9a7d0628f250262291bced052ab92652e98342c3",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/delayed-no-invented-height.png": "60643fc2ef02e26153272782cc4a97422d6ab0b08a0ca5422a94906c1fa4ff5a",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/delayed-old-image-arrived.png": "6c20a5f54dc012c9a7d9259c9a7d0628f250262291bced052ab92652e98342c3",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/failure-placeholder-other-content.png": "217c258f4a1b83f9c9bf3564cf615a5ef2d0600f1a62819cc7fc2a7becedaa9e",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/failure-valid-first.png": "96b2051aedb2733a5620b2de5305cac9532cf9f266b3292d91f550c63550f60b",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/failure-wrap-valid.png": "96b2051aedb2733a5620b2de5305cac9532cf9f266b3292d91f550c63550f60b",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/home-before-5s.png": "8fc8502f06140d7f04bfb6defea3c235639e874172014611ead622d1ac5306ca",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/home-empty.png": "34af8cfecd132f08666cdabd7972a44a536977e5ebee4244a61d1c697b7184a0",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/home-first.png": "8fc8502f06140d7f04bfb6defea3c235639e874172014611ead622d1ac5306ca",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/home-light.png": "8fc8502f06140d7f04bfb6defea3c235639e874172014611ead622d1ac5306ca",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/home-one-after-6s.png": "6dd493128e266ec896e9f5bc2fec1756fad5f5673e88235a7dc342b989515448",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/home-one.png": "6dd493128e266ec896e9f5bc2fec1756fad5f5673e88235a7dc342b989515448",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/home-resize-320.png": "78760d7c64bcaa360e7147f3f9fefba038ed177e223cbbd4c0c4377ab6e91399",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/home-second.png": "8ce24e8f55ea474bd74822282cf180571e6142bfc0942614acc2c6f4d4ac8ac0",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/home-transition.png": "765df410019493e09f234a9b92cc2e6d358280938c0cdd28624a8f3ac11d2981",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/home-wrap.png": "8fc8502f06140d7f04bfb6defea3c235639e874172014611ead622d1ac5306ca",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/inactive-after-6s.png": "f7bf598d63d31ebffac09bcb5d3cd57183fe3f9da9f4120e14ccd22983c27ca5",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/inactive-before.png": "f7bf598d63d31ebffac09bcb5d3cd57183fe3f9da9f4120e14ccd22983c27ca5",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/inactive-start.png": "f7bf598d63d31ebffac09bcb5d3cd57183fe3f9da9f4120e14ccd22983c27ca5",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/many-first.png": "44a42bfedbf994063463e774e4c6d397ea33a71e1443489e3e5a2a0f7f48a1a9",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/many-twenty-first.png": "c4173138625345685ed9a945fbd359c2cae59b9265cba158f9c563722680133d",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/many-wrap.png": "3844369f5cf4ea0fa2a2a148c06dac9ada3b3838664f5d4a97fbdaa9c3bdbfdb",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/reduce-first.png": "c88339572c7bee2dc6c952958ce148331782f28d4b455d2ba9c4a746c64a4ba9",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/reduce-second.png": "77c8bcbcb1c693bc5d713fc3e97b7c81c9ed83999eecb3f6858966acf1cbe4dd",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/reduce-transition.png": "c88339572c7bee2dc6c952958ce148331782f28d4b455d2ba9c4a746c64a4ba9",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/remount-first.png": "4880e28aeae451cddbda8f2ba58ec147b60440f26cfa86d75dbf0e6fa381b01f",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/remount-next.png": "f7bf598d63d31ebffac09bcb5d3cd57183fe3f9da9f4120e14ccd22983c27ca5",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/shrink-one.png": "23ba00bf778494937ecd3f4f15dd36007127cdf412cb33d5c1f54f2f3a7b95e0",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/unmounted-after-6s.png": "8fc8aa79c74ce769ce96965823337521634feb015d742ecf1b5a21cabd275e8b",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/cleanup.json": "b29b5a87a0856c6dd475152c6bb6af51c7cd49845b090952e6b3195f4391daa9",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/commands.json": "e1908abc656cf06a80593f26ed5411e9d3964fc1a2459927931b4e5feffff9c6",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/contact-1.png": "d608d75ea336e80d7a301c32f3b3c3d750aa55aee6fe66f7850e24d60a675c86",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/contact-2.png": "24b85be00ac82c378dcc8e5941e48aa3b326ec973267839d99e7288afbd18079",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/contact-3.png": "24836a87d48450dd827640e42873e1c921df2dbe485f634d663aff7bdaa42126",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/contact-4.png": "ec0524b1e3a6ee30a4f7b312a63edef699a69f43a56deb3dfe79ac06e8c6ec13",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/dependency-hashes.json": "40be04494df3dc3a6230b54039acf080c943ba200de43819ae8db54a1b19d8fd",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/native-os-final.png": "a79e5e44bbee7a123266c9391c8bf2138d52028a9674c0aa8633cc8ce2d84dd4",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/ownership.json": "c5f8ba7a2fd5aaacf3d7a7ffb41f75691665cbea291975a54d27eb59cebf7931",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source/Doubles.swift": "eb98fd62f2f76e456d29d3931b7378e9b68b2e9ad0f76bea64f597e1a9a5fb9f",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source/Harness.swift": "4e3bbde7fe363b9f1f436abb1ffff3d39dbdf17db002ab3b5f9093fe70bbcfa4",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source/assess.py": "45d594396785738545c30228bc7ef46b09f4d0a69847614ec7396277c1cecbe2",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source/cleanup.py": "ba973e9805092bc46a0e032a992826e20b36b3730d7140a3f3d5c05bdde93caa",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source/run.py": "c0324005ce5efed32f07f422e84f2e066153667f327a4157691d7edd64eaccd2",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source-hashes-after.json": "b85d19121fbd459858290f7dee83cd4d3989abf3e65098824402fdf760ee056c",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source-hashes-before.json": "b85d19121fbd459858290f7dee83cd4d3989abf3e65098824402fdf760ee056c",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source-hashes-final.json": "b85d19121fbd459858290f7dee83cd4d3989abf3e65098824402fdf760ee056c",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source-stability.json": "edc3fbbc77c5dcf8fcabdd54cf0ec79b77afaa703d28afcf5faf8b8e6fe5fd1d",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/verification.json": "35cec56ab6f4d53bb2ed2ff4306ebd5fa962734ac3a4a794c59dc9332e310573",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/phase1-sub-banner-integration.md": "883a770b7d1b293805231d6779d3a3d2f51d04e102358dd3cf7ac25c8d7bf12f",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/plan-task.md": "81aa9ab6349449ebc46379118cdcf08af8fa3f88f9a77ef2ece721451101eb0b",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/prd.md": "3ab2aeb2f12224b68c7838f9948949a846d1f5bb24a8918ff68df2b1e4e75872"
},
"dependencyHashes": {
"/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"
},
"harnessCloneChecks": {
"run.py": "IDENTICAL after evidence root/temp root/bundle normalization",
"assess.py": "IDENTICAL after evidence root/temp root/bundle normalization",
"cleanup.py": "IDENTICAL after evidence root/temp root/bundle normalization",
"Doubles.swift": "IDENTICAL after evidence root/temp root/bundle normalization",
"Harness.swift": "IDENTICAL after evidence root/temp root/bundle normalization"
},
"ownedRoot": "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native-final.97cQKj",
"ownedBundle": "qa.sodalive.subbanner.native.final.97cQKj",
"ownedCache": "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native-final.97cQKj/module-cache",
"cacheEnvironment": {
"TMPDIR": "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native-final.97cQKj",
"NODE_COMPILE_CACHE": "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native-final.97cQKj",
"BUN_RUNTIME_TRANSPILER_CACHE_PATH": "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native-final.97cQKj"
},
"parentDevice": {
"lastBootedAt": "2026-10-29T14:27:43Z",
"dataPath": "/Users/klaus/Library/Developer/CoreSimulator/Devices/482F7827-2E3A-4559-BAF4-48B68CC5725B/data",
"dataPathSize": 1324466176,
"logPath": "/Users/klaus/Library/Logs/CoreSimulator/482F7827-2E3A-4559-BAF4-48B68CC5725B",
"udid": "482F7827-2E3A-4559-BAF4-48B68CC5725B",
"isAvailable": true,
"logPathSize": 258048,
"deviceTypeIdentifier": "com.apple.CoreSimulator.SimDeviceType.iPhone-SE-3rd-generation",
"state": "Booted",
"name": "SubBannerQA-20261029"
},
"afterCleanup": true,
"assertions": "PASS"
}
@@ -0,0 +1,137 @@
{
"recordedAtUTC": "2026-10-29T15:20:09.044875+00:00",
"expectedCarouselSHA256": "7e25db3076d545ffc61ea3a1c5585e7b8a617964ef406d7570286d75a75e010a",
"sourceHashes": {
"SodaLive/Sources/V2/Component/Banner/SubBanner.swift": "875bcdb953e47e9c3e157a7d5adc8b39c271691af2b02437fc738ac727d5b7e9",
"SodaLive/Sources/V2/Component/Banner/SubBannerLinkOpener.swift": "0aa860b206d0dff737cb5847741a2b531dbe633728b5fa5f2eeea5410834dab3",
"SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift": "7e25db3076d545ffc61ea3a1c5585e7b8a617964ef406d7570286d75a75e010a",
"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"
},
"snapshotHashes": {
"SodaLive/Sources/V2/Component/Banner/SubBanner.swift": "875bcdb953e47e9c3e157a7d5adc8b39c271691af2b02437fc738ac727d5b7e9",
"SodaLive/Sources/V2/Component/Banner/SubBannerLinkOpener.swift": "0aa860b206d0dff737cb5847741a2b531dbe633728b5fa5f2eeea5410834dab3",
"SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift": "7e25db3076d545ffc61ea3a1c5585e7b8a617964ef406d7570286d75a75e010a",
"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"
},
"sourceMtimes": {
"SodaLive/Sources/V2/Component/Banner/SubBanner.swift": 1793283861.7506907,
"SodaLive/Sources/V2/Component/Banner/SubBannerLinkOpener.swift": 1793283861.7525754,
"SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift": 1793285589.998333,
"SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift": 1793284151.8665268,
"SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift": 1793284151.9246447,
"SodaLive/Sources/UI/Theme/Spacing.swift": 1778841466.1905165,
"SodaLive/Sources/UI/Theme/Color.swift": 1778841466.1895542,
"SodaLive/Sources/Extensions/ColorExtension.swift": 1691563626.4298327,
"SodaLive/Sources/Extensions/FontModifier.swift": 1791429315.4954295
},
"protectedHashes": {
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/README.md": "ddfee9078ffe8ae973b461b4616abe38b58a9eff13b1d55b072e930a3ddfcec5",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/actions.json": "22e801ceb991e92820654416c221da99dbfb4892afc28d8e3646c52e32847263",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/active-before-5s.png": "f7bf598d63d31ebffac09bcb5d3cd57183fe3f9da9f4120e14ccd22983c27ca5",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/active-next.png": "4880e28aeae451cddbda8f2ba58ec147b60440f26cfa86d75dbf0e6fa381b01f",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/content-before-5s.png": "e03bdd3d19802929920672f7f39d26df4bef465d9b13981250cc5fb02f2111a9",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/content-empty.png": "ee561f7ce93a7cd7c719dfb32eda4b40e6d0d7998bbb71cfdfa356cdc60e7bcb",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/content-first.png": "e03bdd3d19802929920672f7f39d26df4bef465d9b13981250cc5fb02f2111a9",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/content-light.png": "e03bdd3d19802929920672f7f39d26df4bef465d9b13981250cc5fb02f2111a9",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/content-one-after-6s.png": "c8c2eeee836d90b3422fe1cf7e8646f094e0db78e7bde73880e499317ca1c84d",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/content-one.png": "c8c2eeee836d90b3422fe1cf7e8646f094e0db78e7bde73880e499317ca1c84d",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/content-resize-320.png": "a6313fb5db500af6e6880ed4f62d42ed2f8e3d2c75f2adaa7d85d8186fffa0cc",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/content-second.png": "3558fe0a407f41360c69b241dc5c7bb63d5e2a5b73450db5918b2c87dcfaff13",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/content-transition.png": "22e35cf56b149fa74b0cc5c602a64fbb738ffde4ef933acc83ba01be4e04ab6d",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/content-wrap.png": "e03bdd3d19802929920672f7f39d26df4bef465d9b13981250cc5fb02f2111a9",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/delayed-next-valid.png": "6c20a5f54dc012c9a7d9259c9a7d0628f250262291bced052ab92652e98342c3",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/delayed-no-invented-height.png": "60643fc2ef02e26153272782cc4a97422d6ab0b08a0ca5422a94906c1fa4ff5a",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/delayed-old-image-arrived.png": "6c20a5f54dc012c9a7d9259c9a7d0628f250262291bced052ab92652e98342c3",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/failure-placeholder-other-content.png": "217c258f4a1b83f9c9bf3564cf615a5ef2d0600f1a62819cc7fc2a7becedaa9e",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/failure-valid-first.png": "96b2051aedb2733a5620b2de5305cac9532cf9f266b3292d91f550c63550f60b",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/failure-wrap-valid.png": "96b2051aedb2733a5620b2de5305cac9532cf9f266b3292d91f550c63550f60b",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/home-before-5s.png": "8fc8502f06140d7f04bfb6defea3c235639e874172014611ead622d1ac5306ca",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/home-empty.png": "34af8cfecd132f08666cdabd7972a44a536977e5ebee4244a61d1c697b7184a0",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/home-first.png": "8fc8502f06140d7f04bfb6defea3c235639e874172014611ead622d1ac5306ca",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/home-light.png": "8fc8502f06140d7f04bfb6defea3c235639e874172014611ead622d1ac5306ca",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/home-one-after-6s.png": "6dd493128e266ec896e9f5bc2fec1756fad5f5673e88235a7dc342b989515448",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/home-one.png": "6dd493128e266ec896e9f5bc2fec1756fad5f5673e88235a7dc342b989515448",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/home-resize-320.png": "78760d7c64bcaa360e7147f3f9fefba038ed177e223cbbd4c0c4377ab6e91399",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/home-second.png": "8ce24e8f55ea474bd74822282cf180571e6142bfc0942614acc2c6f4d4ac8ac0",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/home-transition.png": "765df410019493e09f234a9b92cc2e6d358280938c0cdd28624a8f3ac11d2981",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/home-wrap.png": "8fc8502f06140d7f04bfb6defea3c235639e874172014611ead622d1ac5306ca",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/inactive-after-6s.png": "f7bf598d63d31ebffac09bcb5d3cd57183fe3f9da9f4120e14ccd22983c27ca5",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/inactive-before.png": "f7bf598d63d31ebffac09bcb5d3cd57183fe3f9da9f4120e14ccd22983c27ca5",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/inactive-start.png": "f7bf598d63d31ebffac09bcb5d3cd57183fe3f9da9f4120e14ccd22983c27ca5",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/many-first.png": "44a42bfedbf994063463e774e4c6d397ea33a71e1443489e3e5a2a0f7f48a1a9",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/many-twenty-first.png": "c4173138625345685ed9a945fbd359c2cae59b9265cba158f9c563722680133d",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/many-wrap.png": "3844369f5cf4ea0fa2a2a148c06dac9ada3b3838664f5d4a97fbdaa9c3bdbfdb",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/reduce-first.png": "c88339572c7bee2dc6c952958ce148331782f28d4b455d2ba9c4a746c64a4ba9",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/reduce-second.png": "77c8bcbcb1c693bc5d713fc3e97b7c81c9ed83999eecb3f6858966acf1cbe4dd",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/reduce-transition.png": "c88339572c7bee2dc6c952958ce148331782f28d4b455d2ba9c4a746c64a4ba9",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/remount-first.png": "4880e28aeae451cddbda8f2ba58ec147b60440f26cfa86d75dbf0e6fa381b01f",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/remount-next.png": "f7bf598d63d31ebffac09bcb5d3cd57183fe3f9da9f4120e14ccd22983c27ca5",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/shrink-one.png": "23ba00bf778494937ecd3f4f15dd36007127cdf412cb33d5c1f54f2f3a7b95e0",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/captures/unmounted-after-6s.png": "8fc8aa79c74ce769ce96965823337521634feb015d742ecf1b5a21cabd275e8b",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/cleanup.json": "b29b5a87a0856c6dd475152c6bb6af51c7cd49845b090952e6b3195f4391daa9",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/commands.json": "e1908abc656cf06a80593f26ed5411e9d3964fc1a2459927931b4e5feffff9c6",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/contact-1.png": "d608d75ea336e80d7a301c32f3b3c3d750aa55aee6fe66f7850e24d60a675c86",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/contact-2.png": "24b85be00ac82c378dcc8e5941e48aa3b326ec973267839d99e7288afbd18079",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/contact-3.png": "24836a87d48450dd827640e42873e1c921df2dbe485f634d663aff7bdaa42126",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/contact-4.png": "ec0524b1e3a6ee30a4f7b312a63edef699a69f43a56deb3dfe79ac06e8c6ec13",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/dependency-hashes.json": "40be04494df3dc3a6230b54039acf080c943ba200de43819ae8db54a1b19d8fd",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/native-os-final.png": "a79e5e44bbee7a123266c9391c8bf2138d52028a9674c0aa8633cc8ce2d84dd4",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/ownership.json": "c5f8ba7a2fd5aaacf3d7a7ffb41f75691665cbea291975a54d27eb59cebf7931",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source/Doubles.swift": "eb98fd62f2f76e456d29d3931b7378e9b68b2e9ad0f76bea64f597e1a9a5fb9f",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source/Harness.swift": "4e3bbde7fe363b9f1f436abb1ffff3d39dbdf17db002ab3b5f9093fe70bbcfa4",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source/assess.py": "45d594396785738545c30228bc7ef46b09f4d0a69847614ec7396277c1cecbe2",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source/cleanup.py": "ba973e9805092bc46a0e032a992826e20b36b3730d7140a3f3d5c05bdde93caa",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source/run.py": "c0324005ce5efed32f07f422e84f2e066153667f327a4157691d7edd64eaccd2",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source-hashes-after.json": "b85d19121fbd459858290f7dee83cd4d3989abf3e65098824402fdf760ee056c",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source-hashes-before.json": "b85d19121fbd459858290f7dee83cd4d3989abf3e65098824402fdf760ee056c",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source-hashes-final.json": "b85d19121fbd459858290f7dee83cd4d3989abf3e65098824402fdf760ee056c",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/source-stability.json": "edc3fbbc77c5dcf8fcabdd54cf0ec79b77afaa703d28afcf5faf8b8e6fe5fd1d",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/evidence/native/verification.json": "35cec56ab6f4d53bb2ed2ff4306ebd5fa962734ac3a4a794c59dc9332e310573",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/reviews/phase1-sub-banner-integration.md": "883a770b7d1b293805231d6779d3a3d2f51d04e102358dd3cf7ac25c8d7bf12f",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/plan-task.md": "81aa9ab6349449ebc46379118cdcf08af8fa3f88f9a77ef2ece721451101eb0b",
"docs/20261029_\ucd94\ucc9c_\uc11c\ube0c_\ubc30\ub108/prd.md": "3ab2aeb2f12224b68c7838f9948949a846d1f5bb24a8918ff68df2b1e4e75872"
},
"dependencyHashes": {
"/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"
},
"harnessCloneChecks": {
"run.py": "IDENTICAL after evidence root/temp root/bundle normalization",
"assess.py": "IDENTICAL after evidence root/temp root/bundle normalization",
"cleanup.py": "IDENTICAL after evidence root/temp root/bundle normalization",
"Doubles.swift": "IDENTICAL after evidence root/temp root/bundle normalization",
"Harness.swift": "IDENTICAL after evidence root/temp root/bundle normalization"
},
"ownedRoot": "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native-final.97cQKj",
"ownedBundle": "qa.sodalive.subbanner.native.final.97cQKj",
"ownedCache": "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native-final.97cQKj/module-cache",
"cacheEnvironment": {
"TMPDIR": "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native-final.97cQKj",
"NODE_COMPILE_CACHE": "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native-final.97cQKj",
"BUN_RUNTIME_TRANSPILER_CACHE_PATH": "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native-final.97cQKj"
},
"parentDevice": {
"lastBootedAt": "2026-10-29T14:27:43Z",
"dataPath": "/Users/klaus/Library/Developer/CoreSimulator/Devices/482F7827-2E3A-4559-BAF4-48B68CC5725B/data",
"dataPathSize": 1322061824,
"logPath": "/Users/klaus/Library/Logs/CoreSimulator/482F7827-2E3A-4559-BAF4-48B68CC5725B",
"udid": "482F7827-2E3A-4559-BAF4-48B68CC5725B",
"isAvailable": true,
"logPathSize": 258048,
"deviceTypeIdentifier": "com.apple.CoreSimulator.SimDeviceType.iPhone-SE-3rd-generation",
"state": "Booted",
"name": "SubBannerQA-20261029"
},
"afterCleanup": false,
"assertions": "PASS"
}
@@ -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": "7e25db3076d545ffc61ea3a1c5585e7b8a617964ef406d7570286d75a75e010a",
"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": "7e25db3076d545ffc61ea3a1c5585e7b8a617964ef406d7570286d75a75e010a",
"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": "7e25db3076d545ffc61ea3a1c5585e7b8a617964ef406d7570286d75a75e010a",
"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-final')
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-final')
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-final.97cQKj'
assert bundle == 'qa.sodalive.subbanner.native.final.97cQKj'
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,57 @@
import ast
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
evidence = Path('docs/20261029_추천_서브_배너/reviews/evidence/native-final')
original = evidence.with_name('native')
root = Path('/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native-final.97cQKj')
bundle = 'qa.sodalive.subbanner.native.final.97cQKj'
expected = '7e25db3076d545ffc61ea3a1c5585e7b8a617964ef406d7570286d75a75e010a'
carousel = 'SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift'
for key in ['TMPDIR', 'NODE_COMPILE_CACHE', 'BUN_RUNTIME_TRANSPILER_CACHE_PATH']:
assert os.environ[key] == str(root)
sources = json.loads((original / 'source-hashes-before.json').read_text())
current = {path: hashlib.sha256(Path(path).read_bytes()).hexdigest() for path in sources}
assert current[carousel] == expected
protected = sorted(path for path in original.rglob('*') if path.is_file())
protected += [evidence.parents[1] / 'phase1-sub-banner-integration.md', evidence.parents[2] / 'plan-task.md', evidence.parents[2] / 'prd.md']
protected_hashes = {str(path): hashlib.sha256(path.read_bytes()).hexdigest() for path in protected}
clone_checks = {}
for name in ['run.py', 'assess.py', 'cleanup.py', 'Doubles.swift', 'Harness.swift']:
baseline = (original / 'source' / name).read_text()
normalized = (evidence / 'source' / name).read_text().replace('evidence/native-final', 'evidence/native').replace('sub-banner-native-final.97cQKj', 'sub-banner-native.tstn99').replace(bundle, 'qa.sodalive.subbanner.native.tstn99')
assert normalized == baseline, name
clone_checks[name] = 'IDENTICAL after evidence root/temp root/bundle normalization'
for script in (evidence / 'source').glob('*.py'):
ast.parse(script.read_text(), filename=str(script))
products = Path('/Users/klaus/Library/Developer/Xcode/DerivedData/SodaLive-clzdtvqrsfonoxcijatzkrqdzrsu/Build/Products/Debug-iphonesimulator')
dependencies = {str(products / path): hashlib.sha256((products / path).read_bytes()).hexdigest() for path in ['Kingfisher.o', 'Kingfisher.swiftmodule/x86_64-apple-ios-simulator.swiftmodule']}
after = '--after' in sys.argv
if after:
before = json.loads((evidence / 'provenance-before.json').read_text())
assert current == before['sourceHashes']
assert protected_hashes == before['protectedHashes']
assert dependencies == before['dependencyHashes']
assert not root.exists()
else:
assert root.is_dir()
assert not (evidence / 'provenance-before.json').exists()
for path in sources:
destination = evidence / 'production-source-snapshot' / path
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, destination)
snapshots = {path: hashlib.sha256((evidence / 'production-source-snapshot' / path).read_bytes()).hexdigest() for path in sources}
assert snapshots == current
device_result = subprocess.run(['xcrun', 'simctl', 'list', 'devices', 'booted', '-j'], capture_output=True, text=True, check=True)
device_data = json.loads(device_result.stdout)
device = next(item for items in device_data['devices'].values() for item in items if item['udid'] == '482F7827-2E3A-4559-BAF4-48B68CC5725B')
assert device['state'] == 'Booted'
result = {'recordedAtUTC': datetime.now(timezone.utc).isoformat(), 'expectedCarouselSHA256': expected, 'sourceHashes': current, 'snapshotHashes': snapshots, 'sourceMtimes': {path: Path(path).stat().st_mtime for path in sources}, 'protectedHashes': protected_hashes, 'dependencyHashes': dependencies, 'harnessCloneChecks': clone_checks, 'ownedRoot': str(root), 'ownedBundle': bundle, 'ownedCache': str(root / 'module-cache'), 'cacheEnvironment': {key: os.environ[key] for key in ['TMPDIR', 'NODE_COMPILE_CACHE', 'BUN_RUNTIME_TRANSPILER_CACHE_PATH']}, 'parentDevice': device, 'afterCleanup': after, 'assertions': 'PASS'}
(evidence / ('provenance-after.json' if after else 'provenance-before.json')).write_text(json.dumps(result, indent=2))
print(json.dumps({'afterCleanup': after, 'carouselSHA256': expected, 'sourceSnapshots': len(snapshots), 'protectedFiles': len(protected_hashes), 'harnessCloneChecks': clone_checks, 'assertions': 'PASS'}, 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-final'
root = Path('/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native-final.97cQKj')
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.final.97cQKj'
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": "a2228f05c1ebf83cde90f2cbd4c830e4a495b001dc5acfbf58c2faee950e737a",
"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": "cc146354db59430f125af45ede79a915e5fbc23aa7c2632cca070389c81432e8",
"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": "39341418313ee206027f04a40bc7f72b526c92821b4ddfdbfa37782a317378e4",
"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 02/21 after wrap; many-2-wide.png; manually checked current OS capture"
},
"reduceMotionEnabledTest": "NOT RUN; OS false; read-only environment; parent preferences unchanged",
"physicalTapAndSwipe": "NOT RUN; no native coordinate input tool"
}