본문 바로가기
C.W.K.
Stream
Lesson 03 of 03 · published

시뮬레이터엔 펜이 없어

~16 min · xcuitest, xctest, ios-simulator, apple-pencil, testing-limits, libmypaint

Level 0빈 캔버스
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

빌드마다 지나는 게이트

Ariandel 빌드는 scripts/test-mobile.sh를 통과하기 전엔 TestFlight로 못 가. 이 스크립트는 세 가지를 순서대로 돌려. 키트 드리프트 검사(cwkSiblingKit에서 벤더링한 파일이 아직 키트랑 똑같은지), 엔진 스위트(Mac 자체 GPU 위의 swift test), 그리고 전용 시뮬레이터 "Ariandel QA iPad" 하나 위에서 도는 앱의 호스티드 XCTest와 XCUITest. 빌드 24 기준으로 소스에서 세면 엔진 테스트 210개, 호스티드 93개, UI 19개야. 시뮬레이터 id는 기본값이 없고 꼭 줘야 해. Mac 하나의 시뮬레이터 목록을 여러 앱 세션이 같이 쓰거든. 판정은 걸러진 파이프가 아니라 로그가 내려. 스크립트는 ** TEST SUCCEEDED **를 grep해. 걸러진 파이프는 테스트 실행이 아니라 필터의 상태를 보고하니까.

게이트가 가르쳐 준 것

시뮬레이터 하나로 빌드 스무 개 넘게 밀면 흉터가 남아. Ariandel은 그걸 하우스 사인의 Gotchas 칸에 모아 둬. 그중 여섯은 어느 iPad 앱이든 훔쳐 갈 만해.

증상원인해법
어느 순간부터 UI 단계마다 60초씩 기다려. 앱은 CPU 0%로 놀고 있고끌 수도 있는 뷰(붓 행, 갤러리 카드)에 붙은 컨텍스트 메뉴가 XCTest한테 애니메이션 끝났다는 말을 안 해. .onDrag도 똑같아UI 테스트를 ARIANDEL_NO_ANIMATIONS=1로 띄워서 UIKit 애니메이션을 꺼. 스위트가 1,984초에서 약 1,250초로 줄었어
초록불로 끝났는데 xcodebuild test가 10분을 멈춰 있어런타임 경고 하나가 기본값 -collect-test-diagnostics on-failure를 건드려. 경고는 Thread Performance Checker가 낸 "메인 스레드가 더 낮은 QoS 작업을 기다린다"였고, 여기선 메인에서 시작한 AVAssetWriter가 원인이야. 그 바람에 simctl diagnose가 돌고, 그게 600초에서 타임아웃 나우선순위 역전을 고쳐. -collect-test-diagnostics never는 반복 작업할 때만 써
새 테스트 하나가 타임랩스 테스트 뒤로 정렬되자 TestFlight 게이트에서 떨어졌어호스티드 테스트는 앱 안에서 돌고 앱의 UserDefaults를 같이 써. 한 테스트가 바꾼 설정이 다음 테스트와 UI 테스트로 새테스트용 스튜디오마다 자기 스위트를 줘(isolatedDefaults()). UI 테스트는 ARIANDEL_RESET_SETTINGS=1로 띄워
iPad 팝오버 안의 Form을 스와이프하면 "visible frame is empty"로 실패해XCUITest는 그 Form을 스와이프 못 해팝오버 자체 좌표로 드래그해(reveal). 행은 한 칸씩 짚어서 찾아. 스와이프 횟수를 박아 두지 말고
테스트가 필드에 입력했는데 값이 이상하게 들어가더블 탭으로 필드 내용 전체 선택이 안정적으로 안 돼오른쪽 정렬 필드는 끝을 탭하고, 지우고, 입력하고, 리턴으로 마무리해
컨트롤을 노린 탭이 화면 한가운데 떨어져꽉 채우는 .frame 뒤에 붙인 접근성 수식어는 프레임 전체를 가리키고, XCUITest는 그 한가운데를 탭해접근성 수식어는 .frame 앞, 컨트롤 쪽에 붙여

여섯 다 실패하는 모양이 같았어. 자기 이름을 말해 주는 에러가 아니라 느리거나 들쭉날쭉한 테스트로 나타났지. 해법 옆에 원인을 적어 둬야 다음 세션이 타임아웃 늘리기로 "고치는" 걸 막을 수 있어.

게이트가 못 보는 것

이제 정직해질 차례야. Ariandel 하우스 사인은 세 문장으로 말해. "The Simulator has no Apple Pencil pressure and no microphone. A green simulator run proves layout and logic, never feel or voice. Say what was exercised." 시뮬레이터엔 펜 압력도 마이크도 없고, 초록불은 레이아웃과 로직만 증명해. 느낌이나 목소리는 절대 아냐. 뭘 돌려 봤는지 말해. 다만 한 구절은 너무 세. 시뮬레이터도 오디오 입력은 받아. Mac의 마이크로. 시뮬레이터가 못 주는 건 iPad의 마이크, 스피커, 그 방의 소리야. 게이트도 음성 테이크는 한 번도 안 돌려. 그래서 목소리 증명은 여전히 iPad 몫이야. UI 테스트가 손가락으로 칠하는 것(ARIANDEL_FINGER_PAINTING=1)도 칠할 펜이 없어서야. 그래서 라운드마다 기록 행 끝에 Not exercised 목록이 붙고, 그걸 모으면 진짜 목록이 돼.

  • 펜 압력, 기울기, 호버의 느낌. 압력 곡선의 느낌. 안정화와 테이퍼의 느낌.
  • Apple Pencil Pro의 스퀴즈, 더블 탭, 배럴 롤 (제스처는 모델을 통해서만 시험했어).
  • 유리에 닿는 진짜 손바닥. 그러니까 손이 닿았을 때의 손바닥 거부.
  • TrueDepth가 있어야 하는 Face Paint, 그리고 카메라 자체.
  • 외부 디스플레이, 하드웨어 키보드, iPad 사이 AirDrop.
  • 펜 지연. 처음 엔진 평가가 "What was not checked"에 올려놓고, 증거는 첫 TestFlight 빌드와 함께 온다고 했어. 안 왔어. Ariandel 기록 행 어디에도 지연 얘기가 없어.

하드웨어가 증명한 건 전부 아빠 iPad에서 증명됐어. 피파가 말을 시작하자마자 음성 모드가 다 말한 걸로 받아들인 것(10월 1일. 세션 쿠키가 빠져서 음성 요청마다 401이 났어). 끝 신호가 안 온 터치 하나 때문에 펜이 죽어 버린 것(10월 3일). Generate의 Auto가 첫 획과 함께 꺼진 것(10월 4일). 셋 다 iPad가 먼저 찾았고, 보고서도 iPad를 증인으로 적어. 그렇다고 "시뮬레이터는 못 본다"로 읽진 마. 아빠가 Auto 버그를 짚자 라이브 UI 테스트가 시뮬레이터에서 그대로 재현했고, b14f113은 수정 옆에 라이브 Auto 테스트를 붙였어. 라이브 Ember 테스트를 청할 때만 도니까 기본 게이트는 여전히 건너뛰어. 그 버그는 시뮬레이터도 볼 수 있었어. 아무도 보라고 안 했을 뿐이야.

읽어야만 찾는 버그

세 번째 부류가 있고, 이게 제일 겸손해지게 만들어. 시뮬레이터도, 아마 아빠 손도 끝내 못 드러냈을 버그야. 이 퀘스트를 쓰던 2026-10-12에 4트랙이 C를 읽다가 둘을 찾았어. mypaint_brush_stroke_to는 barrel_rotation 인자를 받아서 안에서 360을 곱해. 그러니까 한 바퀴의 몇 분의 몇을 기대해. Ariandel은 도(degree)를 보내. 90° 롤이 0°로 도착해. viewrotation 인자는 libmypaint의 DEGREES()를 거치니까 라디안을 기대해. Ariandel은 여기도 도를 보내고, 캔버스를 90° 돌리면 116.62°로 도착해. 빌드 24 기준으로 둘 다 찾았고, 아직 안 고쳤어.

왜 아무것도 못 잡았는지 따라가 봐. 시뮬레이터엔 배럴이 없으니까 UI 테스트는 롤을 한 번도 안 만들어. 롤을 쓰는 엔진 테스트는 Liquify의 비틀기를 시험하는데, 그건 롤을 직접 읽고 제대로 돌아가. 붓에 롤을 먹이는 테스트는 하나도 없어. 엔진 테스트는 전부 뷰 회전 0에서 칠해. Ariandel 자체 변환의 단위 테스트는 통과해. Swift 쪽 값은 진짜로 90도니까. 하드웨어에서도 배럴 입력을 매핑한 붓이나, 돌린 캔버스에서 뷰에 따라 바뀌는 입력을 읽는 붓만 이상하게 굴 거야. 아빠는 그걸 "이 붓은 원래 이래"로 읽기 딱 좋아. 이 버그를 보는 도구는 경계를 건너는 테스트 하나뿐이야. 아는 값을 넣고, 받는 쪽이 그걸로 뭘 만들었는지 다시 읽어. 4트랙의 컴파일한 프로브가 벤더링된 엔진에 대고 정확히 그걸 해. 아래 스케치는 같은 생각을 작게 줄인 거야.

초록불은 자기가 돌려 본 것에 대한 주장이고, 딱 거기까지야. 안 돌려 본 목록도 돌려 본 목록만큼 꼼꼼하게 적어. 그리고 라이브러리 경계를 건너는 건 보내려던 값이 아니라 받는 쪽이 받은 값을 시험해.

Code

시뮬레이터의 한계가 앱과 만나는 곳: Studio.swift 원문 (실행 스위치 부분만)·swift
        // UI tests start from the standard settings, whatever earlier runs left behind.
        let environment = ProcessInfo.processInfo.environment
        settings = environment["ARIANDEL_RESET_SETTINGS"] == "1" ? StudioSettings.defaults : StudioSettings.load(from: defaults)
        if environment["ARIANDEL_RESET_SETTINGS"] == "1" { defaults.removeObject(forKey: GenerationSettings.key) }
        // UI tests run on a simulator, which has no Pencil.
        if environment["ARIANDEL_FINGER_PAINTING"] == "1" {
            settings.fingerPainting = true
        }
        // UI tests: a context menu on a draggable row (a brush, an artwork) never tells XCTest
        // its animations ended, and every later step waited 60 s for it; the app sat idle.
        if environment["ARIANDEL_NO_ANIMATIONS"] == "1" {
            UIView.setAnimationsEnabled(false)
        }
호스티드 테스트가 혼자 쓰는 설정: CanvasToolTests.swift의 isolatedDefaults() 원문·swift
    /// Settings a test keeps to itself: nothing it changes reaches the app's own defaults or the next test.
    private func isolatedDefaults() -> UserDefaults {
        let name = "ariandel-tests-\(UUID().uuidString)"
        addTeardownBlock { UserDefaults.standard.removePersistentDomain(forName: name) }
        return UserDefaults(suiteName: name)!
    }
최소 스케치 (swiftc로 컴파일해서 돌려 봄): 같은 롤을 Swift 쪽에서만, 그리고 libmypaint가 값을 쓰는 두 줄을 옮겨 적은 거울을 거쳐 경계 너머까지 시험·swift
import Foundation

// A minimal sketch: two tests of the same Pencil Pro roll. The first checks the
// Swift side alone, the way a hardware-free unit test can; the second carries the
// value across the boundary through a mirror of the two libmypaint lines that
// consume it (mypaint-brush.c: `barrel_rotation * 360`, then
// smallest_angular_difference from a freshly reset brush state of 0).

// Ariandel's conversion (StylusInput.swift, barrelRotation(for:)): radians -> 0...360 degrees.
func barrelRotation(roll: Double) -> Float {
    var degrees = (roll * 180 / .pi).truncatingRemainder(dividingBy: 360)
    if degrees < 0 { degrees += 360 }
    return Float(degrees)
}

// libmypaint's helpers.c, transcribed.
func modArith(_ a: Float, _ n: Float) -> Float { a - n * (a / n).rounded(.down) }
func smallestAngularDifference(_ a: Float, _ b: Float) -> Float {
    var d = modArith(b - a + 180, 360) - 180
    d += d > 180 ? -360 : d < -180 ? 360 : 0
    return d
}
// What the brush's barrel state becomes for one argument, from a reset state of 0.
func libmypaintBarrel(argument: Float) -> Float { smallestAngularDifference(0, argument * 360) }

let roll = 90 * Double.pi / 180            // a quarter turn of the barrel
let sent = barrelRotation(roll: roll)
print("swift side:  roll 90° -> sends \(sent)   test passes: \(abs(sent - 90) < 1e-3)")
let seen = libmypaintBarrel(argument: sent)
print("across:      libmypaint holds \(seen)°   test passes: \(abs(seen - 90) < 1e-3)")
let fixed = libmypaintBarrel(argument: sent / 360)
print("sent / 360:  libmypaint holds \(fixed)°  test passes: \(abs(fixed - 90) < 1e-3)")

// Prints:
// swift side:  roll 90° -> sends 90.0   test passes: true
// across:      libmypaint holds 0.0°   test passes: false
// sent / 360:  libmypaint holds 90.0°  test passes: true

External links

Exercise

네 코드에서 남이 짠 라이브러리한테 숫자를 넘기는 호출 하나를 골라 (각도, 시간, 크기, 비율). 라이브러리 안에서 그 값을 쓰는 줄을 찾아서 기대하는 단위를 적어. 그다음 아는 값을 경계 너머로 넘기고, 네 코드가 보낸 값 말고 라이브러리가 그걸로 뭘 했는지 다시 읽는 테스트를 하나 써. 마지막으로 지난 릴리스에 대해 세 줄짜리 "Not exercised" 목록을 써 봐. 테스트 환경이 물리적으로 만들어 낼 수 없는 것들로.
Hint
단위는 값을 쓰는 함수 윗부분의 곱셈이나 매크로에 숨어 있어 (* 360, * M_PI / 180, / 1000). 라이브러리가 내부 상태를 꺼내 볼 방법을 안 주면 눈에 보이는 효과를 대신 읽어. 그려진 픽셀, 타임스탬프, 로그에 찍힌 값. 안 돌려 본 목록은 시뮬레이터나 에뮬레이터, CI 장비에 없는 하드웨어부터 적기 시작해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.