docs(recommendation): 서브 배너 리뷰와 QA 증거를 보존한다
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user