| 1 | import XCTest |
| 2 | |
| 3 | @_spi(Fixtures) @testable import CallsKit |
| 4 | |
| 5 | /// The warm rim (0.4.3, "T5, warm soft breath"): the four layers' |
| 6 | /// opacity and blur for a level, exactly the mock-up's box-shadow recipe; |
| 7 | /// its colours; when it is on; and Reduce Motion. |
| 8 | final class WarmRimTests: XCTestCase { |
| 9 | private func level(db: Float) -> Float { pow(10, db / 20) } |
| 10 | |
| 11 | func testTheFourLayersFollowTheLevel() { |
| 12 | for lvl in stride(from: 0.0, through: 1, by: 0.05) { |
| 13 | let rim = WarmRim(on: true, level: lvl) |
| 14 | XCTAssertEqual(rim.rimOpacity, 0.10 + lvl * 0.45, accuracy: 1e-12, "rim at \(lvl)") |
| 15 | XCTAssertEqual(rim.topOpacity, 0, "T5 has no top highlight") |
| 16 | XCTAssertEqual(rim.innerOpacity, 0.06 + lvl * 0.30, accuracy: 1e-12, "inner at \(lvl)") |
| 17 | XCTAssertEqual(rim.innerBlur, 3 + lvl * 6, accuracy: 1e-12) |
| 18 | XCTAssertEqual(rim.outerOpacity, lvl * 0.25, accuracy: 1e-12, "outer at \(lvl)") |
| 19 | XCTAssertEqual(rim.outerBlur, 1 + lvl * 7, accuracy: 1e-12) |
| 20 | } |
| 21 | // The ends, spelled out. |
| 22 | let quiet = WarmRim(on: true, level: 0) |
| 23 | XCTAssertEqual(quiet.rimOpacity, 0.10, accuracy: 1e-12) |
| 24 | XCTAssertEqual(quiet.innerOpacity, 0.06, accuracy: 1e-12) |
| 25 | XCTAssertEqual(quiet.innerBlur, 3) |
| 26 | XCTAssertEqual(quiet.outerOpacity, 0, "no breath until the voice rises") |
| 27 | XCTAssertEqual(quiet.outerBlur, 1) |
| 28 | let loud = WarmRim(on: true, level: 1) |
| 29 | XCTAssertEqual(loud.rimOpacity, 0.55, accuracy: 1e-12) |
| 30 | XCTAssertEqual(loud.innerOpacity, 0.36, accuracy: 1e-12) |
| 31 | XCTAssertEqual(loud.innerBlur, 9) |
| 32 | XCTAssertEqual(loud.outerOpacity, 0.25, accuracy: 1e-12) |
| 33 | XCTAssertEqual(loud.outerBlur, 8) |
| 34 | XCTAssertEqual(WarmRim.rimWidth, 1) |
| 35 | } |
| 36 | |
| 37 | func testOffIsNothingAtAnyLevel() { |
| 38 | for lvl in [0, 0.3, 1.0] { |
| 39 | let rim = WarmRim(on: false, level: lvl) |
| 40 | XCTAssertEqual(rim.rimOpacity, 0) |
| 41 | XCTAssertEqual(rim.innerOpacity, 0) |
| 42 | XCTAssertEqual(rim.outerOpacity, 0) |
| 43 | } |
| 44 | XCTAssertEqual(WarmRim.off, WarmRim(on: false, level: 0)) |
| 45 | } |
| 46 | |
| 47 | func testALevelOutOfRangeIsHeldToIt() { |
| 48 | XCTAssertEqual(WarmRim(on: true, level: 2), WarmRim(on: true, level: 1)) |
| 49 | XCTAssertEqual(WarmRim(on: true, level: -1), WarmRim(on: true, level: 0)) |
| 50 | XCTAssertEqual(WarmRim(on: true, level: .nan), WarmRim(on: true, level: 0)) |
| 51 | } |
| 52 | |
| 53 | func testTheColoursAreWarmWhite() { |
| 54 | // hsl(38°, 80 %, 82 / 72 / 66 %), as Chromium computes them. |
| 55 | func check(_ l: Double, _ r: Int, _ g: Int, _ b: Int) { |
| 56 | let c = WarmRim.rgb(hue: WarmRim.hue, saturation: WarmRim.saturation, lightness: l) |
| 57 | XCTAssertEqual(Int((c.red * 255).rounded()), r, "red at \(l)") |
| 58 | XCTAssertEqual(Int((c.green * 255).rounded()), g, "green at \(l)") |
| 59 | XCTAssertEqual(Int((c.blue * 255).rounded()), b, "blue at \(l)") |
| 60 | } |
| 61 | check(WarmRim.rimLightness, 246, 219, 172) |
| 62 | check(WarmRim.innerLightness, 241, 199, 126) |
| 63 | check(WarmRim.outerLightness, 238, 187, 99) |
| 64 | XCTAssertEqual(WarmRim.hue, 38) |
| 65 | XCTAssertEqual(WarmRim.saturation, 0.80) |
| 66 | } |
| 67 | |
| 68 | func testSpeechTurnsItOnAndItFadesOut() { |
| 69 | var glow = SpeakingGlow() |
| 70 | XCTAssertEqual(glow.rim(at: 0), .off, "silence: nothing") |
| 71 | let loud = level(db: -12) |
| 72 | glow.update(level: loud, speaking: true, now: 0) |
| 73 | XCTAssertFalse(glow.on(at: 0), "not before the onset") |
| 74 | glow.update(level: loud, speaking: true, now: 0.2) |
| 75 | XCTAssertTrue(glow.on(at: 0.2)) |
| 76 | // Lit, the level rises over the attack from nothing. |
| 77 | XCTAssertEqual(glow.rim(at: 0.2), WarmRim(on: true, level: 0)) |
| 78 | XCTAssertEqual(glow.rim(at: 0.2 + SpeakingGlow.attack).level, 1 - exp(-1), accuracy: 1e-6) |
| 79 | XCTAssertEqual(glow.rim(at: 5).level, 1, accuracy: 1e-6) |
| 80 | // Silence: still on while the level falls over the release, and off |
| 81 | // once it is under `outBelow` — not at once. |
| 82 | glow.update(level: 0, speaking: false, now: 5) |
| 83 | XCTAssertFalse(glow.lit) |
| 84 | XCTAssertTrue(glow.on(at: 5 + SpeakingGlow.release), "fading") |
| 85 | let out = 5 + SpeakingGlow.release * log(1 / SpeakingGlow.outBelow) |
| 86 | XCTAssertTrue(glow.on(at: out - 0.01)) |
| 87 | XCTAssertFalse(glow.on(at: out + 0.01)) |
| 88 | let gone = glow.rim(at: out + 0.01) |
| 89 | XCTAssertFalse(gone.on) |
| 90 | XCTAssertEqual([gone.rimOpacity, gone.innerOpacity, gone.outerOpacity], [0, 0, 0], "nothing drawn") |
| 91 | XCTAssertLessThan(out, glow.settles, "and the view is still redrawing when it goes off") |
| 92 | } |
| 93 | |
| 94 | func testReduceMotionIsASteadyRimAtPointSix() { |
| 95 | var glow = SpeakingGlow() |
| 96 | XCTAssertEqual(glow.rim(at: 0, reduceMotion: true), .off) |
| 97 | glow.update(level: level(db: -10), speaking: true, now: 0) |
| 98 | glow.update(level: level(db: -10), speaking: true, now: 0.2) |
| 99 | for (db, t) in [(-10, 0.3), (-35, 0.4), (-20, 0.5)] as [(Float, Double)] { |
| 100 | glow.update(level: level(db: db), speaking: true, now: t) |
| 101 | for dt in [0, 0.01, 0.1, 1.0] { |
| 102 | XCTAssertEqual(glow.rim(at: t + dt, reduceMotion: true), WarmRim(on: true, level: 0.6)) |
| 103 | } |
| 104 | } |
| 105 | XCTAssertEqual(SpeakingGlow.steady, 0.6) |
| 106 | // Out at once, with no fade. |
| 107 | glow.update(level: 0, speaking: false, now: 1) |
| 108 | XCTAssertEqual(glow.rim(at: 1, reduceMotion: true), .off) |
| 109 | } |
| 110 | |
| 111 | func testTheFixturesLevels() { |
| 112 | XCTAssertEqual(SpeakingGlow.fixture(level: level(db: FixtureSpeaking.silent.decibels)).rim(at: 0), .off) |
| 113 | let expected: [(FixtureSpeaking, Double)] = [(.low, 13.0 / 30), (.mid, 21.0 / 30), (.high, 1)] |
| 114 | for (fixture, lvl) in expected { |
| 115 | let rim = SpeakingGlow.fixture(level: level(db: fixture.decibels)).rim(at: 0) |
| 116 | XCTAssertTrue(rim.on, "\(fixture)") |
| 117 | XCTAssertEqual(rim.level, lvl, accuracy: 1e-5, "\(fixture)") |
| 118 | } |
| 119 | } |
| 120 | } |
| 121 | |