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

This commit is contained in:
Yu Sung
2026-10-30 12:19:58 +09:00
parent 7a42db66bc
commit 32ab7797e0
206 changed files with 21989 additions and 0 deletions
@@ -0,0 +1,160 @@
import SwiftUI
import Combine
enum LanguageOption: String { case system, ko, en, ja }
struct QAItem { let creatorId = 1; let postId = 1; let price = 1 }
struct RecommendationBannerResponse {}
struct AudioBannerResponse {}
struct FixtureResponse {
var subBanners: [SubBanner]
var eligibleSubBanners: [SubBanner] { subBanners.filter(\.isEligible) }
let lives: [QAItem] = [.init()]
let banners: [QAItem] = [.init()]
let recentlyActiveCreators: [QAItem] = [.init()]
let recentDebutCreators: [QAItem] = [.init()]
let aiCharacters: [QAItem] = []
let cheerCreators: [QAItem] = []
let popularCommunityPosts: [QAItem] = []
let originalSeries: [QAItem] = [.init()]
let newAndHotAudios: [QAItem] = []
let latestAudios: [QAItem] = []
let freeAudios: [QAItem] = []
let pointAudios: [QAItem] = []
let mostCommentedAudios: [QAItem] = []
let recommendedAudios: [QAItem] = []
}
final class MainHomeRecommendationViewModel: ObservableObject {
static weak var current: MainHomeRecommendationViewModel?
@Published var recommendations: FixtureResponse? = .init(subBanners: [])
@Published var isLoading = false
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isShowCommunityPostPurchaseDialog = false
var purchasingCommunityPostPrice = 0
init() { Self.current = self; recommendations = .init(subBanners: HarnessState.shared.items) }
func refresh() async {}
func fetchRecommendations() {}
func isFollowAllCompleted(_ key: String) -> Bool { false }
func isFollowAllLoading(_ key: String) -> Bool { false }
func followAll(creatorIds: [Int], completionKey: String) {}
func likeCommunityPost(postId: Int) {}
func openCommunityPostPurchase(postId: Int, price: Int) {}
func purchaseCommunityPost() {}
}
final class MainContentRecommendationViewModel: ObservableObject {
static weak var current: MainContentRecommendationViewModel?
@Published var recommendations: FixtureResponse? = .init(subBanners: [])
@Published var isLoading = false
@Published var isShowPopup = false
var errorMessage = ""
let hasLoaded = true
let shouldShowEmptyState = false
let emptyStateMessage = "QA empty"
init() { Self.current = self; recommendations = .init(subBanners: HarnessState.shared.items) }
func refresh() async {}
func fetchRecommendations() {}
}
final class CreatorCommunityMediaPlayerManager: ObservableObject {
static let shared = CreatorCommunityMediaPlayerManager()
@Published var isShowPopup = false
let errorMessage = ""
let isLoading = false
}
enum I18n {
enum MainContentRecommendation {
static let sodaLiveOnlySectionTitle = "QA exclusive series"
static let newAndHotSectionTitle = "QA new and hot"
static let latestAudioSectionTitle = "QA latest"
static let freeAudioSectionTitle = "QA free"
static let pointAudioSectionTitle = "QA point"
static let mostCommentedAudioSectionTitle = "QA comments"
static let recommendedAudioSectionTitle = "QA recommended"
}
}
struct Rectangles: PreferenceKey {
static var defaultValue: [String: CGRect] = [:]
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) { value.merge(nextValue(), uniquingKeysWith: { _, new in new }) }
}
extension View {
func probe(_ name: String) -> some View {
background(GeometryReader { proxy in Color.clear.preference(key: Rectangles.self, value: [name: proxy.frame(in: .global)]) })
}
func sodaToast(isPresented: Binding<Bool>, message: String, autohideIn: Double) -> some View { self }
}
struct QABar: View {
let name: String
var body: some View {
Text(name).appFont(.body3).foregroundColor(.white)
.frame(maxWidth: .infinity).frame(height: SodaSpacing.s48)
.background(Color.gray900).probe(name)
}
}
struct LottieRefreshableScrollView<Content: View>: View {
let action: () async -> Void
@ViewBuilder let content: () -> Content
var body: some View { ScrollView { content() } }
}
struct MainHomeLiveSection: View {
let items: [QAItem]; let onTapLive: (Int) -> Void; let onTapAll: () -> Void
var body: some View { QABar(name: "QA home live") }
}
struct MainHomeBannerSection: View {
let items: [QAItem]; let onTapBanner: (RecommendationBannerResponse) -> Void
var body: some View { QABar(name: "QA home main banner") }
}
struct MainHomeActiveCreatorSection: View {
let items: [QAItem]; let onTapLive: (Int) -> Void; let onTapCreator: (Int) -> Void
let onTapContent: (Int) -> Void; let onTapCommunityPost: (Int) -> Void
var body: some View { QABar(name: "QA active creators") }
}
struct MainHomeRecentDebutCreatorSection: View {
let items: [QAItem]; let onTapCreator: (Int) -> Void
var body: some View { QABar(name: "QA debut creators") }
}
struct MainHomeAiCharacterSection: View {
let items: [QAItem]; let onTapCharacter: (Int) -> Void
var body: some View { EmptyView() }
}
struct MainHomeCheerCreatorSection: View {
let items: [QAItem]; let isFollowAllCompleted: Bool; let isFollowAllLoading: Bool
let onTapCreator: (Int) -> Void; let onTapFollowAll: () -> Void
var body: some View { EmptyView() }
}
struct MainHomePopularCommunitySection: View {
let items: [QAItem]; let onTapCommunityPost: (Int) -> Void; let onTapLike: (Int) -> Void; let onTapPurchase: (QAItem) -> Void
var body: some View { EmptyView() }
}
struct MainHomeBusinessInfoSection: View { var body: some View { QABar(name: "QA other home content") } }
struct CommunityPostPurchaseDialog: View {
@Binding var isShowing: Bool; let can: Int; let confirmAction: () -> Void
var body: some View { EmptyView() }
}
struct LoadingView: View { var body: some View { ProgressView() } }
struct MainContentAudioBannerSection: View {
let items: [QAItem]; let onTapBanner: (AudioBannerResponse) -> Void
var body: some View { QABar(name: "QA content main banner") }
}
struct MainContentVoiceOnOnlySection: View {
let title: String; let items: [QAItem]; let action: () -> Void; let onTapSeries: (Int) -> Void
var body: some View { QABar(name: "QA exclusive series") }
}
struct MainContentAudioListCarouselSection: View {
let title: String; let items: [QAItem]; let action: () -> Void; let onTapContent: (Int) -> Void
var body: some View { QABar(name: "QA other content") }
}
struct MainContentAudioHorizontalCardSection: View {
let title: String; let items: [QAItem]; let action: () -> Void; let onTapContent: (Int) -> Void
var body: some View { EmptyView() }
}
struct MainContentMostCommentedAudioSection: View {
let title: String; let items: [QAItem]; let onTapContent: (Int) -> Void
var body: some View { EmptyView() }
}
struct MainContentRecommendedAudioGridSection: View {
let title: String; let items: [QAItem]; let onTapContent: (Int) -> Void
var body: some View { EmptyView() }
}
struct MainContentAudioEmptyStateView: View {
let message: String
var body: some View { Text(message) }
}
@@ -0,0 +1,207 @@
import SwiftUI
import UIKit
import Kingfisher
final class HarnessState: ObservableObject {
static let shared = HarnessState()
@Published var items: [SubBanner] = []
@Published var context = "home"
@Published var mounted = true
@Published var width: CGFloat = 375
@Published var phase = ScenePhase.active
@Published var reduceMotion = false
var frames: [String: CGRect] = [:]
var scenario = "preparing"
func update(_ items: [SubBanner]) {
self.items = items
MainHomeRecommendationViewModel.current?.recommendations = .init(subBanners: items)
MainContentRecommendationViewModel.current?.recommendations = .init(subBanners: items)
}
}
final class FixtureProtocol: URLProtocol {
static var requests: [[String: Any]] = []
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let url = request.url!
DispatchQueue.main.async {
Self.requests.append(["url": url.absoluteString, "uptime": ProcessInfo.processInfo.systemUptime])
}
guard url.host == "subbanner.qa.invalid" else {
client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet)); return
}
if url.path.contains("fail") {
client?.urlProtocol(self, didFailWithError: URLError(.cannotDecodeContentData)); return
}
let tall = url.path.contains("tall")
let size = CGSize(width: 1200, height: tall ? 600 : 400)
let format = UIGraphicsImageRendererFormat(); format.scale = 1
let image = UIGraphicsImageRenderer(size: size, format: format).image { context in
(tall ? UIColor.systemOrange : UIColor.systemTeal).setFill()
context.fill(CGRect(origin: .zero, size: size))
let title = tall ? "B 1200 x 600" : "A 1200 x 400"
title.draw(at: CGPoint(x: 64, y: 96), withAttributes: [.font: UIFont.boldSystemFont(ofSize: 70), .foregroundColor: UIColor.black])
url.lastPathComponent.draw(at: CGPoint(x: 64, y: 210), withAttributes: [.font: UIFont.systemFont(ofSize: 42), .foregroundColor: UIColor.black])
}
let deliver = {
self.client?.urlProtocol(self, didReceive: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "image/png"])!, cacheStoragePolicy: .notAllowed)
self.client?.urlProtocol(self, didLoad: image.pngData()!)
self.client?.urlProtocolDidFinishLoading(self)
}
if url.path.contains("delay") { DispatchQueue.main.asyncAfter(deadline: .now() + 8, execute: deliver) }
else { deliver() }
}
override func stopLoading() {}
}
struct PhaseProbe: View {
@Environment(\.scenePhase) private var phase
@Environment(\.accessibilityReduceMotion) private var reduce
var body: some View {
Color.clear.onAppear { HarnessDelegate.current?.record("environment", ["phase": String(describing: phase), "reduceMotion": reduce]) }
.onChange(of: phase) { HarnessDelegate.current?.record("phase", ["value": String(describing: $0)]) }
.onChange(of: reduce) { HarnessDelegate.current?.record("reduceMotion", ["value": $0]) }
}
}
struct HarnessRoot: View {
@ObservedObject var state = HarnessState.shared
var body: some View {
VStack(spacing: 0) {
Text("NATIVE QA / " + state.context).appFont(.caption2).foregroundColor(.white).frame(height: SodaSpacing.s24)
if state.mounted {
Group {
if state.context == "home" {
MainHomeRecommendationView(onTapLive: { _ in }, onTapCreator: { _ in }, onTapContent: { _ in }, onTapCharacter: { _ in }, onTapCommunityPost: { _ in }, onTapBanner: { _ in }, onTapLiveAll: {}, onTapFollowAll: { $0() })
} else if state.context == "content" {
MainContentRecommendationView(onTapContent: { _ in }, onTapBanner: { _ in }, onTapSeries: { _ in }, onTapLatestAudioAll: {}, onTapNewAndHotAll: {}, onTapSodaLiveOnlyAll: {}, onTapFreeAudioAll: {}, onTapPointAudioAll: {})
} else {
VStack(spacing: SodaSpacing.s24) {
QABar(name: "QA direct before")
if !state.items.isEmpty {
SubBannerCarousel(items: state.items) { item in HarnessDelegate.current?.record("carousel-onTap", ["id": item.id]) }.probe("carousel")
}
QABar(name: "QA direct after")
Spacer()
}
}
}
.frame(width: state.width)
.frame(maxWidth: .infinity, alignment: .leading)
.background(PhaseProbe())
.environment(\.scenePhase, state.phase)
}
Spacer(minLength: 0)
}
.background(Color.black)
.onPreferenceChange(Rectangles.self) { state.frames = $0 }
}
}
final class HarnessDelegate: NSObject, UIApplicationDelegate {
static weak var current: HarnessDelegate?
var window: UIWindow?
let state = HarnessState.shared
let started = ProcessInfo.processInfo.systemUptime
var events: [[String: Any]] = []
let output = URL.documentsDirectory.appendingPathComponent("native", isDirectory: true)
func application(_ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
Self.current = self
UserDefaults.standard.set("en", forKey: "app.language")
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [FixtureProtocol.self]
ImageDownloader.default.sessionConfiguration = config
try! FileManager.default.createDirectory(at: output, withIntermediateDirectories: true)
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = UIHostingController(rootView: HarnessRoot())
window.overrideUserInterfaceStyle = .dark
self.window = window
window.makeKeyAndVisible()
after(1) { Task { @MainActor in await self.run() } }
return true
}
func after(_ seconds: Double, _ action: @escaping () -> Void) { DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: action) }
@MainActor func wait(_ seconds: Double) async { try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) }
func record(_ event: String, _ details: [String: Any] = [:]) {
var entry = details
entry["event"] = event; entry["time"] = ProcessInfo.processInfo.systemUptime - started; entry["scenario"] = state.scenario
events.append(entry)
try! JSONSerialization.data(withJSONObject: events, options: [.prettyPrinted, .sortedKeys]).write(to: output.appendingPathComponent("actions.json"))
}
func sample(_ name: String) {
guard let window else { return }
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) }
try! image.pngData()!.write(to: output.appendingPathComponent(name + ".png"))
let frames = state.frames.mapValues { ["x": $0.minX, "y": $0.minY, "width": $0.width, "height": $0.height] }
record(name, ["frames": frames, "inputIDs": state.items.map(\.id), "width": state.width, "phase": String(describing: state.phase), "osReduceMotion": UIAccessibility.isReduceMotionEnabled, "requests": FixtureProtocol.requests, "png": name + ".png"])
}
func banner(_ id: Int, _ path: String, _ context: String = "home") -> SubBanner {
SubBanner(id: id, placement: context == "content" ? .contentRecommendation : .homeRecommendation, imageUrl: "https://subbanner.qa.invalid/" + path, webUrl: "https://subbanner.qa.invalid/destination", androidDeepLink: nil, iosDeepLink: "sodaqa://banner/\(id)")
}
@MainActor func setup(_ name: String, context: String, items: [SubBanner]) async {
state.mounted = false
await wait(0.15)
state.scenario = name; state.context = context; state.width = 375; state.phase = .active; state.reduceMotion = false
state.update(items); state.mounted = true
record("scenario-start", ["context": context, "count": items.count])
}
@MainActor func run() async {
for context in ["home", "content"] {
let pair = [banner(1, context + "-wide.png", context), banner(2, context + "-tall.png", context)]
await setup(context + "-pair", context: context, items: pair)
await wait(0.8); sample(context + "-first")
await wait(3.6); sample(context + "-before-5s")
await wait(0.75); sample(context + "-transition")
await wait(0.65); sample(context + "-second")
await wait(4.8); sample(context + "-wrap")
window?.overrideUserInterfaceStyle = .light
await wait(0.3); sample(context + "-light")
window?.overrideUserInterfaceStyle = .dark
state.update([]); await wait(0.4); sample(context + "-empty")
state.update([banner(3, context + "-single-wide.png", context)])
await wait(0.6); sample(context + "-one")
await wait(6); sample(context + "-one-after-6s")
state.width = 320; await wait(0.5); sample(context + "-resize-320")
state.width = 375; await wait(0.3)
}
await setup("delayed-current", context: "direct", items: [banner(10, "delay-wide.png"), banner(11, "valid-tall.png")])
await wait(0.6); sample("delayed-no-invented-height")
await wait(5.3); sample("delayed-next-valid")
await wait(2.7); sample("delayed-old-image-arrived")
await setup("failure-isolation", context: "content", items: [banner(20, "valid-wide.png"), banner(21, "fail.png")])
await wait(0.8); sample("failure-valid-first")
await wait(5); sample("failure-placeholder-other-content")
await wait(5); sample("failure-wrap-valid")
await setup("inactive-remount", context: "direct", items: [banner(30, "lifecycle-wide.png"), banner(31, "lifecycle-tall.png")])
await wait(5.8); sample("inactive-before")
state.phase = .inactive; record("inject-inactive")
await wait(0.3); sample("inactive-start")
await wait(6); sample("inactive-after-6s")
state.phase = .active; record("inject-active")
await wait(4.4); sample("active-before-5s")
await wait(1.3); sample("active-next")
state.mounted = false; record("unmount")
await wait(6); sample("unmounted-after-6s")
state.mounted = true; record("remount")
await wait(0.6); sample("remount-first")
await wait(5.1); sample("remount-next")
state.update([banner(32, "shrink-wide.png")]); await wait(0.6); sample("shrink-one")
await setup("reduced-motion", context: "direct", items: [banner(40, "reduce-wide.png"), banner(41, "reduce-tall.png")])
record("reduce-motion-observation-only", ["osValue": UIAccessibility.isReduceMotionEnabled, "enabledTest": "NOT RUN: read-only SwiftUI environment; parent device preferences unchanged"])
await wait(0.7); sample("reduce-first")
await wait(4.45); sample("reduce-transition")
await wait(0.7); sample("reduce-second")
await setup("twenty-one", context: "direct", items: (1...21).map { banner(100 + $0, "many-\($0)-wide.png") })
await wait(0.8); sample("many-first")
await wait(99.8); sample("many-twenty-first")
await wait(5); sample("many-wrap")
let target = banner(900, "action-wide.png")
var attempts: [String] = []
SubBannerLinkOpener.open(target) { url, completion in attempts.append(url.absoluteString); completion(false) }
record("opener-driver-not-physical-tap", ["attempts": attempts])
record("finished")
}
}
@main struct Entry {
static func main() { UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, nil, NSStringFromClass(HarnessDelegate.self)) }
}
@@ -0,0 +1,96 @@
import hashlib
import json
from pathlib import Path
from PIL import Image, ImageDraw
evidence = Path('docs/20261029_추천_서브_배너/reviews/evidence/native')
capture = evidence / 'captures'
events = json.loads((capture / 'actions.json').read_text())
samples = {event['event']: event for event in events if 'png' in event}
before = json.loads((evidence / 'source-hashes-before.json').read_text())
current = {path: hashlib.sha256(Path(path).read_bytes()).hexdigest() for path in before}
assert current == before, 'Product source changed; rerun native capture'
assert events[-1]['event'] == 'finished'
image_info = {}
for path in sorted(capture.glob('*.png')):
assert path.read_bytes().startswith(b'\x89PNG\r\n\x1a\n')
image = Image.open(path).convert('RGB')
assert image.size == (750, 1334)
assert path.stat().st_mtime > max(Path(source).stat().st_mtime for source in before)
image_info[path.name] = {'sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'width': image.width, 'height': image.height, 'fresh': True}
os_capture = evidence / 'native-os-final.png'
assert os_capture.read_bytes().startswith(b'\x89PNG\r\n\x1a\n')
assert Image.open(os_capture).size == (750, 1334)
assert os_capture.stat().st_mtime > max(Path(source).stat().st_mtime for source in before)
def frame(name, key):
return samples[name]['frames'][key]
def pixels(name):
image = Image.open(capture / (name + '.png')).convert('RGB')
rows = []
for position in range(image.height):
left, right = image.getpixel((0, position)), image.getpixel((image.width - 1, position))
def colored(value):
red, green, blue = value
return (blue > 140 and green > 110 and red < 100) or (red > 200 and 80 < green < 200 and blue < 60)
if colored(left) and colored(right):
rows.append(position)
if not rows:
return None
return {'x': 0, 'width': image.width / 2, 'y': min(rows) / 2, 'height': len(rows) / 2}
geometry = {}
for context, preceding, following in [('home', 'QA active creators', 'QA debut creators'), ('content', 'QA content main banner', 'QA exclusive series')]:
for suffix, expected in [('first', 125), ('second', 187.5)]:
name = context + '-' + suffix
previous = frame(name, preceding)
next_frame = frame(name, following)
image = pixels(name)
derived_height = next_frame['y'] - previous['y'] - previous['height'] - 48
assert derived_height == expected
assert image['height'] == expected and image['width'] == 375 and image['x'] == 0
assert image['y'] - previous['y'] - previous['height'] == 24
assert next_frame['y'] - image['y'] - image['height'] == 24
geometry[name] = {'pixelFramePt': image, 'precedingGapPt': 24, 'followingGapPt': 24, 'derivedHeightPt': derived_height}
empty = context + '-empty'
previous, next_frame = frame(empty, preceding), frame(empty, following)
assert next_frame['y'] - previous['y'] - previous['height'] == 24
assert pixels(empty) is None
geometry[empty] = {'remainingOrdinarySectionGapPt': 24, 'extraBannerGapPt': 0}
resized = pixels(context + '-resize-320')
image = Image.open(capture / (context + '-resize-320.png')).convert('RGB')
line = [image.getpixel((position, 600 if context == 'home' else 320)) for position in range(image.width)]
colored_positions = [position for position, (red, green, blue) in enumerate(line) if blue > 140 and green > 110 and red < 100]
assert min(colored_positions) == 0 and max(colored_positions) == 639
previous = frame(context + '-resize-320', preceding)
next_frame = frame(context + '-resize-320', following)
height = next_frame['y'] - previous['y'] - previous['height'] - 48
assert abs(height - 320 / 3) < 0.51
geometry[context + '-resize-320'] = {'pixelWidthPt': 320, 'geometryHeightPt': height, 'roundingTolerancePt': 0.51}
assert image_info[context + '-one.png']['sha256'] == image_info[context + '-one-after-6s.png']['sha256']
assert frame('delayed-no-invented-height', 'carousel')['height'] == 0
assert frame('delayed-next-valid', 'carousel')['height'] == 187.5
assert frame('delayed-old-image-arrived', 'carousel')['height'] == 187.5
assert image_info['inactive-start.png']['sha256'] == image_info['inactive-after-6s.png']['sha256']
assert len(samples['many-wrap']['inputIDs']) == 21
attempts = next(event['attempts'] for event in events if event['event'] == 'opener-driver-not-physical-tap')
assert attempts == ['sodaqa://banner/900', 'https://subbanner.qa.invalid/destination']
manifest = list(image_info)
for sheet_index in range(0, len(manifest), 12):
sheet = Image.new('RGB', (1000, 1422), 'white')
draw = ImageDraw.Draw(sheet)
for index, name in enumerate(manifest[sheet_index:sheet_index + 12]):
image = Image.open(capture / name).convert('RGB')
image.thumbnail((250, 445))
column, row = index % 4, index // 4
draw.text((column * 250 + 4, row * 474 + 4), name, fill='black')
sheet.paste(image, (column * 250, row * 474 + 25))
sheet.save(evidence / ('contact-' + str(sheet_index // 12 + 1) + '.png'))
result = {'nativeCaptureCount': len(image_info), 'additionalOSCapture': {'path': 'native-os-final.png', 'sha256': hashlib.sha256(os_capture.read_bytes()).hexdigest(), 'width': 750, 'height': 1334, 'fresh': True}, 'productHashesUnchanged': current == before, 'geometry': geometry, 'pngs': image_info, 'assertions': 'PASS', 'manualFilenameCorrection': {'many-twenty-first.png': 'Observed 20/21', 'many-wrap.png': 'Observed 21/21', 'native-os-final.png': 'Observed 01/21 after wrap'}, 'reduceMotionEnabledTest': 'NOT RUN; OS false; read-only environment; parent preferences unchanged', 'physicalTapAndSwipe': 'NOT RUN; no native coordinate input tool'}
(evidence / 'verification.json').write_text(json.dumps(result, indent=2))
(evidence / 'source-hashes-final.json').write_text(json.dumps(current, indent=2))
print(json.dumps({'nativeCaptures': len(image_info), 'geometry': geometry, 'assertions': 'PASS'}, indent=2))
@@ -0,0 +1,42 @@
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
evidence = Path('docs/20261029_추천_서브_배너/reviews/evidence/native')
ownership = json.loads((evidence / 'ownership.json').read_text())
root = Path(ownership['root'])
for key in ['TMPDIR', 'NODE_COMPILE_CACHE', 'BUN_RUNTIME_TRANSPILER_CACHE_PATH']:
os.environ[key] = str(root)
commands = []
def run(args):
result = subprocess.run(args, text=True, capture_output=True)
receipt = {'args': args, 'exit': result.returncode, 'stdout': result.stdout, 'stderr': result.stderr}
commands.append(receipt)
return receipt
device, bundle = ownership['device'], ownership['bundle']
assert root.name == 'sub-banner-native.tstn99'
assert bundle == 'qa.sodalive.subbanner.native.tstn99'
assert run(['xcrun', 'simctl', 'terminate', device, bundle])['exit'] == 0
assert run(['xcrun', 'simctl', 'uninstall', device, bundle])['exit'] == 0
missing = run(['xcrun', 'simctl', 'get_app_container', device, bundle, 'data'])
assert missing['exit'] != 0
process = run(['ps', '-p', str(ownership['pid']), '-o', 'pid=,comm='])
assert process['exit'] != 0
shutil.rmtree(root)
devices = json.loads(run(['xcrun', 'simctl', 'list', 'devices', 'booted', '-j'])['stdout'])
parent = next(item for items in devices['devices'].values() for item in items if item['udid'] == device)
assert parent['state'] == 'Booted'
before = json.loads((evidence / 'source-hashes-before.json').read_text())
current = {path: hashlib.sha256(Path(path).read_bytes()).hexdigest() for path in before}
assert current == before
products = Path('/Users/klaus/Library/Developer/Xcode/DerivedData/SodaLive-clzdtvqrsfonoxcijatzkrqdzrsu/Build/Products/Debug-iphonesimulator')
dependencies = ['Kingfisher.o', 'Kingfisher.swiftmodule/x86_64-apple-ios-simulator.swiftmodule']
dependency_hashes = {str(products / path): hashlib.sha256((products / path).read_bytes()).hexdigest() for path in dependencies}
(evidence / 'dependency-hashes.json').write_text(json.dumps(dependency_hashes, indent=2))
receipt = {'commands': commands, 'appAbsent': True, 'pidAbsent': True, 'rootAbsent': not root.exists(), 'parentDeviceStillBooted': True, 'productHashesUnchanged': current == before, 'sharedCacheDeleted': False}
(evidence / 'cleanup.json').write_text(json.dumps(receipt, indent=2))
print(json.dumps({key: value for key, value in receipt.items() if key != 'commands'}, indent=2))
@@ -0,0 +1,68 @@
import hashlib
import json
import os
from pathlib import Path
import plistlib
import shutil
import subprocess
import time
repo = Path.cwd()
evidence = repo / 'docs/20261029_추천_서브_배너/reviews/evidence/native'
root = Path('/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/sub-banner-native.tstn99')
for key in ['TMPDIR', 'NODE_COMPILE_CACHE', 'BUN_RUNTIME_TRANSPILER_CACHE_PATH']:
os.environ[key] = str(root)
device = '482F7827-2E3A-4559-BAF4-48B68CC5725B'
bundle = 'qa.sodalive.subbanner.native.tstn99'
products = Path('/Users/klaus/Library/Developer/Xcode/DerivedData/SodaLive-clzdtvqrsfonoxcijatzkrqdzrsu/Build/Products/Debug-iphonesimulator')
sources = [
'SodaLive/Sources/V2/Component/Banner/SubBanner.swift',
'SodaLive/Sources/V2/Component/Banner/SubBannerLinkOpener.swift',
'SodaLive/Sources/V2/Component/Banner/SubBannerCarousel.swift',
'SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift',
'SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift',
'SodaLive/Sources/UI/Theme/Spacing.swift',
'SodaLive/Sources/UI/Theme/Color.swift',
'SodaLive/Sources/Extensions/ColorExtension.swift',
'SodaLive/Sources/Extensions/FontModifier.swift',
]
previous = evidence / 'commands.json'
commands = json.loads(previous.read_text()) if previous.exists() else []
def run(args, timeout=120):
result = subprocess.run([str(arg) for arg in args], text=True, capture_output=True, timeout=timeout)
commands.append({'args': [str(arg) for arg in args], 'exit': result.returncode, 'stdout': result.stdout, 'stderr': result.stderr})
(evidence / 'commands.json').write_text(json.dumps(commands, indent=2))
print(result.stdout, result.stderr, flush=True)
result.check_returncode()
return result.stdout.strip()
hashes = {path: hashlib.sha256((repo / path).read_bytes()).hexdigest() for path in sources}
(evidence / 'source-hashes-before.json').write_text(json.dumps(hashes, indent=2))
app = root / 'SubBannerQA.app'
app.mkdir(exist_ok=True)
with (app / 'Info.plist').open('wb') as stream:
plistlib.dump({'CFBundleIdentifier': bundle, 'CFBundleName': 'SubBannerQA', 'CFBundleExecutable': 'SubBannerQA', 'CFBundlePackageType': 'APPL', 'CFBundleVersion': '1', 'CFBundleShortVersionString': '1', 'MinimumOSVersion': '16.6', 'LSRequiresIPhoneOS': True, 'UIDeviceFamily': [1], 'UILaunchScreen': {}}, stream)
sdk = run(['xcrun', '--sdk', 'iphonesimulator', '--show-sdk-path'])
run(['xcrun', '--sdk', 'iphonesimulator', 'swiftc', '-swift-version', '5', '-target', 'x86_64-apple-ios16.6-simulator', '-sdk', sdk, '-I', products, '-module-cache-path', root / 'module-cache', *sources, evidence / 'source/Doubles.swift', evidence / 'source/Harness.swift', products / 'Kingfisher.o', '-o', app / 'SubBannerQA'], timeout=180)
run(['xcrun', 'simctl', 'install', device, app])
launch = run(['xcrun', 'simctl', 'launch', device, bundle])
(evidence / 'ownership.json').write_text(json.dumps({'root': str(root), 'bundle': bundle, 'pid': int(launch.split(':')[-1]), 'device': device, 'deviceOwner': 'parent'}, indent=2))
container = Path(run(['xcrun', 'simctl', 'get_app_container', device, bundle, 'data']))
native = container / 'Documents/native'
deadline = time.monotonic() + 330
while time.monotonic() < deadline:
time.sleep(5)
action_file = native / 'actions.json'
if action_file.exists():
events = json.loads(action_file.read_text())
print(events[-1]['event'], events[-1]['time'], flush=True)
if events[-1]['event'] == 'finished':
break
else:
print('TIMEOUT; preserving partial output', flush=True)
shutil.copytree(native, evidence / 'captures', dirs_exist_ok=True)
after = {path: hashlib.sha256((repo / path).read_bytes()).hexdigest() for path in sources}
(evidence / 'source-hashes-after.json').write_text(json.dumps(after, indent=2))
(evidence / 'source-stability.json').write_text(json.dumps({'unchanged': hashes == after, 'changed': [path for path in sources if hashes[path] != after[path]]}, indent=2))
run(['xcrun', 'simctl', 'io', device, 'screenshot', evidence / 'native-os-final.png'])
print('CAPTURES COPIED; OWN APP STILL INSTALLED UNTIL ASSESSMENT', flush=True)