docs(mypage): 공지 롤링 리뷰와 QA 증거를 보존한다
@@ -0,0 +1,48 @@
|
||||
# P1-T2 제목 전용 롤링 증거
|
||||
|
||||
제품 변경은 `SodaLive/Sources/MyPage/MyPageView.swift`의 `UpdateBannerView` 표시 구조뿐이다. `Text`의 identity·기존 비대칭 전환을 제목용 `ZStack` 안에 두고 그 영역을 clip했다. 화살표는 고정된 `HStack` 형제이며 Button에는 identity·전환이 없다. 기존 if-let, 현재 공지 상세 callback, padding 이후 contentShape, 글꼴·색상·제목 폭, 5초 Timer·생명주기·Reduce Motion 경로는 유지했다.
|
||||
|
||||
제품 SHA-256: `af77569942c0bbdbad0242431362b5987dc278d8f70d015ccc1ef675b2b264ed`.
|
||||
작업 전 SHA-256: `7f8ac15573ab4c9fe01508431297e40d2e95a22ad37585859f03224475749d02`.
|
||||
현재 파일의 배너 부분을 기존 retained 배너로 메모리에서 치환한 해시가 작업 전 파일과 일치한다. 배너 밖 기존 작업은 그대로다. 이 확인은 변경 범위 증명이며 UI 동작 테스트가 아니다.
|
||||
|
||||
## 네이티브 확인
|
||||
|
||||
기존 `../native-fix/rerun-154222/source/Harness.swift`를 재사용하고 임시 복사본의 `run()` 시나리오만 축소했다. 루트 뷰·캡처 구현은 동일하다. 현재 제품 배너와 실제 helper/DTO/font/spacing/color를 추출하여 UIKit `UIHostingController`로 컴파일했다. Pretendard-Regular 폰트도 앱에 포함했다. 전체 SodaLive 앱이나 웹 모사가 아니다. 입력과 대체한 AppState sink·LanguageOption은 [provenance.json](provenance.json), 실행한 정확한 명령·exit·출력은 [commands.json](commands.json), 컴파일 입력은 [source/](source/)에 있다.
|
||||
|
||||
전용 iPhone SE3 / iOS 18.3 / 375pt 폭에서 새 native `drawHierarchy` PNG 10장을 생성했다. 모든 PNG의 signature·750×1334 크기·추출 배너 이후 생성 여부를 검사했다. [전체 배너 비교판](banner-contact-sheet.png) 및 원본 [frames/](frames/)를 직접 열어 확인했다. 원본 PNG를 수정하지 않았으며 `*-banner.png`는 명시적인 관찰용 crop이다.
|
||||
|
||||
| 시나리오 | 근거와 관측 |
|
||||
|---|---|
|
||||
| 2개 첫 교체 | `frames/two-rest.png` 첫째 → `two-end.png` 둘째, 제목 영역 변경 2,213px |
|
||||
| 마지막 → 처음의 rest/mid/end | `frames/wrap-rest.png` 둘째 → `wrap-mid.png` 두 제목의 세로 이동·제목 영역 경계 clipping → `wrap-end.png` 첫째. 제목 변경은 rest→mid 3,857px, mid→end 3,796px |
|
||||
| 1개 고정 | `frames/one-rest.png`와 `one-after-6s.png`의 제목·화살표 영역 동일 |
|
||||
| 긴 한글 제목 | `frames/long-dark.png`, `long-light.png`: 한 줄 ellipsis, 화살표 비겹침, 배너 영역 동일 |
|
||||
| 화살표 고정 | 10장 모두 crop `[654,267,702,339)`에서 변경 픽셀 0. 화살표 실제 nonblack 경계 `[672,289,689,318)`도 동일 |
|
||||
|
||||
화살표 RGB 해시: `bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6`.
|
||||
제목 관찰 영역은 `[48,267,638,339)`, 배너는 `[0,267,750,339)`이며 좌표는 px이다. [comparison.json](comparison.json)에 각 프레임의 해시·신선도·픽셀 수·화살표 경계를 기록했다. wrap 종료 제목은 최초 첫째와 동일하고 wrap 시작은 둘째 종료와 동일하여 실제 순환도 대조했다.
|
||||
|
||||
`two-mid.png`는 예약 시각과 실제 렌더링의 차이로 전환 전에 캡처되어 rest와 같다. 이를 중간 전환의 증거로 간주하지 않는다. 실제 in-flight 증거는 `wrap-mid.png`이다. 주기를 단축하거나 제품 Timer를 교체하지 않았으며 정밀 5.000초 측정·프레임 성능 보증도 아니다.
|
||||
|
||||
## 명령과 결과
|
||||
|
||||
저장소 루트에서 실행:
|
||||
|
||||
```sh
|
||||
python3 "docs/20261028_마이_탭_공지사항_롤링/reviews/evidence/title-only/run.py"
|
||||
python3 "docs/20261028_마이_탭_공지사항_롤링/reviews/evidence/title-only/compare.py"
|
||||
xcrun swiftc -frontend -parse SodaLive/Sources/MyPage/MyPageView.swift
|
||||
GIT_MASTER=1 git diff --check
|
||||
```
|
||||
|
||||
- runner 내부 `bash work/tests/check-my-page-notices.sh`: exit 0, 기존 실제 소스 회귀 검사 PASS 4줄. [focused-check.log](focused-check.log).
|
||||
- 실제 추출 배너 native 컴파일 `xcrun --sdk iphonesimulator swiftc -sdk <SDK> -target x86_64-apple-ios16.6-simulator -module-cache-path <own-root>/module-cache <source/*.swift> -o <own-root>/NoticeNativeQA.app/NoticeNativeQA`: exit 0. 확장된 모든 인자는 `commands.json` 참조.
|
||||
- 제품 Swift parse와 `git diff --check`: exit 0. LSP 도구는 제공되지 않았다.
|
||||
- 비교 스크립트: exit 0, 화살표 불변 10장·2개 순환·1개 고정·긴 제목·배너 밖 변경 없음 확인.
|
||||
|
||||
## 자원 정리와 한계
|
||||
|
||||
소유 root는 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/notice-title-native.8wiga1c7`, device는 `25EC20C1-F057-4DAD-BD61-A287C5D942C7`였다. TMPDIR·임시 캐시 환경변수를 소유 root 하위로 지정하고 atexit 정리를 등록했다. 앱 종료·전용 device shutdown/delete·root 삭제 후 부재를 확인했다. 실제 launch PID를 `ps -p <pid> -o pid=,command=`로 재확인하여 exit 1·출력 없음도 기록했다. [cleanup.json](cleanup.json)이 영수증이다. 부모 root `notice-title-only.i9lFW3`와 공유 simulator/cache는 수정하지 않았다.
|
||||
|
||||
물리/좌표 탭·padding hit-test·전체 상세 이동, VoiceOver·Reduce Motion 런타임 설정 변경, 다른 폭·실기기·iOS16.6 실행은 미검증이다. 전체 앱 빌드는 부모 담당이며 수행하지 않았다. API/서버 호출, 다른 제품 코드 수정, 부모 PRD/계획 수정, 새 UI 소스 문자열 테스트, 의존성 추가, 커밋, 다른 에이전트 호출은 하지 않았다. 독립 시각 리뷰 PASS를 주장하지 않으며 이 자료는 지정된 native 범위의 직접 관찰과 픽셀 비교 증거다.
|
||||
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"root": "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/notice-title-native.8wiga1c7",
|
||||
"device": "25EC20C1-F057-4DAD-BD61-A287C5D942C7",
|
||||
"parentRootUntouched": "notice-title-only.i9lFW3",
|
||||
"deviceAbsent": true,
|
||||
"processAbsent": true,
|
||||
"rootAbsent": true,
|
||||
"appPid": 89665,
|
||||
"postCleanupProcessCheck": {
|
||||
"argv": [
|
||||
"ps",
|
||||
"-p",
|
||||
"89665",
|
||||
"-o",
|
||||
"pid=,command="
|
||||
],
|
||||
"exit": 1,
|
||||
"output": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
from PIL import Image, ImageChops, ImageDraw
|
||||
|
||||
evidence = Path(__file__).resolve().parent
|
||||
frames = evidence / "frames"
|
||||
names = ["two-rest", "two-mid", "two-end", "wrap-rest", "wrap-mid", "wrap-end", "one-rest", "one-after-6s", "long-dark", "long-light"]
|
||||
images = {name: Image.open(frames / (name + ".png")).convert("RGB") for name in names}
|
||||
arrow_box = (654, 267, 702, 339)
|
||||
title_box = (48, 267, 638, 339)
|
||||
banner_box = (0, 267, 750, 339)
|
||||
reference_arrow = images["two-rest"].crop(arrow_box)
|
||||
|
||||
|
||||
def changed_pixels(first, second):
|
||||
return sum(pixel != (0, 0, 0) for pixel in ImageChops.difference(first, second).get_flattened_data())
|
||||
|
||||
|
||||
results = {}
|
||||
sheet = Image.new("RGB", (750, len(names) * 110), "#202020")
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
for index, name in enumerate(names):
|
||||
path = frames / (name + ".png")
|
||||
image = images[name]
|
||||
assert path.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
assert image.size == (750, 1334)
|
||||
arrow = image.crop(arrow_box)
|
||||
assert changed_pixels(reference_arrow, arrow) == 0
|
||||
bounds = arrow.getbbox()
|
||||
assert bounds is not None
|
||||
results[name] = {"sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "dimensions": list(image.size), "freshAfterExtractedSource": path.stat().st_mtime > (evidence / "source/UpdateBannerView.swift").stat().st_mtime, "arrowChangedPixels": changed_pixels(reference_arrow, arrow), "arrowRGBSha256": hashlib.sha256(arrow.tobytes()).hexdigest(), "arrowInkBoundsPx": [bounds[0] + arrow_box[0], bounds[1] + arrow_box[1], bounds[2] + arrow_box[0], bounds[3] + arrow_box[1]]}
|
||||
assert results[name]["freshAfterExtractedSource"]
|
||||
image.crop(banner_box).save(evidence / (name + "-banner.png"))
|
||||
draw.text((8, index * 110 + 5), name, fill="white")
|
||||
sheet.paste(image.crop(banner_box), (0, index * 110 + 30))
|
||||
|
||||
pairs = [("two-rest", "two-mid"), ("two-rest", "two-end"), ("wrap-rest", "wrap-mid"), ("wrap-mid", "wrap-end"), ("two-rest", "wrap-end"), ("two-end", "wrap-rest"), ("one-rest", "one-after-6s"), ("long-dark", "long-light")]
|
||||
title_diffs = {first + " -> " + second: changed_pixels(images[first].crop(title_box), images[second].crop(title_box)) for first, second in pairs}
|
||||
assert title_diffs["two-rest -> two-end"] > 0
|
||||
assert title_diffs["wrap-rest -> wrap-mid"] > 0
|
||||
assert title_diffs["wrap-mid -> wrap-end"] > 0
|
||||
assert title_diffs["two-rest -> wrap-end"] == 0
|
||||
assert title_diffs["two-end -> wrap-rest"] == 0
|
||||
assert title_diffs["one-rest -> one-after-6s"] == 0
|
||||
assert title_diffs["long-dark -> long-light"] == 0
|
||||
provenance = json.loads((evidence / "provenance.json").read_text())
|
||||
product = Path(provenance["product"])
|
||||
text = product.read_text()
|
||||
old_banner = (evidence.parent / "native-fix/rerun-154222/source/UpdateBannerView.swift").read_text().removeprefix("import SwiftUI\n\n").rstrip()
|
||||
start = text.index("struct UpdateBannerView: View {")
|
||||
end = text.index("// MARK: - Profile Section View")
|
||||
reconstructed = text[:start] + old_banner + "\n\n" + text[end:]
|
||||
assert hashlib.sha256(reconstructed.encode()).hexdigest() == "7f8ac15573ab4c9fe01508431297e40d2e95a22ad37585859f03224475749d02"
|
||||
assert hashlib.sha256(product.read_bytes()).hexdigest() == provenance["sha256"]
|
||||
report = {"productSha256": provenance["sha256"], "outsideBannerUnchanged": True, "arrowSlotPx": arrow_box, "titleRegionPx": title_box, "bannerPx": banner_box, "frames": results, "titleChangedPixels": title_diffs, "timingLimit": "two-mid was sampled before visible transition and equals rest; wrap-mid proves the native in-flight transition. Scheduling and rendering are not exact 5.000-second timing measurements."}
|
||||
(evidence / "comparison.json").write_text(json.dumps(report, indent=2))
|
||||
sheet.save(evidence / "banner-contact-sheet.png")
|
||||
commands = json.loads((evidence / "commands.json").read_text())
|
||||
launch = next(command for command in commands if "argv" in command and command["argv"][:3] == ["xcrun", "simctl", "launch"])
|
||||
pid = int(launch["output"].rsplit(":", 1)[1].strip())
|
||||
process = subprocess.run(["ps", "-p", str(pid), "-o", "pid=,command="], text=True, capture_output=True)
|
||||
assert process.returncode == 1 and not process.stdout.strip()
|
||||
cleanup = json.loads((evidence / "cleanup.json").read_text())
|
||||
assert not Path(cleanup["root"]).exists()
|
||||
cleanup.update({"appPid": pid, "processAbsent": True, "postCleanupProcessCheck": {"argv": process.args, "exit": process.returncode, "output": process.stdout}})
|
||||
(evidence / "cleanup.json").write_text(json.dumps(cleanup, indent=2))
|
||||
print(json.dumps({"arrowInvariantFrames": len(results), "titleChangedPixels": title_diffs, "outsideBannerUnchanged": True, "productSha256": provenance["sha256"]}, indent=2))
|
||||
@@ -0,0 +1,195 @@
|
||||
{
|
||||
"productSha256": "af77569942c0bbdbad0242431362b5987dc278d8f70d015ccc1ef675b2b264ed",
|
||||
"outsideBannerUnchanged": true,
|
||||
"arrowSlotPx": [
|
||||
654,
|
||||
267,
|
||||
702,
|
||||
339
|
||||
],
|
||||
"titleRegionPx": [
|
||||
48,
|
||||
267,
|
||||
638,
|
||||
339
|
||||
],
|
||||
"bannerPx": [
|
||||
0,
|
||||
267,
|
||||
750,
|
||||
339
|
||||
],
|
||||
"frames": {
|
||||
"two-rest": {
|
||||
"sha256": "9aa699352a8b59342e2dfb2d1b4e6e21aadaf7631982788036eada23b6d4d439",
|
||||
"dimensions": [
|
||||
750,
|
||||
1334
|
||||
],
|
||||
"freshAfterExtractedSource": true,
|
||||
"arrowChangedPixels": 0,
|
||||
"arrowRGBSha256": "bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6",
|
||||
"arrowInkBoundsPx": [
|
||||
672,
|
||||
289,
|
||||
689,
|
||||
318
|
||||
]
|
||||
},
|
||||
"two-mid": {
|
||||
"sha256": "9aa699352a8b59342e2dfb2d1b4e6e21aadaf7631982788036eada23b6d4d439",
|
||||
"dimensions": [
|
||||
750,
|
||||
1334
|
||||
],
|
||||
"freshAfterExtractedSource": true,
|
||||
"arrowChangedPixels": 0,
|
||||
"arrowRGBSha256": "bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6",
|
||||
"arrowInkBoundsPx": [
|
||||
672,
|
||||
289,
|
||||
689,
|
||||
318
|
||||
]
|
||||
},
|
||||
"two-end": {
|
||||
"sha256": "98a65e1f87fdbf4e2a5ac4cbc7862549b9863755a18a508b3e2b9a362230cff1",
|
||||
"dimensions": [
|
||||
750,
|
||||
1334
|
||||
],
|
||||
"freshAfterExtractedSource": true,
|
||||
"arrowChangedPixels": 0,
|
||||
"arrowRGBSha256": "bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6",
|
||||
"arrowInkBoundsPx": [
|
||||
672,
|
||||
289,
|
||||
689,
|
||||
318
|
||||
]
|
||||
},
|
||||
"wrap-rest": {
|
||||
"sha256": "98a65e1f87fdbf4e2a5ac4cbc7862549b9863755a18a508b3e2b9a362230cff1",
|
||||
"dimensions": [
|
||||
750,
|
||||
1334
|
||||
],
|
||||
"freshAfterExtractedSource": true,
|
||||
"arrowChangedPixels": 0,
|
||||
"arrowRGBSha256": "bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6",
|
||||
"arrowInkBoundsPx": [
|
||||
672,
|
||||
289,
|
||||
689,
|
||||
318
|
||||
]
|
||||
},
|
||||
"wrap-mid": {
|
||||
"sha256": "90c801bc8245634f8dcaba707ba597ed4ae324d859a442d645a254652ede8793",
|
||||
"dimensions": [
|
||||
750,
|
||||
1334
|
||||
],
|
||||
"freshAfterExtractedSource": true,
|
||||
"arrowChangedPixels": 0,
|
||||
"arrowRGBSha256": "bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6",
|
||||
"arrowInkBoundsPx": [
|
||||
672,
|
||||
289,
|
||||
689,
|
||||
318
|
||||
]
|
||||
},
|
||||
"wrap-end": {
|
||||
"sha256": "9aa699352a8b59342e2dfb2d1b4e6e21aadaf7631982788036eada23b6d4d439",
|
||||
"dimensions": [
|
||||
750,
|
||||
1334
|
||||
],
|
||||
"freshAfterExtractedSource": true,
|
||||
"arrowChangedPixels": 0,
|
||||
"arrowRGBSha256": "bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6",
|
||||
"arrowInkBoundsPx": [
|
||||
672,
|
||||
289,
|
||||
689,
|
||||
318
|
||||
]
|
||||
},
|
||||
"one-rest": {
|
||||
"sha256": "a046258bed2ce2d36d3e6d33600803b18f1b6de0f23f6aec9e592121af36f044",
|
||||
"dimensions": [
|
||||
750,
|
||||
1334
|
||||
],
|
||||
"freshAfterExtractedSource": true,
|
||||
"arrowChangedPixels": 0,
|
||||
"arrowRGBSha256": "bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6",
|
||||
"arrowInkBoundsPx": [
|
||||
672,
|
||||
289,
|
||||
689,
|
||||
318
|
||||
]
|
||||
},
|
||||
"one-after-6s": {
|
||||
"sha256": "a046258bed2ce2d36d3e6d33600803b18f1b6de0f23f6aec9e592121af36f044",
|
||||
"dimensions": [
|
||||
750,
|
||||
1334
|
||||
],
|
||||
"freshAfterExtractedSource": true,
|
||||
"arrowChangedPixels": 0,
|
||||
"arrowRGBSha256": "bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6",
|
||||
"arrowInkBoundsPx": [
|
||||
672,
|
||||
289,
|
||||
689,
|
||||
318
|
||||
]
|
||||
},
|
||||
"long-dark": {
|
||||
"sha256": "47eb29b2389c6c33242e475da6815e3bdbbf169dae29c5c893d46150c7a124e6",
|
||||
"dimensions": [
|
||||
750,
|
||||
1334
|
||||
],
|
||||
"freshAfterExtractedSource": true,
|
||||
"arrowChangedPixels": 0,
|
||||
"arrowRGBSha256": "bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6",
|
||||
"arrowInkBoundsPx": [
|
||||
672,
|
||||
289,
|
||||
689,
|
||||
318
|
||||
]
|
||||
},
|
||||
"long-light": {
|
||||
"sha256": "47eb29b2389c6c33242e475da6815e3bdbbf169dae29c5c893d46150c7a124e6",
|
||||
"dimensions": [
|
||||
750,
|
||||
1334
|
||||
],
|
||||
"freshAfterExtractedSource": true,
|
||||
"arrowChangedPixels": 0,
|
||||
"arrowRGBSha256": "bbea1dbee839fd83d527bd7dedeb600b983053e8ad39b68b23e64c7982410fb6",
|
||||
"arrowInkBoundsPx": [
|
||||
672,
|
||||
289,
|
||||
689,
|
||||
318
|
||||
]
|
||||
}
|
||||
},
|
||||
"titleChangedPixels": {
|
||||
"two-rest -> two-mid": 0,
|
||||
"two-rest -> two-end": 2213,
|
||||
"wrap-rest -> wrap-mid": 3857,
|
||||
"wrap-mid -> wrap-end": 3796,
|
||||
"two-rest -> wrap-end": 0,
|
||||
"two-end -> wrap-rest": 0,
|
||||
"one-rest -> one-after-6s": 0,
|
||||
"long-dark -> long-light": 0
|
||||
},
|
||||
"timingLimit": "two-mid was sampled before visible transition and equals rest; wrap-mid proves the native in-flight transition. Scheduling and rendering are not exact 5.000-second timing measurements."
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
PASS: actual NoticeApi/Repository public v2 GET requestPlain without Authorization; legacy list auth/timezone unchanged
|
||||
PASS: actual DTO/roller 0/1/2/3, omitted/null envelope, repeated order/wrap/selection, exact HTML/date, legacy no-id, shrink/reset and lifecycle predicate
|
||||
PASS: actual MyPageViewModel background delivery on main, 0/1/2/3 success, empty hides, HTTP/envelope/decode/transport failure preserves data, log-only/loading semantics
|
||||
PASS: My Page notices regression checks (actual production API/Repository/VM/DTO/roller, native Combine; no server calls)
|
||||
@@ -0,0 +1,187 @@
|
||||
[
|
||||
{
|
||||
"event" : "application-didBecomeActive",
|
||||
"scenario" : "preparing",
|
||||
"uptime" : 1.3584730760194361
|
||||
},
|
||||
{
|
||||
"event" : "scenario-start",
|
||||
"scenario" : "two-title-only",
|
||||
"uptime" : 1.5774609060026705
|
||||
},
|
||||
{
|
||||
"elements" : [
|
||||
|
||||
],
|
||||
"event" : "two-rest",
|
||||
"phaseProbe" : [
|
||||
{
|
||||
"event" : "appear",
|
||||
"phase" : "active"
|
||||
}
|
||||
],
|
||||
"requestedPhase" : "active",
|
||||
"scenario" : "two-title-only",
|
||||
"screenshot" : "two-rest.png",
|
||||
"uptime" : 6.7126591140404344
|
||||
},
|
||||
{
|
||||
"elements" : [
|
||||
|
||||
],
|
||||
"event" : "two-mid",
|
||||
"phaseProbe" : [
|
||||
{
|
||||
"event" : "appear",
|
||||
"phase" : "active"
|
||||
}
|
||||
],
|
||||
"requestedPhase" : "active",
|
||||
"scenario" : "two-title-only",
|
||||
"screenshot" : "two-mid.png",
|
||||
"uptime" : 6.8130604360485449
|
||||
},
|
||||
{
|
||||
"elements" : [
|
||||
|
||||
],
|
||||
"event" : "two-end",
|
||||
"phaseProbe" : [
|
||||
{
|
||||
"event" : "appear",
|
||||
"phase" : "active"
|
||||
}
|
||||
],
|
||||
"requestedPhase" : "active",
|
||||
"scenario" : "two-title-only",
|
||||
"screenshot" : "two-end.png",
|
||||
"uptime" : 7.6315462980419397
|
||||
},
|
||||
{
|
||||
"elements" : [
|
||||
|
||||
],
|
||||
"event" : "wrap-rest",
|
||||
"phaseProbe" : [
|
||||
{
|
||||
"event" : "appear",
|
||||
"phase" : "active"
|
||||
}
|
||||
],
|
||||
"requestedPhase" : "active",
|
||||
"scenario" : "two-title-only",
|
||||
"screenshot" : "wrap-rest.png",
|
||||
"uptime" : 11.689762036083266
|
||||
},
|
||||
{
|
||||
"elements" : [
|
||||
|
||||
],
|
||||
"event" : "wrap-mid",
|
||||
"phaseProbe" : [
|
||||
{
|
||||
"event" : "appear",
|
||||
"phase" : "active"
|
||||
}
|
||||
],
|
||||
"requestedPhase" : "active",
|
||||
"scenario" : "two-title-only",
|
||||
"screenshot" : "wrap-mid.png",
|
||||
"uptime" : 11.895390465040691
|
||||
},
|
||||
{
|
||||
"elements" : [
|
||||
|
||||
],
|
||||
"event" : "wrap-end",
|
||||
"phaseProbe" : [
|
||||
{
|
||||
"event" : "appear",
|
||||
"phase" : "active"
|
||||
}
|
||||
],
|
||||
"requestedPhase" : "active",
|
||||
"scenario" : "two-title-only",
|
||||
"screenshot" : "wrap-end.png",
|
||||
"uptime" : 12.91269328200724
|
||||
},
|
||||
{
|
||||
"event" : "scenario-start",
|
||||
"scenario" : "one-static",
|
||||
"uptime" : 13.90669221105054
|
||||
},
|
||||
{
|
||||
"elements" : [
|
||||
|
||||
],
|
||||
"event" : "one-rest",
|
||||
"phaseProbe" : [
|
||||
{
|
||||
"event" : "appear",
|
||||
"phase" : "active"
|
||||
}
|
||||
],
|
||||
"requestedPhase" : "active",
|
||||
"scenario" : "one-static",
|
||||
"screenshot" : "one-rest.png",
|
||||
"uptime" : 14.727093173074536
|
||||
},
|
||||
{
|
||||
"elements" : [
|
||||
|
||||
],
|
||||
"event" : "one-after-6s",
|
||||
"phaseProbe" : [
|
||||
{
|
||||
"event" : "appear",
|
||||
"phase" : "active"
|
||||
}
|
||||
],
|
||||
"requestedPhase" : "active",
|
||||
"scenario" : "one-static",
|
||||
"screenshot" : "one-after-6s.png",
|
||||
"uptime" : 20.693930465029553
|
||||
},
|
||||
{
|
||||
"event" : "scenario-start",
|
||||
"scenario" : "long-title",
|
||||
"uptime" : 22.577453555073589
|
||||
},
|
||||
{
|
||||
"elements" : [
|
||||
|
||||
],
|
||||
"event" : "long-dark",
|
||||
"phaseProbe" : [
|
||||
{
|
||||
"event" : "appear",
|
||||
"phase" : "active"
|
||||
}
|
||||
],
|
||||
"requestedPhase" : "active",
|
||||
"scenario" : "long-title",
|
||||
"screenshot" : "long-dark.png",
|
||||
"uptime" : 23.391706261085346
|
||||
},
|
||||
{
|
||||
"elements" : [
|
||||
|
||||
],
|
||||
"event" : "long-light",
|
||||
"phaseProbe" : [
|
||||
{
|
||||
"event" : "appear",
|
||||
"phase" : "active"
|
||||
}
|
||||
],
|
||||
"requestedPhase" : "active",
|
||||
"scenario" : "long-title",
|
||||
"screenshot" : "long-light.png",
|
||||
"uptime" : 25.083867059089243
|
||||
},
|
||||
{
|
||||
"event" : "title-only-finished",
|
||||
"scenario" : "long-title",
|
||||
"uptime" : 25.728418567101471
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"product": "SodaLive/Sources/MyPage/MyPageView.swift",
|
||||
"sha256": "af77569942c0bbdbad0242431362b5987dc278d8f70d015ccc1ef675b2b264ed",
|
||||
"extractedBannerSha256": "56b9f87c93d58b01d9e555b0a9d34ad5c45420929a9df81e7e64c8cf64b856fe",
|
||||
"retainedHarness": "docs/20261028_\ub9c8\uc774_\ud0ed_\uacf5\uc9c0\uc0ac\ud56d_\ub864\ub9c1/reviews/evidence/native-fix/rerun-154222/source/Harness.swift",
|
||||
"harnessChange": "run() scenario only; rendering/sampling/root unchanged",
|
||||
"dependencies": {
|
||||
"MyPageNoticeRoller.swift": {
|
||||
"path": "SodaLive/Sources/V2/MyPage/Models/MyPageNoticeRoller.swift",
|
||||
"sha256": "1bd3f0dfc2ee59ce692291bf3079b06874e7c1012e4365105d3636928e29fe8a",
|
||||
"sameAsRetained": true
|
||||
},
|
||||
"GetNoticeResponse.swift": {
|
||||
"path": "SodaLive/Sources/Settings/Notice/GetNoticeResponse.swift",
|
||||
"sha256": "bbf55eb38659af3df7c6db29af0fa7904d0f87457ec098068fe96e282cee1977",
|
||||
"sameAsRetained": true
|
||||
},
|
||||
"FontModifier.swift": {
|
||||
"path": "SodaLive/Sources/Extensions/FontModifier.swift",
|
||||
"sha256": "086fb0a19d884124a4cca638f7c8483de724ee31a61603b2cbd489aabf26a6c6",
|
||||
"sameAsRetained": true
|
||||
},
|
||||
"Spacing.swift": {
|
||||
"path": "SodaLive/Sources/UI/Theme/Spacing.swift",
|
||||
"sha256": "372a73b821236c15449fa022cd628f61556f1381a16f234a4b77e833c2e588c1",
|
||||
"sameAsRetained": true
|
||||
},
|
||||
"ColorExtension.swift": {
|
||||
"path": "SodaLive/Sources/Extensions/ColorExtension.swift",
|
||||
"sha256": "747347d86ade888fe9c1d30629285b4a71991bc0f21189a0d4b76ad314c32364",
|
||||
"sameAsRetained": true
|
||||
}
|
||||
},
|
||||
"timer": "Unmodified production 5s Timer / 0.35s transition",
|
||||
"substitutions": "Retained local AppState routing sink and LanguageOption; no API/network; actual product font bundled",
|
||||
"root": "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/notice-title-native.8wiga1c7"
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import atexit
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import plistlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
repo = Path.cwd()
|
||||
evidence = Path(__file__).resolve().parent
|
||||
retained = evidence.parent / "native-fix/rerun-154222/source"
|
||||
root = Path(tempfile.mkdtemp(prefix="notice-title-native.", dir="/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode"))
|
||||
device = None
|
||||
launched_pid = None
|
||||
bundle = "local.sodalive.NoticeTitleOnlyQA"
|
||||
commands = []
|
||||
for key in ("TMPDIR", "TMP", "TEMP", "XDG_CACHE_HOME", "NODE_COMPILE_CACHE", "BUN_RUNTIME_TRANSPILER_CACHE_PATH", "CLANG_MODULE_CACHE_PATH", "SWIFT_MODULECACHE_PATH"):
|
||||
os.environ[key] = str(root / key)
|
||||
Path(os.environ[key]).mkdir()
|
||||
|
||||
|
||||
def run(args, timeout=120, check=True):
|
||||
result = subprocess.run(args, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
|
||||
commands.append({"argv": [str(value) for value in args], "exit": result.returncode, "output": result.stdout})
|
||||
(evidence / "commands.json").write_text(json.dumps(commands, indent=2))
|
||||
if check and result.returncode:
|
||||
raise RuntimeError(result.stdout)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def cleanup():
|
||||
receipt = {"root": str(root), "device": device, "parentRootUntouched": "notice-title-only.i9lFW3"}
|
||||
if device:
|
||||
run(["xcrun", "simctl", "terminate", device, bundle], check=False)
|
||||
run(["xcrun", "simctl", "shutdown", device], check=False)
|
||||
run(["xcrun", "simctl", "delete", device], check=False)
|
||||
devices = json.loads(run(["xcrun", "simctl", "list", "devices", "-j"]))
|
||||
receipt["deviceAbsent"] = all(item["udid"] != device for group in devices["devices"].values() for item in group)
|
||||
receipt["appPid"] = launched_pid
|
||||
receipt["processAbsent"] = launched_pid is None or not run(["ps", "-p", str(launched_pid), "-o", "pid=,command="], check=False)
|
||||
shutil.rmtree(root)
|
||||
receipt["rootAbsent"] = not root.exists()
|
||||
(evidence / "cleanup.json").write_text(json.dumps(receipt, indent=2))
|
||||
|
||||
|
||||
atexit.register(cleanup)
|
||||
source = root / "source"
|
||||
source.mkdir()
|
||||
product = repo / "SodaLive/Sources/MyPage/MyPageView.swift"
|
||||
text = product.read_text()
|
||||
banner = "import SwiftUI\n\n" + text[text.index("struct UpdateBannerView: View {"):text.index("// MARK: - Profile Section View")].rstrip() + "\n"
|
||||
(source / "UpdateBannerView.swift").write_text(banner)
|
||||
dependencies = {
|
||||
"MyPageNoticeRoller.swift": "SodaLive/Sources/V2/MyPage/Models/MyPageNoticeRoller.swift",
|
||||
"GetNoticeResponse.swift": "SodaLive/Sources/Settings/Notice/GetNoticeResponse.swift",
|
||||
"FontModifier.swift": "SodaLive/Sources/Extensions/FontModifier.swift",
|
||||
"Spacing.swift": "SodaLive/Sources/UI/Theme/Spacing.swift",
|
||||
"ColorExtension.swift": "SodaLive/Sources/Extensions/ColorExtension.swift",
|
||||
}
|
||||
for name, path in dependencies.items():
|
||||
shutil.copy2(repo / path, source / name)
|
||||
harness = (retained / "Harness.swift").read_text()
|
||||
scenario = ''' func run() {
|
||||
let first = NoticeItem(title: "첫 번째 공지 안내", content: "<p>첫 번째 전체 본문</p>", date: "2026-10-28")
|
||||
let second = NoticeItem(title: "두 번째 서비스 소식", content: "<p>두 번째 전체 본문</p>", date: "2026-10-27")
|
||||
let long = NoticeItem(title: "서비스 업데이트와 새로운 기능을 안내합니다 공지사항의 긴 한국어 제목을 끝까지 확인해 주세요", content: "<p>긴 제목</p>", date: "2026-10-25")
|
||||
set("two-title-only", items: [first, second])
|
||||
for (delay, name) in [(4.8, "two-rest"), (5.15, "two-mid"), (5.7, "two-end"), (9.8, "wrap-rest"), (10.15, "wrap-mid"), (10.7, "wrap-end")] {
|
||||
after(delay) { self.sample(name) }
|
||||
}
|
||||
after(12) {
|
||||
self.set("one-static", items: [first])
|
||||
self.after(0.7) { self.sample("one-rest") }
|
||||
self.after(6.7) { self.sample("one-after-6s") }
|
||||
}
|
||||
after(20) {
|
||||
self.set("long-title", items: [long])
|
||||
self.after(0.7) { self.sample("long-dark") }
|
||||
self.after(1.5) { self.window?.overrideUserInterfaceStyle = .light }
|
||||
self.after(2.3) { self.sample("long-light") }
|
||||
self.after(3) { self.record("title-only-finished") }
|
||||
}
|
||||
}
|
||||
'''
|
||||
harness = harness[:harness.index(" func run() {")] + scenario + harness[harness.index("\n}\n\n@main"):]
|
||||
(source / "Harness.swift").write_text(harness)
|
||||
shutil.copytree(source, evidence / "source", dirs_exist_ok=True)
|
||||
provenance = {"product": str(product.relative_to(repo)), "sha256": hashlib.sha256(product.read_bytes()).hexdigest(), "extractedBannerSha256": hashlib.sha256(banner.encode()).hexdigest(), "retainedHarness": str(retained.relative_to(repo) / "Harness.swift"), "harnessChange": "run() scenario only; rendering/sampling/root unchanged", "dependencies": {name: {"path": path, "sha256": hashlib.sha256((repo / path).read_bytes()).hexdigest(), "sameAsRetained": (repo / path).read_bytes() == (retained / name).read_bytes()} for name, path in dependencies.items()}, "timer": "Unmodified production 5s Timer / 0.35s transition", "substitutions": "Retained local AppState routing sink and LanguageOption; no API/network; actual product font bundled", "root": str(root)}
|
||||
(evidence / "provenance.json").write_text(json.dumps(provenance, indent=2))
|
||||
check = run(["bash", "work/tests/check-my-page-notices.sh"])
|
||||
(evidence / "focused-check.log").write_text(check + "\n")
|
||||
run(["xcrun", "swiftc", "-frontend", "-parse", str(product)])
|
||||
app = root / "NoticeNativeQA.app"
|
||||
app.mkdir()
|
||||
shutil.copy2(repo / "SodaLive/Resources/Font/Pretendard-Regular.otf", app / "Pretendard-Regular.otf")
|
||||
with (app / "Info.plist").open("wb") as handle:
|
||||
plistlib.dump({"CFBundleIdentifier": bundle, "CFBundleExecutable": "NoticeNativeQA", "CFBundleName": "NoticeNativeQA", "CFBundlePackageType": "APPL", "CFBundleVersion": "1", "CFBundleShortVersionString": "1", "MinimumOSVersion": "16.6", "LSRequiresIPhoneOS": True, "UIDeviceFamily": [1], "UILaunchScreen": {}, "UIAppFonts": ["Pretendard-Regular.otf"]}, handle)
|
||||
sdk = run(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"])
|
||||
run(["xcrun", "--sdk", "iphonesimulator", "swiftc", "-sdk", sdk, "-target", "x86_64-apple-ios16.6-simulator", "-module-cache-path", str(root / "module-cache"), *map(str, source.glob("*.swift")), "-o", str(app / "NoticeNativeQA")], timeout=180)
|
||||
device = run(["xcrun", "simctl", "create", "NoticeTitleOnly-Owned", "com.apple.CoreSimulator.SimDeviceType.iPhone-SE-3rd-generation", "com.apple.CoreSimulator.SimRuntime.iOS-18-3"])
|
||||
run(["xcrun", "simctl", "boot", device])
|
||||
for attempt in range(2):
|
||||
try:
|
||||
run(["xcrun", "simctl", "bootstatus", device, "-b"], timeout=240)
|
||||
break
|
||||
except subprocess.TimeoutExpired:
|
||||
commands.append({"event": "bootstatus-timeout", "attempt": attempt + 1})
|
||||
if attempt == 1:
|
||||
raise
|
||||
run(["xcrun", "simctl", "install", device, str(app)])
|
||||
launch = run(["xcrun", "simctl", "launch", device, bundle])
|
||||
launched_pid = int(launch.rsplit(":", 1)[1].strip())
|
||||
container = Path(run(["xcrun", "simctl", "get_app_container", device, bundle, "data"]))
|
||||
actions = container / "Documents/matrix/actions.json"
|
||||
deadline = time.monotonic() + 65
|
||||
while time.monotonic() < deadline:
|
||||
if actions.exists() and any(event["event"] == "title-only-finished" for event in json.loads(actions.read_text())):
|
||||
break
|
||||
time.sleep(1)
|
||||
else:
|
||||
raise RuntimeError("Native harness did not finish within 65 seconds: " + launch)
|
||||
shutil.copytree(actions.parent, evidence / "frames", dirs_exist_ok=True)
|
||||
assert hashlib.sha256(product.read_bytes()).hexdigest() == provenance["sha256"]
|
||||
print(check)
|
||||
print("Native title-only frames: " + str(evidence / "frames"))
|
||||
@@ -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,19 @@
|
||||
//
|
||||
// GetNoticeResponse.swift
|
||||
// SodaLive
|
||||
//
|
||||
// Created by klaus on 2023/08/10.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct GetNoticeResponse: Decodable {
|
||||
let totalCount: Int
|
||||
let noticeList: [NoticeItem]
|
||||
}
|
||||
|
||||
struct NoticeItem: Decodable, Hashable {
|
||||
let title: String
|
||||
let content: String
|
||||
let date: String
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
enum LanguageOption: String { case system, ko, en, ja }
|
||||
|
||||
enum AppStep { case noticeDetail(notice: NoticeItem) }
|
||||
|
||||
final class AppState {
|
||||
static let shared = AppState()
|
||||
var captures: [[String: String]] = []
|
||||
var phases: [[String: String]] = []
|
||||
func setAppStep(step: AppStep) {
|
||||
switch step {
|
||||
case .noticeDetail(let notice):
|
||||
captures.append(["title": notice.title, "content": notice.content, "date": notice.date])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PhaseProbe: View {
|
||||
@Environment(\.scenePhase) private var phase
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.onAppear { AppState.shared.phases.append(["event": "appear", "phase": String(describing: phase)]) }
|
||||
.onChange(of: phase) { value in AppState.shared.phases.append(["event": "change", "phase": String(describing: value)]) }
|
||||
}
|
||||
}
|
||||
|
||||
final class HarnessState: ObservableObject {
|
||||
@Published var items: [NoticeItem] = []
|
||||
@Published var mounted = true
|
||||
@Published var phase = ScenePhase.active
|
||||
@Published var scenario = "preparing"
|
||||
}
|
||||
|
||||
struct HarnessRoot: View {
|
||||
@ObservedObject var state: HarnessState
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("NATIVE QA HARNESS").font(.headline)
|
||||
Text(state.scenario).font(.caption.monospaced())
|
||||
Text("Actual UpdateBannerView below").font(.caption)
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(24)
|
||||
if state.mounted && !state.items.isEmpty {
|
||||
UpdateBannerView(items: state.items)
|
||||
.background(PhaseProbe())
|
||||
.environment(\.scenePhase, state.phase)
|
||||
}
|
||||
Text("Harness context / no network / no login")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(24)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(Color(hex: "131313"))
|
||||
.environment(\.locale, Locale(identifier: "ko_KR"))
|
||||
}
|
||||
}
|
||||
|
||||
final class HarnessDelegate: NSObject, UIApplicationDelegate {
|
||||
var window: UIWindow?
|
||||
let state = HarnessState()
|
||||
let started = ProcessInfo.processInfo.systemUptime
|
||||
var events: [[String: Any]] = []
|
||||
let output = URL.documentsDirectory.appendingPathComponent(CommandLine.arguments.contains("--lifecycle") ? "lifecycle" : "matrix", isDirectory: true)
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
UserDefaults.standard.set("ko", forKey: "app.language")
|
||||
try! FileManager.default.createDirectory(at: output, withIntermediateDirectories: true)
|
||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||
window.rootViewController = UIHostingController(rootView: HarnessRoot(state: state))
|
||||
window.overrideUserInterfaceStyle = .dark
|
||||
self.window = window
|
||||
window.makeKeyAndVisible()
|
||||
NotificationCenter.default.addObserver(forName: UIApplication.willResignActiveNotification, object: nil, queue: .main) { [weak self] _ in
|
||||
self?.state.phase = .inactive
|
||||
self?.record("application-willResignActive")
|
||||
}
|
||||
NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in
|
||||
self?.state.phase = .active
|
||||
self?.record("application-didBecomeActive")
|
||||
}
|
||||
after(1) { self.run() }
|
||||
return true
|
||||
}
|
||||
|
||||
func after(_ seconds: Double, _ action: @escaping () -> Void) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: action)
|
||||
}
|
||||
|
||||
func record(_ event: String, details: [String: Any] = [:]) {
|
||||
var entry = details
|
||||
entry["event"] = event
|
||||
entry["uptime"] = ProcessInfo.processInfo.systemUptime - started
|
||||
entry["scenario"] = state.scenario
|
||||
events.append(entry)
|
||||
let data = try! JSONSerialization.data(withJSONObject: events, options: [.prettyPrinted, .sortedKeys])
|
||||
try! data.write(to: output.appendingPathComponent("actions.json"))
|
||||
}
|
||||
|
||||
func elements() -> [NSObject] {
|
||||
var found: [NSObject] = []
|
||||
var visited = Set<ObjectIdentifier>()
|
||||
func visit(_ object: NSObject, depth: Int) {
|
||||
guard depth < 30, visited.insert(ObjectIdentifier(object)).inserted else { return }
|
||||
if object.isAccessibilityElement { found.append(object) }
|
||||
if let children = object.accessibilityElements {
|
||||
for child in children { if let child = child as? NSObject { visit(child, depth: depth + 1) } }
|
||||
}
|
||||
let count = object.accessibilityElementCount()
|
||||
if count > 0 && count < 1000 {
|
||||
for index in 0..<count {
|
||||
if let child = object.accessibilityElement(at: index) as? NSObject { visit(child, depth: depth + 1) }
|
||||
}
|
||||
}
|
||||
if let view = object as? UIView { for child in view.subviews { visit(child, depth: depth + 1) } }
|
||||
}
|
||||
if let window { visit(window, depth: 0) }
|
||||
return found
|
||||
}
|
||||
|
||||
func sample(_ name: String, activate: Bool = false, screenshot: Bool = true) {
|
||||
let nodes = elements()
|
||||
var details: [String: Any] = ["elements": nodes.map { node -> [String: Any] in
|
||||
let frame = node.accessibilityFrame
|
||||
return ["class": String(describing: type(of: node)), "label": node.accessibilityLabel ?? "", "button": node.accessibilityTraits.contains(.button), "x": frame.minX, "y": frame.minY, "width": frame.width, "height": frame.height]
|
||||
}]
|
||||
details["phaseProbe"] = AppState.shared.phases
|
||||
details["requestedPhase"] = String(describing: state.phase)
|
||||
if activate {
|
||||
let before = AppState.shared.captures.count
|
||||
let button = nodes.first { node in
|
||||
node.accessibilityTraits.contains(.button) && state.items.contains(where: { node.accessibilityLabel?.contains($0.title) == true })
|
||||
}
|
||||
details["accessibilityActivateResult"] = button?.accessibilityActivate() ?? false
|
||||
details["captureDelta"] = AppState.shared.captures.count - before
|
||||
details["sink"] = AppState.shared.captures
|
||||
}
|
||||
if screenshot, let window {
|
||||
let renderer = UIGraphicsImageRenderer(bounds: window.bounds)
|
||||
let image = renderer.image { _ in window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) }
|
||||
try! image.pngData()!.write(to: output.appendingPathComponent(name + ".png"))
|
||||
details["screenshot"] = name + ".png"
|
||||
}
|
||||
record(name, details: details)
|
||||
}
|
||||
|
||||
func set(_ scenario: String, items: [NoticeItem]) {
|
||||
state.scenario = scenario
|
||||
state.items = items
|
||||
state.phase = .active
|
||||
state.mounted = true
|
||||
record("scenario-start")
|
||||
}
|
||||
|
||||
func run() {
|
||||
let first = NoticeItem(title: "첫 번째 공지 안내", content: "<p>첫 번째 전체 본문</p>", date: "2026-10-28")
|
||||
let second = NoticeItem(title: "두 번째 서비스 소식", content: "<p>두 번째 전체 본문</p>", date: "2026-10-27")
|
||||
let long = NoticeItem(title: "서비스 업데이트와 새로운 기능을 안내합니다 공지사항의 긴 한국어 제목을 끝까지 확인해 주세요", content: "<p>긴 제목</p>", date: "2026-10-25")
|
||||
set("two-title-only", items: [first, second])
|
||||
for (delay, name) in [(4.8, "two-rest"), (5.15, "two-mid"), (5.7, "two-end"), (9.8, "wrap-rest"), (10.15, "wrap-mid"), (10.7, "wrap-end")] {
|
||||
after(delay) { self.sample(name) }
|
||||
}
|
||||
after(12) {
|
||||
self.set("one-static", items: [first])
|
||||
self.after(0.7) { self.sample("one-rest") }
|
||||
self.after(6.7) { self.sample("one-after-6s") }
|
||||
}
|
||||
after(20) {
|
||||
self.set("long-title", items: [long])
|
||||
self.after(0.7) { self.sample("long-dark") }
|
||||
self.after(1.5) { self.window?.overrideUserInterfaceStyle = .light }
|
||||
self.after(2.3) { self.sample("long-light") }
|
||||
self.after(3) { self.record("title-only-finished") }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@main
|
||||
struct NoticeQAEntry {
|
||||
static func main() { UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, nil, NSStringFromClass(HarnessDelegate.self)) }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
struct MyPageNoticeRoller {
|
||||
private var items: [NoticeItem] = []
|
||||
private(set) var index = 0
|
||||
|
||||
var current: NoticeItem? {
|
||||
items.isEmpty ? nil : items[index]
|
||||
}
|
||||
|
||||
mutating func update(_ items: [NoticeItem]) {
|
||||
guard self.items != items else { return }
|
||||
self.items = items
|
||||
index = 0
|
||||
}
|
||||
|
||||
mutating func advance() {
|
||||
guard items.count > 1 else { return }
|
||||
index = (index + 1) % items.count
|
||||
}
|
||||
|
||||
func shouldRoll(isVisible: Bool, isActive: Bool) -> Bool {
|
||||
items.count > 1 && isVisible && isActive
|
||||
}
|
||||
}
|
||||
@@ -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,87 @@
|
||||
import SwiftUI
|
||||
|
||||
struct UpdateBannerView: View {
|
||||
private static let foregroundColor = Color(hex: "B0BEC5")
|
||||
private static let rollInterval: TimeInterval = 5
|
||||
private static let transitionDuration: TimeInterval = 0.35
|
||||
|
||||
let items: [NoticeItem]
|
||||
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var roller = MyPageNoticeRoller()
|
||||
@State private var rollTimer: Timer?
|
||||
@State private var isVisible = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if let item = roller.current {
|
||||
Button {
|
||||
AppState.shared.setAppStep(step: .noticeDetail(notice: item))
|
||||
} label: {
|
||||
HStack(spacing: SodaSpacing.s8) {
|
||||
ZStack {
|
||||
Text(item.title)
|
||||
.appFont(.body3)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.id(roller.index)
|
||||
.transition(reduceMotion ? .identity : .asymmetric(
|
||||
insertion: .move(edge: .bottom),
|
||||
removal: .move(edge: .top)
|
||||
))
|
||||
}
|
||||
.clipped()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.frame(width: SodaSpacing.s24, height: SodaSpacing.s24)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.foregroundColor(Self.foregroundColor)
|
||||
.padding(.horizontal, SodaSpacing.s24)
|
||||
.padding(.vertical, SodaSpacing.s6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.clipped()
|
||||
.background(Color.black)
|
||||
.onAppear {
|
||||
isVisible = true
|
||||
roller.update(items)
|
||||
restartRollTimer(scenePhase: scenePhase, reduceMotion: reduceMotion)
|
||||
}
|
||||
.onDisappear {
|
||||
isVisible = false
|
||||
stopRollTimer()
|
||||
}
|
||||
.onChange(of: items) { items in
|
||||
roller.update(items)
|
||||
restartRollTimer(scenePhase: scenePhase, reduceMotion: reduceMotion)
|
||||
}
|
||||
.onChange(of: scenePhase) { scenePhase in
|
||||
restartRollTimer(scenePhase: scenePhase, reduceMotion: reduceMotion)
|
||||
}
|
||||
.onChange(of: reduceMotion) { reduceMotion in
|
||||
restartRollTimer(scenePhase: scenePhase, reduceMotion: reduceMotion)
|
||||
}
|
||||
}
|
||||
|
||||
private func restartRollTimer(scenePhase: ScenePhase, reduceMotion: Bool) {
|
||||
stopRollTimer()
|
||||
guard roller.shouldRoll(isVisible: isVisible, isActive: scenePhase == .active) else { return }
|
||||
|
||||
rollTimer = Timer.scheduledTimer(withTimeInterval: Self.rollInterval, repeats: true) { _ in
|
||||
withAnimation(reduceMotion ? nil : .easeOut(duration: Self.transitionDuration)) {
|
||||
roller.advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopRollTimer() {
|
||||
rollTimer?.invalidate()
|
||||
rollTimer = nil
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |