204 lines
9.1 KiB
Swift
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 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|