Skip to content
C.W.K.
Stream
Lesson 03 of 03 · published

The Simulator Has No Pencil

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

Level 0Blank Canvas
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

The Gate Every Build Passes

No Ariandel build goes to TestFlight without passing scripts/test-mobile.sh first. It runs three things in order: the kit drift check (the files vendored from cwkSiblingKit still match the kit), the engine suite (swift test on the Mac's own GPU), and the app's hosted XCTest and XCUITest suites on one dedicated simulator, "Ariandel QA iPad". At build 24 that is 210 engine tests, 93 hosted tests and 19 UI tests, counted from the source. The simulator id is required, not defaulted, because several app sessions share one Mac's simulator list. And the verdict comes from the log, never from a filtered pipe: the script greps for ** TEST SUCCEEDED **, because a filtered pipe reports the filter's status, not the test run's.

What the Gate Taught

Twenty-odd builds through one simulator leave scar tissue, and Ariandel keeps it in its house sign's Gotchas section. Six of them are worth stealing for any iPad app:

SymptomCauseFix
Every UI step after a certain point waits 60 s, while the app sits idle at 0% CPUA context menu on a view that is also draggable (a brush row, a gallery card; .onDrag does the same) never tells XCTest its animations endedLaunch UI tests with ARIANDEL_NO_ANIMATIONS=1, which turns UIKit animations off. The suite went from 1,984 s to about 1,250 s
A green run, then xcodebuild test hangs ten minutesA runtime warning (the Thread Performance Checker: the main thread waiting on lower-QoS work, here an AVAssetWriter started on main) makes the default -collect-test-diagnostics on-failure run simctl diagnose, which times out at 600 sFix the priority inversion. Pass -collect-test-diagnostics never only while iterating
A new test failed the TestFlight gate once it sorted after the time-lapse testHosted tests run inside the app and share its UserDefaults, so one test's settings leak into the next and into the UI testsGive each test studio its own suite (isolatedDefaults()) and launch UI tests with ARIANDEL_RESET_SETTINGS=1
Swiping a Form inside an iPad popover fails with "visible frame is empty"XCUITest cannot swipe that FormDrag through the popover's own coordinates (reveal); reach rows by stepping, never by a fixed count of swipes
A test types into a field and the value comes out wrongA double tap does not reliably select a field's contentsTap a right-aligned field at its end, delete, type, finish with a return
A tap meant for a control lands in the middle of the screenAccessibility modifiers placed after a filling .frame name the whole frame, and XCUITest taps its middlePut accessibility modifiers on the control, before the .frame

Every one of these failed the same way: as a slow or flaky test, not as an error that names itself. Writing the cause down beside the fix is what keeps the next session from "fixing" it with a longer timeout.

What the Gate Cannot See

Now the honest part. Ariandel's house sign says it in three sentences: "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." One clause of that is too strong: the Simulator does take audio input, from the Mac's own microphone. What it can't give you is the iPad's microphone, speaker and room, and the gate never runs a voice take anyway, so voice stays the iPad's to prove. The UI tests paint with fingers (ARIANDEL_FINGER_PAINTING=1) because there is no Pencil to paint with. So every round's history row ends with a Not exercised list, and the lists add up to a real inventory:

  • Pencil pressure, tilt and hover feel; the pressure curve's feel; stabilization's and taper's feel.
  • Apple Pencil Pro's squeeze, double tap and barrel roll (gestures were tested only through the model).
  • A real palm on the glass, so palm rejection as a hand meets it.
  • Face Paint, which needs the TrueDepth camera, and the camera itself.
  • An external display, a hardware keyboard, AirDrop between iPads.
  • Pencil latency. The original engine evaluation listed it under "What was not checked" and said the evidence would arrive with the first TestFlight build. It has not: no Ariandel history row mentions latency at all.

What hardware did prove, it proved on Dad's iPad: voice mode treating Pippa as finished the moment she began to speak (a missing session cookie made every voice take a 401, 1 October), a Pencil left dead by a touch whose end never arrived (3 October), Generate's Auto mode dying with the first stroke (4 October). The iPad found all three first, and the reports name it as the witness. Don't read that as "the simulator couldn't", though. Once Dad named the Auto bug, a live UI test reproduced it on the simulator, and b14f113 added a live Auto test beside the fix. It runs only when live Ember tests are asked for, so the default gate still skips it. The simulator could see that bug. Nobody had asked it to look.

Bugs Only Reading Can Find

There is a third category, and it is the most humbling: bugs that neither the simulator nor, probably, Dad's hand would ever surface. Track 4 found two while this quest was being written, on 2026-10-12, by reading the C. mypaint_brush_stroke_to takes a barrel_rotation argument and multiplies it by 360 inside, so it expects a fraction of a turn. Ariandel sends degrees. A 90° roll arrives as 0°. Its viewrotation argument goes through libmypaint's DEGREES(), so it expects radians. Ariandel sends degrees again, and a 90° canvas turn arrives as 116.62°. As of build 24 both are found and not yet fixed.

Walk through why nothing caught them. The simulator has no barrel, so no UI test ever produces a roll. The engine tests that use a roll test Liquify's twirl, which reads the roll itself and is correct; none feeds a roll to a brush. Every engine test paints at view rotation 0. A unit test of Ariandel's own conversion passes, because the Swift value really is 90 degrees. And on hardware, only brushes that map the barrel input or read view-dependent inputs on a turned canvas would act strangely, which Dad could easily read as "that brush is just like that". The only instrument that sees this bug is a test that crosses the boundary: feed a known value in, read back what the callee made of it. Track 4's compiled probe does exactly that against the vendored engine. The sketch below is the same idea in miniature.

A green run is a claim about what it exercised, nothing more. Write the not-exercised list as carefully as the exercised one, and for anything that crosses a library boundary, test the value the callee receives, not the value you meant to send.

Code

Where the simulator's limits meet the app: Studio.swift, verbatim (trimmed to the launch switches)·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)
        }
Settings a hosted test keeps to itself: isolatedDefaults() in CanvasToolTests.swift, verbatim·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)!
    }
A minimal sketch (compiled with swiftc and run): the same roll tested on the Swift side alone and across the boundary, through a transcription of libmypaint's two consuming lines·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

Pick one call in your own code where you hand a number to a library you did not write (an angle, a duration, a size, a rate). Find the line inside the library that consumes it and write down the unit it expects. Then write one test that passes a known value across the boundary and reads back what the library did with it, not what your code sent. Finally, write a three-line "Not exercised" list for your last release: things your test environment physically cannot produce.
Hint
Units hide in multiplications and macros near the top of the consuming function (* 360, * M_PI / 180, / 1000). If the library exposes no getter for its internal state, read back an observable effect instead: a rendered pixel, a timestamp, a logged value. For the not-exercised list, start from the hardware your simulator, emulator or CI box does not have.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.