Files
sodalive-ios/work/tests/SubBannerGestureHarness.swift
T

204 lines
9.1 KiB
Swift

import SwiftUI
import Kingfisher
enum LanguageOption: String { case system, ko, en, ja }
func recordHarnessEvent(_ event: String) {
let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let url = directory.appendingPathComponent("events.jsonl")
let data = try! JSONSerialization.data(withJSONObject: [
"event": event, "time": CACurrentMediaTime(), "pid": ProcessInfo.processInfo.processIdentifier,
"test": ProcessInfo.processInfo.environment["SB_TEST_NAME"] ?? "manual"
]) + Data([10])
if !FileManager.default.fileExists(atPath: url.path) { FileManager.default.createFile(atPath: url.path, contents: nil) }
let handle = try! FileHandle(forWritingTo: url)
handle.seekToEndOfFile()
handle.write(data)
try! handle.close()
}
final class FixtureProtocol: URLProtocol {
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet))
}
override func stopLoading() {}
}
@MainActor
final class FrameRecorder {
static let shared = FrameRecorder()
private var link: CADisplayLink?
private var end: CFTimeInterval = 0
private var directory: URL?
private var frames: [[String: Any]] = []
private var onTransition: (() -> Void)?
func start(_ name: String, duration: TimeInterval = 2.5, onTransition: (() -> Void)? = nil) {
stop()
self.onTransition = onTransition
recordHarnessEvent("record-\(name)")
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
directory = documents.appendingPathComponent(name)
try! FileManager.default.createDirectory(at: directory!, withIntermediateDirectories: true)
frames = []
end = CACurrentMediaTime() + duration
let displayLink = CADisplayLink(target: self, selector: #selector(capture))
displayLink.preferredFramesPerSecond = 30
displayLink.add(to: .main, forMode: .common)
link = displayLink
}
@objc private func capture() {
guard CACurrentMediaTime() < end else { stop(); return }
guard let window = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene })
.flatMap(\.windows).first(where: \.isKeyWindow), let directory else { return }
let format = UIGraphicsImageRendererFormat()
format.scale = 1
let image = UIGraphicsImageRenderer(bounds: window.bounds, format: format).image { _ in
window.drawHierarchy(in: window.bounds, afterScreenUpdates: false)
}
let filename = String(format: "%04d.png", frames.count)
try! image.pngData()!.write(to: directory.appendingPathComponent(filename))
frames.append(["file": filename, "time": CACurrentMediaTime()])
try! JSONSerialization.data(withJSONObject: frames, options: .prettyPrinted)
.write(to: directory.appendingPathComponent("frames.json"))
if onTransition != nil, hasBothEndpointImages(image) {
recordHarnessEvent("observed-mixed-\(directory.lastPathComponent)")
let action = onTransition
onTransition = nil
DispatchQueue.main.async { action?() }
}
}
private func hasBothEndpointImages(_ image: UIImage) -> Bool {
let row = image.cgImage!.cropping(to: CGRect(x: 0, y: 240, width: 375, height: 1))!
var pixels = [UInt8](repeating: 0, count: 375 * 4)
pixels.withUnsafeMutableBytes { buffer in
let context = CGContext(data: buffer.baseAddress, width: 375, height: 1, bitsPerComponent: 8,
bytesPerRow: 375 * 4, space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
context.draw(row, in: CGRect(x: 0, y: 0, width: 375, height: 1))
}
var red = 0
var green = 0
for offset in stride(from: 0, to: pixels.count, by: 4) {
if pixels[offset] > 200, pixels[offset + 1] < 100, pixels[offset + 2] < 100 { red += 1 }
if pixels[offset] < 100, pixels[offset + 1] > 150, pixels[offset + 2] < 150 { green += 1 }
}
return red > 5 && green > 5
}
private func stop() {
link?.invalidate()
link = nil
if let directory {
try! JSONSerialization.data(withJSONObject: frames, options: .prettyPrinted)
.write(to: directory.appendingPathComponent("frames.json"))
}
}
}
@main
struct SubBannerGestureApp: App {
init() {
recordHarnessEvent("launch")
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [FixtureProtocol.self]
ImageDownloader.default.sessionConfiguration = configuration
for index in 1...3 {
for mixed in [false, true] {
let size = CGSize(width: 600, height: mixed && index == 2 ? 200 : 300)
let image = UIGraphicsImageRenderer(size: size).image { context in
[UIColor.systemRed, .systemGreen, .systemBlue][index - 1].setFill()
context.fill(CGRect(origin: .zero, size: size))
UIColor.white.setFill()
context.fill(CGRect(x: 18, y: 0, width: 12, height: size.height))
context.fill(CGRect(x: size.width - 30, y: 0, width: 12, height: size.height))
("BANNER \(index)" as NSString).draw(at: CGPoint(x: 180, y: 80), withAttributes: [
.font: UIFont.boldSystemFont(ofSize: 36), .foregroundColor: UIColor.white
])
}
ImageCache.default.store(image, forKey: Self.url(index, mixed: mixed), toDisk: false)
}
}
}
static func url(_ index: Int, mixed: Bool) -> String {
"https://gesture-fixture.invalid/\(mixed ? "mixed" : "equal")/\(index).png"
}
var body: some Scene {
WindowGroup { GestureHarness() }
}
}
struct GestureHarness: View {
private let arguments = ProcessInfo.processInfo.arguments
@Environment(\.scenePhase) private var scenePhase
@State private var count = 0
@State private var lastID = 0
@State private var replaced = false
@State private var shown = true
private var active: Bool { arguments.contains("active") }
private var items: [SubBanner] {
if arguments.contains("empty") { return [] }
let amount = replaced || arguments.contains("single") ? 1 : (active ? 2 : 3)
return (1...amount).map { index in
SubBanner(id: replaced ? 9 : index, placement: .homeRecommendation,
imageUrl: SubBannerGestureApp.url(index, mixed: arguments.contains("mixed")),
webUrl: "https://gesture-fixture.invalid/link/\(index)",
androidDeepLink: nil, iosDeepLink: nil)
}
}
var body: some View {
VStack(spacing: SodaSpacing.s8) {
Text("taps=\(count);last=\(lastID)").accessibilityIdentifier("tap-status")
Text("Native touch regression")
HStack {
Button("Record") { FrameRecorder.shared.start("manual") }
Button("Forward") { FrameRecorder.shared.start("forward") }
Button("Replace") { recordHarnessEvent("replace"); replaced = true }
Button(shown ? "Hide" : "Show") { recordHarnessEvent(shown ? "hide" : "show"); shown.toggle() }
}
ScrollView {
VStack(spacing: SodaSpacing.s24) {
if shown {
SubBannerCarousel(items: items) { item in
count += 1
lastID = item.id
recordHarnessEvent("tap-\(item.id)")
}
.environment(\.scenePhase, active ? scenePhase : .inactive)
}
Text("추천 콘텐츠").accessibilityIdentifier("after-banner")
ForEach(0..<30) { index in
Text("Content row \(index)").frame(maxWidth: .infinity).frame(height: 44)
}
}
}
}
.appFont(.body3)
.background(Color.bg)
.foregroundColor(.white)
.preferredColorScheme(.dark)
.task(id: scenePhase) {
recordHarnessEvent("phase-\(scenePhase)")
guard active, scenePhase == .active, !arguments.contains("background") else { return }
do { try await Task.sleep(nanoseconds: 8_000_000_000) } catch { return }
let name = arguments.contains("replace-during-wrap") ? "replace" : (arguments.contains("hide-during-wrap") ? "hide" : "automatic")
if name == "automatic" {
FrameRecorder.shared.start(name, duration: 4)
} else {
FrameRecorder.shared.start(name, duration: 4) {
recordHarnessEvent(name)
if name == "replace" { replaced = true } else { shown = false }
}
}
}
}
}