// test/brush_test.dart // // Pins the data-driven brush model (lib/editor/engine/brush.dart) against the // authoritative spec (docs/research/pen-brush-spec.md §1 + §4): // (a) each preset's KEY perfect_freehand params match the spec table; // (b) the quadratic pressure warp via PressureCurve(gamma:2) behaves (p=0→ // floor, p=1→1, p=0.5→~0.25 within the floor, monotonic); // (c) the four presets are DISTINCT (thinning + gamma differ), so a brush is // not merely a width re-label. import 'package:flutter_test/flutter_test.dart'; import 'package:badnote/editor/engine/brush.dart'; import 'package:badnote/editor/input/pressure_curve.dart'; void main() { group('(a) presets match the spec §4 tables', () { test('all four kinds have a preset', () { for (final k in BrushKind.values) { expect(kBrushPresets.containsKey(k), isTrue, reason: 'missing $k'); expect(brushProfileFor(k).kind, k); } }); test('fountain pen — Pow2 (p²), moderate thinning, no taper, solid', () { final b = brushProfileFor(BrushKind.fountainPen); expect(b.pressureGamma, 2.0); // rnote Pow2 / quadratic expect(b.pfThinning, 0.65); expect(b.pfStreamline, 0.4); expect(b.pfSmoothing, 0.5); expect(b.simulatePressure, isFalse); expect(b.taper, isFalse); expect(b.capStart, isTrue); expect(b.capEnd, isTrue); expect(b.opacity, 1.0); expect(b.blendMultiply, isFalse); }); test('ballpoint — linear, near-constant width (thinning 0.15)', () { final b = brushProfileFor(BrushKind.ballpoint); expect(b.pressureGamma, 1.0); // rnote Linear expect(b.pfThinning, 0.15); expect(b.pfStreamline, 0.55); expect(b.simulatePressure, isFalse); expect(b.taper, isFalse); }); test('highlighter — flat width (thinning 0), square caps, multiply', () { final b = brushProfileFor(BrushKind.highlighter); expect(b.pressureGamma, 1.0); expect(b.pfThinning, 0.0); // constant width expect(b.pfStreamline, 0.5); expect(b.pfSmoothing, 0.4); expect(b.capStart, isFalse); // square ends expect(b.capEnd, isFalse); expect(b.blendMultiply, isTrue); // marker build-up (applied later) expect(b.opacity, lessThan(1.0)); }); test('pencil — Sqrt (√p), moderate thinning, soft streamline', () { final b = brushProfileFor(BrushKind.pencil); expect(b.pressureGamma, 0.5); // rnote Sqrt / √p expect(b.pfThinning, 0.45); expect(b.pfStreamline, 0.35); expect(b.taper, isFalse); expect(b.blendMultiply, isFalse); }); }); group('(b) quadratic warp via PressureCurve(gamma:2)', () { test('p=0 → floor, p=1 → 1 (endpoints)', () { const c = PressureCurve(floor: 0.15, gamma: 2.0); expect(c.apply(0.0), closeTo(0.15, 1e-9)); expect(c.apply(1.0), closeTo(1.0, 1e-9)); }); test('p=0.5 → ~0.25 mapped into the floored range', () { // shaped = p² = 0.25; floored: floor + (1-floor)*0.25. const floor = 0.15; const c = PressureCurve(floor: floor, gamma: 2.0); expect(c.apply(0.5), closeTo(floor + (1 - floor) * 0.25, 1e-9)); // With a zero floor it is exactly the bare quadratic 0.25. expect(const PressureCurve(gamma: 2.0).apply(0.5), closeTo(0.25, 1e-9)); }); test('monotonic non-decreasing across [0,1]', () { const c = PressureCurve(floor: 0.15, gamma: 2.0); var prev = c.apply(0.0); for (var i = 1; i <= 20; i++) { final v = c.apply(i / 20); expect(v, greaterThanOrEqualTo(prev), reason: 'non-monotonic at $i/20'); prev = v; } }); test('quadratic stays below linear in the interior (steeper ramp)', () { // p² < p for 0 PressureCurve(gamma: brushProfileFor(k).pressureGamma).apply(0.5); final fountain = warp(BrushKind.fountainPen); // 0.25 final ballpoint = warp(BrushKind.ballpoint); // 0.5 final pencil = warp(BrushKind.pencil); // √0.5 ≈ 0.707 expect(fountain, lessThan(ballpoint)); expect(ballpoint, lessThan(pencil)); }); }); group('(c) presets are distinct (not a width re-label)', () { test('thinning differs across all four', () { final thinnings = BrushKind.values.map((k) => brushProfileFor(k).pfThinning).toSet(); // fountain 0.9, ballpoint 0.15, highlighter 0.0, pencil 0.5 ⇒ 4 distinct. expect(thinnings.length, BrushKind.values.length); }); test('pressure gamma differs (fountain p² vs pencil √p vs linear)', () { expect(brushProfileFor(BrushKind.fountainPen).pressureGamma, isNot(brushProfileFor(BrushKind.pencil).pressureGamma)); expect(brushProfileFor(BrushKind.fountainPen).pressureGamma, isNot(brushProfileFor(BrushKind.ballpoint).pressureGamma)); }); test('caps/taper: highlighter is square; fountain/ballpoint round no taper', () { expect(brushProfileFor(BrushKind.fountainPen).taper, isFalse); expect(brushProfileFor(BrushKind.highlighter).capStart, isFalse); expect(brushProfileFor(BrushKind.ballpoint).taper, isFalse); }); }); }