docs(recommendation): 서브 배너 리뷰와 QA 증거를 보존한다
@@ -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의 후속 범위다.
|
||||
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"commands": [
|
||||
{
|
||||
"args": [
|
||||
"xcrun",
|
||||
"simctl",
|
||||
"terminate",
|
||||
"482F7827-2E3A-4559-BAF4-48B68CC5725B",
|
||||
"qa.sodalive.subbanner.native.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"
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"/Users/klaus/Library/Developer/Xcode/DerivedData/SodaLive-clzdtvqrsfonoxcijatzkrqdzrsu/Build/Products/Debug-iphonesimulator/Kingfisher.o": "ab9481b5935132edf5744692d88ff4c3e34f1a68f4014f88267a5ad5d95f31b8",
|
||||
"/Users/klaus/Library/Developer/Xcode/DerivedData/SodaLive-clzdtvqrsfonoxcijatzkrqdzrsu/Build/Products/Debug-iphonesimulator/Kingfisher.swiftmodule/x86_64-apple-ios-simulator.swiftmodule": "b155d956bfaa735f7c72f4630b5be0e2dc82fcb768c2c0ce930ab17faff22fdc"
|
||||
}
|
||||
|
After Width: | Height: | Size: 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"
|
||||
}
|
||||