The Garage SDKs, in the open

garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of
Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme,
docs under docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
This commit is contained in:
ImBenji
2026-09-23 18:49:21 +01:00
co-authored by Claude Opus 5.5
commit b269201919
117 changed files with 26944 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
import "package:flutter/rendering.dart";
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// A centred label sat in an Expanded, so it centred in the space LEFT OVER
// after a leading icon rather than across the button - one icon threw the text
// off centre by half its own width plus the gap.
void main() {
Widget host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(child: SizedBox(width: 300, child: child)),
),
);
Future<double> offset(WidgetTester t, Widget b) async {
await t.pumpWidget(host(b));
return t.getRect(find.text("Label")).center.dx -
t.getRect(find.byType(Button)).center.dx;
}
Button btn({Widget? leading, Widget? trailing, AlignmentGeometry? alignment}) =>
Button(
style: const ButtonStyle.secondary(),
alignment: alignment,
leading: leading,
trailing: trailing,
onPressed: () {},
child: const Text("Label"),
);
const icon = Icon(LucideIcons.mail);
_fieldCentring();
_labelNeverWraps();
testWidgets("centred label is centred with no icons", (t) async {
expect(await offset(t, btn(alignment: Alignment.center)), 0.0);
});
testWidgets("centred label is centred with a leading icon", (t) async {
expect(await offset(t, btn(alignment: Alignment.center, leading: icon)), 0.0);
});
testWidgets("centred label is centred with a trailing icon", (t) async {
expect(await offset(t, btn(alignment: Alignment.center, trailing: icon)), 0.0);
});
testWidgets("centred label is centred with both", (t) async {
expect(
await offset(t,
btn(alignment: Alignment.center, leading: icon, trailing: icon)),
0.0);
});
testWidgets("no alignment means no balancing - the button still shrink-wraps",
(t) async {
// without an alignment there is no centre to miss, and padding the empty
// side out would only make the button wider.
// unconstrained, so the button reports its own intrinsic width
Widget loose(Widget c) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(child: c),
),
);
await t.pumpWidget(loose(btn(leading: icon)));
final unaligned = t.getSize(find.byType(Button)).width;
await t.pumpWidget(loose(btn(alignment: Alignment.center, leading: icon)));
final aligned = t.getSize(find.byType(Button)).width;
// the balanced one is wider by exactly the mirrored icon plus its gap
expect(aligned, greaterThan(unaligned));
expect(aligned - unaligned,
const Density().iconSize + const Density().controlGap);
});
testWidgets("an icon button with a leading icon isnt balanced out", (t) async {
// there is no label in here to sit off centre - both slots are glyphs - so
// the mirror spacer is 15px of nothing. the snap targets button in the
// blender header measured 52 wide for 38 of content because of it.
Widget loose(Widget c) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(child: c),
),
);
await t.pumpWidget(loose(IconButton.outline(
density: ControlDensity.compact,
leading: Icon(LucideIcons.ruler_dimension_line).iconSmall,
icon: Icon(LucideIcons.chevron_down).iconSmall,
onPressed: () {},
)));
const d = Density();
final pad = ControlDensity.compact
.resolveIcon(ThemeData(colorScheme: testScheme, density: d));
expect(
t.getSize(find.byType(IconButton)).width,
pad.horizontal + d.iconSize * 2 + d.controlGap,
);
});
}
// The same defect in TextField: centred text sits in an Expanded, so a leading
// feature pushed it off the field's centre by half that feature plus the gap.
void _fieldCentring() {
Widget host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(child: SizedBox(width: 300, child: child)),
),
);
testWidgets("centred field text is centred with a leading feature",
(t) async {
await t.pumpWidget(host(const TextField(
initialValue: "abc",
readOnly: true,
enabled: false,
textAlign: TextAlign.center,
features: [InputFeature.leading(Icon(LucideIcons.mail))],
)));
final field = t.getRect(find.byType(TextField));
final text = t.getRect(find.text("abc"));
expect((text.center.dx - field.center.dx).abs(), lessThan(0.5));
});
testWidgets("balancing uses a spacer, not a second icon", (t) async {
// a copy of the feature would sit in the tree twice, which is how this
// broke a test that looked for exactly one icon inside a field.
await t.pumpWidget(host(const TextField(
initialValue: "abc",
readOnly: true,
enabled: false,
textAlign: TextAlign.center,
features: [InputFeature.leading(Icon(LucideIcons.mail))],
)));
expect(
find.descendant(
of: find.byType(TextField), matching: find.byIcon(LucideIcons.mail)),
findsOneWidget,
);
});
}
// A leading icon takes width off the label's allowance; Expanded forces the
// label to absorb it, and Text used to resolve that by wrapping - "Use" on one
// line and the rest on a second the button's height hid. It reads as a
// truncation but never was one.
void _labelNeverWraps() {
Widget host(Widget child, double cap) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(child: SizedBox(width: cap, child: Wrap(children: [child]))),
),
);
for (final cap in [400.0, 130.0, 90.0]) {
testWidgets("label stays on one line at width $cap", (t) async {
await t.pumpWidget(host(
Button(
style: const ButtonStyle.primary(),
leading: const Icon(LucideIcons.mail),
onPressed: () {},
child: const Text("Use passkey"),
),
cap,
));
final rp = t.renderObject(find.text("Use passkey")) as RenderParagraph;
expect(rp.size.height, const Density().lineBox,
reason: "a wrapped label is twice this tall");
});
}
}
+105
View File
@@ -0,0 +1,105 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// A segmented control lives in a PropertyRow: bounded WIDTH, unbounded height.
// That combination is the whole point of these tests - `expands: true` drops
// the IntrinsicHeight that gives `stretch` something to stretch to, and the
// buttons collapse to nothing.
Widget _host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(width: 300, child: child),
),
),
);
Widget _seg(String label) => Button(
style: const ButtonStyle.outline(),
alignment: Alignment.center,
onPressed: () {},
child: Text(label),
);
void main() {
testWidgets("fill splits the main axis evenly", (tester) async {
await tester.pumpWidget(
_host(
ButtonGroup.horizontal(
fill: true,
children: [_seg("Off"), _seg("Optional"), _seg("Required")],
),
),
);
expect(tester.takeException(), isNull);
final off = tester.getSize(find.widgetWithText(Button, "Off"));
final optional = tester.getSize(find.widgetWithText(Button, "Optional"));
final required = tester.getSize(find.widgetWithText(Button, "Required"));
// an even share of the 300, not a share of the label lengths
expect(off.width, closeTo(100, 1));
expect(optional.width, closeTo(100, 1));
expect(required.width, closeTo(100, 1));
// and a real height, which is what collapsed before
expect(off.height, greaterThan(0));
});
testWidgets("it survives a narrow field without overflowing", (
tester,
) async {
// a phone-width property row. Three segments that each want ~90px of
// label in 200px is where a fill would blow the right edge off.
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 200,
child: ButtonGroup.horizontal(
fill: true,
children: [_seg("Off"), _seg("Optional"), _seg("Required")],
),
),
),
),
),
);
expect(tester.takeException(), isNull);
final group = tester.getSize(find.byType(ButtonGroup));
expect(group.width, closeTo(200, 1));
for (final label in ["Off", "Optional", "Required"]) {
final seg = tester.getSize(find.widgetWithText(Button, label));
expect(seg.width, closeTo(200 / 3, 1), reason: label);
expect(seg.height, greaterThan(0), reason: label);
}
});
testWidgets("without fill it still shrink wraps to its labels", (
tester,
) async {
await tester.pumpWidget(
_host(
ButtonGroup.horizontal(
children: [_seg("Off"), _seg("Optional"), _seg("Required")],
),
),
);
expect(tester.takeException(), isNull);
final off = tester.getSize(find.widgetWithText(Button, "Off"));
final required = tester.getSize(find.widgetWithText(Button, "Required"));
expect(off.width, lessThan(required.width));
});
}
@@ -0,0 +1,80 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// ChromeBar's height was a hardcoded 30 that didnt move with the tier. At
// normal that left a 27px control 1.5px of margin, and at product the bar came
// out SHORTER than the 33px buttons meant to sit in it - a header its own
// contents didnt fit. It takes the density's token now.
Future<double> _barHeight(WidgetTester tester, Density density) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: density),
child: const Align(
alignment: Alignment.topCenter,
child: ChromeBar(child: SizedBox.shrink()),
),
),
),
);
await tester.pump();
return tester.getSize(find.byType(ChromeBar)).height;
}
void main() {
for (final density in const [
Density(),
Density.normal(),
Density.product(),
]) {
final label = "${density.controlHeight.toInt()}px controls";
test("a chrome bar clears its own controls ($label)", () {
// one controlGap above and one below - the same air a button gets from
// the button beside it
expect(
density.chromeBarHeight,
density.controlHeight + density.controlGap * 2,
);
// the thing that was actually broken: the bar has to be able to hold
// the control, not merely be near its size
expect(
density.chromeBarHeight,
greaterThan(density.controlHeight),
reason: "a control wouldnt fit inside its own chrome",
);
});
testWidgets("ChromeBar takes that height with none given ($label)", (
tester,
) async {
expect(await _barHeight(tester, density), density.chromeBarHeight);
});
}
testWidgets("an explicit height still wins", (tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(
colorScheme: testScheme,
density: const Density.product(),
),
child: const Align(
alignment: Alignment.topCenter,
child: ChromeBar(height: 52, child: SizedBox.shrink()),
),
),
),
);
await tester.pump();
expect(tester.getSize(find.byType(ChromeBar)).height, 52);
});
}
@@ -0,0 +1,43 @@
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/theme/theme_data.dart";
// The gap scale is derived from controlGap, and the claim that makes that the
// right base is that two tokens which were authored by hand long before the
// scale existed land exactly on steps of it. If someone retunes controlGap,
// containerGap or containerPadding and that stops being true, the base unit
// has drifted and the scale is lying about where it comes from.
void main() {
for (final density in const [Density(), Density.normal()]) {
final name = density.control.name;
test("$name: gapMd is containerGap, gapXl is containerPadding", () {
expect(density.gapMd, density.containerGap);
expect(density.gapXl, density.containerPadding);
});
test("$name: the scale climbs and stays on whole pixels", () {
final steps = [
density.gapXxs,
density.gapXs,
density.gapSm,
density.gapMd,
density.gapLg,
density.gapXl,
density.gapXxl,
];
for (var i = 1; i < steps.length; i++) {
expect(steps[i], greaterThan(steps[i - 1]),
reason: "step $i should be bigger than the one before it");
}
for (final s in steps) {
expect(s, s.roundToDouble(), reason: "$s is not a whole pixel");
}
});
}
test("compact lands on the sizes the style guide observed in real use", () {
const d = Density();
expect([d.gapXxs, d.gapXs, d.gapSm, d.gapMd, d.gapLg, d.gapXl, d.gapXxl],
[2.0, 4.0, 6.0, 8.0, 12.0, 16.0, 24.0]);
});
}
+131
View File
@@ -0,0 +1,131 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
Widget _host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme),
child: Center(child: SizedBox(width: 400, child: child)),
),
);
Widget _row({String? error}) => _host(
SettingsRow(
key: const Key("row"),
label: "Name",
error: error,
field: const TextField(),
),
);
void main() {
testWidgets("the label's aside renders beside it, not instead of it", (
tester,
) async {
await tester.pumpWidget(
_host(
const SettingsRow(
label: "Name",
action: Text("Required"),
field: TextField(),
),
),
);
expect(find.text("Name"), findsOneWidget);
expect(find.text("Required"), findsOneWidget);
// on the label's line, and at the FAR right of its half of the row -
// spaceBetween, the way ProductField does it in the onboarding flow.
final label = tester.getRect(find.text("Name"));
final tag = tester.getRect(find.text("Required"));
final field = tester.getRect(find.byType(TextField));
expect((tag.center.dy - label.center.dy).abs(), lessThan(2));
// tucked against the label rather than pushed to the far end of the
// column - one gap between them, not the whole remaining width.
expect(tag.left - label.right, lessThan(12));
expect(tag.left, greaterThan(label.right - 1));
expect(tag.right, lessThan(field.left));
});
testWidgets("the aside reads as a tag, not as a second label", (
tester,
) async {
await tester.pumpWidget(
_host(
const SettingsRow(
label: "Name",
action: Text("Required"),
field: TextField(),
),
),
);
final tag = tester.widget<Text>(find.text("Required"));
final style = DefaultTextStyle.of(
tester.element(find.text("Required")),
).style.merge(tag.style);
final label = tester.widget<Text>(find.text("Name"));
expect(style.fontSize, const Density().textXxs);
expect(style.color, testScheme.mutedForeground);
expect(style.fontSize, lessThan(label.style!.fontSize!));
});
testWidgets("a property row puts it in the same place", (tester) async {
await tester.pumpWidget(
_host(
PropertyRow(
label: "Price",
scheme: testScheme,
action: const Text("Required"),
child: const TextField(),
),
),
);
final label = tester.getRect(find.text("Price"));
final tag = tester.getRect(find.text("Required"));
final field = tester.getRect(find.byType(TextField));
expect(tag.left - label.right, lessThan(12));
expect(tag.right, lessThan(field.left));
});
testWidgets("the row eases open rather than jumping", (tester) async {
await tester.pumpWidget(_row());
await tester.pumpAndSettle();
final bare = tester.getSize(find.byKey(const Key("row"))).height;
await tester.pumpWidget(_row(error: "nope"));
await tester.pump(); // the frame the change lands on
final atStart = tester.getSize(find.byKey(const Key("row"))).height;
await tester.pump(const Duration(milliseconds: 90));
final midway = tester.getSize(find.byKey(const Key("row"))).height;
await tester.pumpAndSettle();
final settled = tester.getSize(find.byKey(const Key("row"))).height;
expect(settled, greaterThan(bare));
expect(midway, greaterThan(atStart));
expect(midway, lessThan(settled));
});
testWidgets("the outline fades in", (tester) async {
await tester.pumpWidget(_row());
await tester.pumpAndSettle();
await tester.pumpWidget(_row(error: "nope"));
await tester.pump();
await tester.pump(const Duration(milliseconds: 90));
final fades = tester
.widgetList<FadeTransition>(find.byType(FadeTransition))
.map((f) => f.opacity.value)
.toList();
expect(fades.any((v) => v > 0.0 && v < 1.0), isTrue);
});
}
@@ -0,0 +1,112 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// A feature lives INSIDE the field. It never gets to decide how tall the field
// is - that is the density's job and nothing elses.
//
// It used to. The field wrapped its whole content row in textFieldPadding, so
// anything you put in a feature slot got the padding stacked on top of its own
// height. A trailing IconButton is a full control tall (23 at compact), so the
// field came out at 23 + 12 = 35 against a controlHeight of 23 - half again as
// tall as the plain field sat next to it. The packages OWN InputFeature.clear()
// did it too, which is how it survived this long unnoticed.
//
// The existing feature-height probe in the app only ever passed a bare Text, so
// none of this was covered.
Future<double> _height(
WidgetTester tester, {
required Density density,
required List<InputFeature> features,
String text = "",
}) async {
final controller = TextEditingController(text: text);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: density),
child: Center(
child: SizedBox(
width: 300,
child: TextField(controller: controller, features: features),
),
),
),
),
);
await tester.pump();
return tester.getSize(find.byType(TextField)).height;
}
void main() {
for (final density in const [Density(), Density.normal()]) {
final label = density.control.name;
testWidgets("a trailing button doesnt grow the field ($label)", (
tester,
) async {
final plain = await _height(tester, density: density, features: const []);
final withButton = await _height(
tester,
density: density,
features: [
InputFeature.trailing(
IconButton(
variance: ButtonVariance.ghost,
density: ControlDensity.compact,
icon: const Icon(LucideIcons.eye),
onPressed: () {},
),
),
],
);
expect(plain, density.controlHeight);
expect(
withButton,
plain,
reason:
"a button in a trailing slot pushed the field to $withButton "
"against a plain $plain",
);
});
testWidgets("every built-in feature holds controlHeight ($label)", (
tester,
) async {
final cases = <String, List<InputFeature>>{
"leading icon": const [InputFeature.leading(Icon(LucideIcons.lock))],
"clear": [const InputFeature.clear()],
"spinner": [const InputFeature.spinner()],
"increment": [const InputFeature.incrementButton()],
"scrub": [const InputFeature.scrub()],
};
for (final entry in cases.entries) {
// empty AND filled - the clear button only shows up once theres text.
for (final text in const ["", "12"]) {
final h = await _height(
tester,
density: density,
features: entry.value,
text: text,
);
expect(
tester.takeException(),
isNull,
reason: "${entry.key} threw laying out in the field",
);
expect(
h,
density.controlHeight,
reason: "${entry.key} made the field $h",
);
}
}
});
}
}
+63
View File
@@ -0,0 +1,63 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
Widget _host(Density density, Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: density),
child: Column(children: [child]),
),
);
void main() {
for (final density in const [Density(), Density.normal()]) {
testWidgets("${density.control.name}: Gap.md takes its extent from the theme",
(tester) async {
await tester.pumpWidget(_host(density, const Gap.md()));
expect(tester.getSize(find.byType(Gap)).height, density.gapMd);
});
}
testWidgets("a literal Gap still wins over the scale", (tester) async {
await tester.pumpWidget(_host(const Density(), const Gap(17)));
expect(tester.getSize(find.byType(Gap)).height, 17.0);
});
_outsideFlex();
testWidgets("the same Gap.md moves when the density does", (tester) async {
await tester.pumpWidget(_host(const Density(), const Gap.md()));
final compact = tester.getSize(find.byType(Gap)).height;
await tester.pumpWidget(_host(const Density.normal(), const Gap.md()));
final normal = tester.getSize(find.byType(Gap)).height;
expect(normal, greaterThan(compact));
});
}
// Regression: a Gap with no Flex parent used to throw at layout time and take
// the enclosing subtree with it. One inside an AnimatedSize destroyed a login
// card in the Garage hub, on a build that analyzed clean.
void _outsideFlex() {
testWidgets("a Gap outside a Flex lays out instead of throwing",
(tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: const Center(child: SizedBox(width: 100, child: Gap.md())),
),
),
);
// it lays out - that is the whole point, it used to take the subtree down
expect(tester.getSize(find.byType(Gap)).height, const Density().gapMd);
// and it says so loudly in debug rather than failing silently
final reported = tester.takeException();
expect(reported, isA<FlutterError>());
expect("$reported", contains("no axis to size along"));
});
}
@@ -0,0 +1,98 @@
import "dart:ui" show Color;
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/theme/theme_data.dart";
// CHARACTERISATION BASELINE — do not "fix" these numbers.
//
// This locks the geometry Arcs & Angles renders at today, ahead of the density
// API being reshaped. The API is expected to change; the numbers are not. A
// failure here means the refactor moved something on screen, which is the one
// outcome that isn't allowed.
//
// Captured 28 Aug 2026 from Density() and Density.normal().
void main() {
group("compact", () {
const d = Density();
test("type", () {
expect(d.fontSize, 10.0);
expect(d.lineHeight, 1.1);
expect(d.lineBox, 11.0);
// level with the font, not the line box - an icon sized to the line box
// reads heavier than the label beside it
expect(d.iconSize, 10.0);
});
test("text ladder", () {
expect(d.textXs, 11.0);
expect(d.textSm, 14.0);
expect(d.textLg, 18.0);
});
test("control geometry", () {
expect(d.controlHeight, 23.0);
expect(d.controlPaddingY, 6.0);
expect(d.buttonPaddingX, 10.0);
expect(d.controlGap, 4.0);
expect(d.controlBorderWidth, 1.0);
expect(d.buttonPadding.horizontal, 20.0);
expect(d.buttonPadding.vertical, 12.0);
expect(d.borderedControlPadding.horizontal, 18.0);
expect(d.borderedControlPadding.vertical, 10.0);
});
test("rows", () {
expect(d.menuRowHeight, 20.0);
expect(d.popupRowHeight, 20.0);
expect(d.popupMaxHeight, 240.0);
expect(d.explorerRowHeight, 22.0);
expect(d.explorerRowPaddingY, 5.5);
expect(d.explorerRowBasePadding, 4.0);
expect(d.explorerRowEndPadding, 10.0);
expect(d.listRowIndent, 6.0);
});
test("containers and gaps", () {
expect(d.containerGap, 8.0);
expect(d.containerPadding, 16.0);
expect([d.gapXxs, d.gapXs, d.gapSm, d.gapMd, d.gapLg, d.gapXl, d.gapXxl],
[2.0, 4.0, 6.0, 8.0, 12.0, 16.0, 24.0]);
});
});
group("normal", () {
const d = Density.normal();
test("type", () {
expect(d.fontSize, 10.0);
expect(d.lineBox, 11.0);
});
test("text ladder", () {
expect(d.textXs, 11.0);
expect(d.textSm, 14.0);
expect(d.textLg, 18.0);
});
test("control geometry", () {
expect(d.controlHeight, 27.0);
expect(d.controlPaddingY, 8.0);
expect(d.buttonPaddingX, 12.0);
expect(d.controlGap, 5.0);
});
test("rows", () {
expect(d.menuRowHeight, 26.0);
expect(d.popupRowHeight, 26.0);
expect(d.explorerRowHeight, 30.0);
expect(d.explorerRowBasePadding, 8.0);
expect(d.explorerRowEndPadding, 14.0);
expect(d.listRowIndent, 12.0);
});
test("containers and gaps", () {
expect(d.containerGap, 10.0);
expect(d.containerPadding, 20.0);
expect([d.gapXxs, d.gapXs, d.gapSm, d.gapMd, d.gapLg, d.gapXl, d.gapXxl],
[3.0, 5.0, 8.0, 10.0, 15.0, 20.0, 30.0]);
});
});
test("icon tiers at scaling 1.0", () {
final t = IconThemeTokens.forDensity(const Density(), 1.0, const Color(0));
expect(t.small.size, 10.0);
expect(t.medium.size, 20.0);
expect(t.large.size, 24.0);
});
}
+121
View File
@@ -0,0 +1,121 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// `.iconSmall` and friends set a SIZE. They used to set the tier's colour too -
// the global scheme foreground - which beat whatever IconTheme enclosed the
// icon. Inside a button that meant the button's variant colour lost: a primary
// button hands its leading icon primaryForeground, and the wrapper put
// scheme.foreground back over it. On a dark scheme thats near-white on a
// near-white fill, so the icon vanished.
Color? _resolved(WidgetTester tester, Key key) {
final finder = find.descendant(
of: find.byKey(key),
matching: find.byType(Icon),
);
final element = finder.evaluate().first;
final icon = element.widget as Icon;
return icon.color ?? IconTheme.of(element).color;
}
void main() {
testWidgets("a size helper doesnt take the button's colour off its icon", (
tester,
) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Button(
key: const Key("bare"),
style: const ButtonStyle.primary(),
leading: const Icon(LucideIcons.user),
onPressed: () {},
child: const Text("bare"),
),
Button(
key: const Key("sized"),
style: const ButtonStyle.primary(),
leading: const Icon(LucideIcons.user).iconSmall,
onPressed: () {},
child: const Text("sized"),
),
],
),
),
),
),
);
await tester.pump();
expect(
_resolved(tester, const Key("sized")),
_resolved(tester, const Key("bare")),
reason: "wrapping in .iconSmall should only change the size",
);
expect(
_resolved(tester, const Key("sized")),
testScheme.primaryForeground,
reason: "a primary button's icon comes off primaryForeground",
);
});
testWidgets("outside a button it still falls through to the app theme", (
tester,
) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(
child: KeyedSubtree(
key: const Key("loose"),
child: const Icon(LucideIcons.user).iconSmall,
),
),
),
),
);
await tester.pump();
expect(_resolved(tester, const Key("loose")), testScheme.foreground);
});
testWidgets("the size still comes off the density", (tester) async {
for (final density in const [Density(), Density.normal()]) {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: density),
child: Center(
child: KeyedSubtree(
key: const Key("sized"),
child: const Icon(LucideIcons.user).iconSmall,
),
),
),
),
);
await tester.pump();
final element = find
.descendant(
of: find.byKey(const Key("sized")),
matching: find.byType(Icon),
)
.evaluate()
.first;
expect(IconTheme.of(element).size, density.iconSize);
}
});
}
+163
View File
@@ -0,0 +1,163 @@
import "package:flutter/services.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
/// Type [text] one character at a time through [f], the way a keyboard would.
///
/// Feeding the whole string in one go would only ever test paste - and a mask
/// that handles paste can still put the caret in the wrong place on every
/// keystroke, which is the bug people actually hit.
TextEditingValue _type(MaskedTextInputFormatter f, String text) {
var v = TextEditingValue.empty;
for (final ch in text.split("")) {
final at = v.selection.baseOffset < 0
? v.text.length
: v.selection.baseOffset;
final next = TextEditingValue(
text: v.text.substring(0, at) + ch + v.text.substring(at),
selection: TextSelection.collapsed(offset: at + 1),
);
v = f.formatEditUpdate(v, next);
}
return v;
}
/// One backspace at the caret.
TextEditingValue _backspace(MaskedTextInputFormatter f, TextEditingValue v) {
final at = v.selection.baseOffset;
if (at <= 0) return v;
return f.formatEditUpdate(
v,
TextEditingValue(
text: v.text.substring(0, at - 1) + v.text.substring(at),
selection: TextSelection.collapsed(offset: at - 1),
),
);
}
TextEditingValue _paste(MaskedTextInputFormatter f, String text) =>
f.formatEditUpdate(
TextEditingValue.empty,
TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
),
);
void main() {
final phone = MaskedTextInputFormatter("### ###-####");
group("typing", () {
test("literals arrive on their own", () {
expect(_type(phone, "5").text, "5");
expect(_type(phone, "555").text, "555");
// the space appears with the 4th digit, not before it - a trailing
// literal you cant delete reads as the field being stuck
expect(_type(phone, "5551").text, "555 1");
expect(_type(phone, "555123").text, "555 123");
expect(_type(phone, "5551234").text, "555 123-4");
expect(_type(phone, "5551234567").text, "555 123-4567");
});
test("the caret stays after what you typed", () {
final v = _type(phone, "5551");
expect(v.text, "555 1");
expect(v.selection.baseOffset, 5);
});
test("it stops when the mask is full", () {
expect(_type(phone, "55512345678901").text, "555 123-4567");
});
test("characters the slot wont take are dropped, not the whole edit", () {
expect(_type(phone, "5a5b5c").text, "555");
});
});
group("deleting", () {
test("backspace over a literal still removes a character", () {
// the case a naive mask gets stuck on: the caret is just past the space,
// backspace kills the space, the digits are unchanged, and re-applying
// the mask puts the space straight back.
var v = _type(phone, "5551");
expect(v.text, "555 1");
v = _backspace(phone, v);
expect(v.text, "555");
v = _backspace(phone, v);
expect(v.text, "55");
});
test("backspacing the lot empties the field", () {
var v = _type(phone, "5551234567");
for (var i = 0; i < 20; i++) {
v = _backspace(phone, v);
}
expect(v.text, "");
});
test("backspace at the start is a no-op, not a crash", () {
final v = phone.formatEditUpdate(
TextEditingValue.empty,
TextEditingValue.empty,
);
expect(v.text, "");
expect(_backspace(phone, v).text, "");
});
});
group("pasting", () {
test("an already formatted number survives", () {
expect(_paste(phone, "555 123-4567").text, "555 123-4567");
});
test("someone elses formatting is reduced and re-laid", () {
expect(_paste(phone, "(555) 123-4567").text, "555 123-4567");
expect(_paste(phone, "555.123.4567").text, "555 123-4567");
expect(_paste(phone, "5551234567").text, "555 123-4567");
});
test("overlong paste is cut to the mask", () {
expect(_paste(phone, "555123456789").text, "555 123-4567");
});
});
group("slot kinds", () {
test("A takes letters, # takes digits", () {
final post = MaskedTextInputFormatter("AA# #AA");
expect(_type(post, "SW1A1AA").text, "SW1 1AA");
// a digit cant fill a letter slot
expect(_type(post, "12").text, "");
});
test("* takes either", () {
final any = MaskedTextInputFormatter("**-**");
expect(_type(any, "a1b2").text, "a1-b2");
});
});
group("unmask", () {
test("gives back only what was typed", () {
expect(phone.unmask("555 123-4567"), "5551234567");
expect(phone.unmask("555 1"), "5551");
expect(phone.unmask(""), "");
});
test("a literal that looks like a slot character isnt returned", () {
// the leading 1 is part of the mask, not something anybody typed
final trunk = MaskedTextInputFormatter("1-###-####");
final v = _type(trunk, "5551234");
expect(v.text, "1-555-1234");
expect(trunk.unmask(v.text), "5551234");
});
test("round trips through apply", () {
const raw = "5551234567";
expect(phone.unmask(phone.apply(raw)), raw);
});
});
test("slotCount counts slots, not mask length", () {
expect(phone.slotCount, 10);
expect(phone.mask.length, 12);
});
}
@@ -0,0 +1,149 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// A group inside a group. The inner one used to install its own scope over the
// outer one's, so the outer's say was simply lost: the bottom row's field kept
// the top corners the vertical group had flattened, and painted a top edge the
// row above had already drawn.
//
// Corners are 0-or-1 keep factors, so composing is a product - a corner stays
// round only where BOTH groups leave it alone.
void main() {
ButtonGroupCorners cornersOf(WidgetTester tester, Type type) {
final ctx = tester.element(find.byType(type));
return ButtonGroupScope.maybeOf(ctx)!.corners;
}
ButtonGroupScope scopeOf(WidgetTester tester, Key key) {
return ButtonGroupScope.maybeOf(tester.element(find.byKey(key)))!;
}
testWidgets("a horizontal group nested in a vertical one composes", (
tester,
) async {
const top = Key("top");
const bottomStart = Key("bottom-start");
const bottomEnd = Key("bottom-end");
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(
child: SizedBox(
width: 300,
child: ButtonGroup.vertical(
expands: true,
children: [
const SizedBox(key: top, height: 20),
// no expands on the inner group - it keeps the
// IntrinsicHeight that bounds it inside the vertical one.
ButtonGroup.horizontal(
children: const [
Expanded(child: SizedBox(key: bottomStart, height: 20)),
SizedBox(key: bottomEnd, height: 20, width: 20),
],
),
],
),
),
),
),
),
);
// top row: vertical index 0 - both top corners live, bottom flattened
// against the row below.
final t = scopeOf(tester, top);
expect(
[t.corners.topStart, t.corners.topEnd],
[1, 1],
reason: "the top row keeps its top corners",
);
expect(
[t.corners.bottomStart, t.corners.bottomEnd],
[0, 0],
reason: "the top row's bottom is a seam",
);
expect(t.hideTop, isFalse);
expect(t.hideStart, isFalse);
// bottom-left: bottom of the vertical group AND start of the horizontal
// one, so the ONLY surviving corner is bottom-start.
final bs = scopeOf(tester, bottomStart);
expect(
[
bs.corners.topStart,
bs.corners.topEnd,
bs.corners.bottomStart,
bs.corners.bottomEnd,
],
[0, 0, 1, 0],
reason: "only the outer bottom-start corner survives both groups",
);
expect(
bs.hideTop,
isTrue,
reason: "the row above already painted this edge",
);
expect(bs.hideStart, isFalse, reason: "its the first in its own row");
// bottom-right: bottom of the vertical AND end of the horizontal, so only
// bottom-end survives - and it gives up BOTH its top and its start edge,
// which is the pair a single axis+index could never express.
final be = scopeOf(tester, bottomEnd);
expect(
[
be.corners.topStart,
be.corners.topEnd,
be.corners.bottomStart,
be.corners.bottomEnd,
],
[0, 0, 0, 1],
);
expect(be.hideTop, isTrue, reason: "the row above painted the top edge");
expect(
be.hideStart,
isTrue,
reason: "the field beside it painted the start edge",
);
});
testWidgets("an unnested group is unchanged", (tester) async {
const a = Key("a");
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(
child: ButtonGroup.horizontal(
children: const [
SizedBox(key: a, height: 20, width: 20),
SizedBox(height: 20, width: 20),
],
),
),
),
),
);
final s = scopeOf(tester, a);
expect(
[
s.corners.topStart,
s.corners.topEnd,
s.corners.bottomStart,
s.corners.bottomEnd,
],
[1, 0, 1, 0],
);
expect(s.hideTop, isFalse);
expect(s.hideStart, isFalse);
});
}
+167
View File
@@ -0,0 +1,167 @@
import "package:flutter/widgets.dart";
import "package:flutter_lucide/flutter_lucide.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
Widget _host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
// WidgetsLocalizations, which ReorderableList wants for its drag
// semantics. A WidgetsApp brings them; this host isnt one, so it says
// so itself rather than the widget quietly shadowing an app's locale.
child: Localizations(
locale: const Locale("en", "US"),
delegates: const [DefaultWidgetsLocalizations.delegate],
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
// deliberately NO Material and no MaterialApp: garage's app shell is
// a WidgetsApp, so anything material PropertiesList needs it has to
// bring itself. The Overlay is the one thing a WidgetsApp really does
// provide (through its Navigator), so the host provides it too.
child: Overlay(initialEntries: [OverlayEntry(builder: (_) => child)]),
),
),
);
PropertiesEntry _entry(String id, {PropertiesPin? pin, bool movable = true}) {
return PropertiesEntry(
id: id,
pin: pin,
movable: movable,
child: Builder(
builder: (context) => PropertiesSection(
title: id,
scheme: GarageTheme.of(context).colorScheme,
collapsed: true,
onToggle: () {},
rows: const [],
),
),
);
}
void main() {
testWidgets("every section is movable by default", (tester) async {
await tester.pumpWidget(
_host(
PropertiesList(entries: [_entry("a"), _entry("b")], onReorder: (_) {}),
),
);
// one grab handle each - the default is that things move
expect(find.byIcon(LucideIcons.grip_horizontal), findsNWidgets(2));
});
testWidgets("an unmovable section has no handle", (tester) async {
await tester.pumpWidget(
_host(
PropertiesList(
entries: [_entry("a"), _entry("b", movable: false)],
onReorder: (_) {},
),
),
);
expect(find.byIcon(LucideIcons.grip_horizontal), findsOneWidget);
});
testWidgets("a pinned section sits above the list, out of its reach", (
tester,
) async {
await tester.pumpWidget(
_host(
PropertiesList(
// handed over LAST, and it still comes out first
entries: [
_entry("a"),
_entry("b"),
_entry("top", pin: PropertiesPin.top),
],
onReorder: (_) {},
),
),
);
final top = tester.getTopLeft(find.text("top")).dy;
expect(top, lessThan(tester.getTopLeft(find.text("a")).dy));
// two movable, so two handles - the pinned one has none
expect(find.byIcon(LucideIcons.grip_horizontal), findsNWidgets(2));
// and it isnt IN the reorderable list at all, which is what stops the
// list shifting it out of the way and then bouncing the drop back.
expect(
find.descendant(
of: find.byType(ReorderableList),
matching: find.text("top"),
),
findsNothing,
);
expect(
find.descendant(
of: find.byType(ReorderableList),
matching: find.text("a"),
),
findsOneWidget,
);
});
testWidgets("with nothing pinned there is no stray list", (tester) async {
await tester.pumpWidget(
_host(
PropertiesList(entries: [_entry("a"), _entry("b")], onReorder: (_) {}),
),
);
expect(find.byType(ReorderableList), findsOneWidget);
});
test("a section is not rebuilt just because its position moved", () {
// the whole reason the index lives in a mutable slot. Notifying on it
// rebuilt every section in the list each time the gap shifted mid-drag -
// rows, text fields and all - which is what made dragging feel like it
// was catching.
final slot = PropertiesReorderSlot(0);
const child = SizedBox();
final before = PropertiesReorderScope(
slot: slot,
draggable: true,
child: child,
);
slot.index = 3;
final after = PropertiesReorderScope(
slot: slot,
draggable: true,
child: child,
);
expect(after.updateShouldNotify(before), isFalse);
// but whether it can be dragged at all still is worth a rebuild - thats
// the handle appearing or going.
final locked = PropertiesReorderScope(
slot: slot,
draggable: false,
child: child,
);
expect(locked.updateShouldNotify(after), isTrue);
});
testWidgets("a section on its own has no handle at all", (tester) async {
await tester.pumpWidget(
_host(
Builder(
builder: (context) => PropertiesSection(
title: "alone",
scheme: GarageTheme.of(context).colorScheme,
collapsed: true,
onToggle: () {},
rows: const [],
),
),
),
);
expect(find.byIcon(LucideIcons.grip_horizontal), findsNothing);
});
}
@@ -0,0 +1,131 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// The actions band. Before this existed a Save button had to go in `rows`,
// where it read as one more property that happened to be a button - no
// separator, no tone, and lined up against the property split like a value.
Widget _host(Widget child, {Density density = const Density()}) =>
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: density),
child: Center(child: SizedBox(width: 420, child: child)),
),
);
Widget _section({List<Widget> actions = const [], bool collapsed = false}) =>
PropertiesSection(
title: "Profile",
scheme: testScheme,
collapsed: collapsed,
onToggle: () {},
actions: actions,
rows: [
PropertyRow(
label: "Username",
scheme: testScheme,
child: const SizedBox(),
),
],
);
Color? _bandColour(WidgetTester tester) {
final containers = find
.ancestor(
of: find.text("Save"),
matching: find.byType(Container),
)
.evaluate()
.map((e) => e.widget as Container)
.where((c) => c.color != null);
return containers.isEmpty ? null : containers.first.color;
}
void main() {
testWidgets("no actions means no band and no separator", (tester) async {
await tester.pumpWidget(_host(_section()));
expect(find.byType(Divider), findsNothing);
});
testWidgets("actions get a separator and the muted band", (tester) async {
await tester.pumpWidget(
_host(
_section(
actions: [
Button(
style: const ButtonStyle.primary(),
onPressed: () {},
child: const Text("Save"),
),
],
),
),
);
expect(find.byType(Divider), findsOneWidget);
// toned OFF the section, not the same fill
expect(_bandColour(tester), testScheme.muted);
expect(_bandColour(tester),
isNot(testScheme.card));
});
testWidgets("the band spans the section, not just its buttons", (
tester,
) async {
await tester.pumpWidget(
_host(
_section(
actions: [
Button(
style: const ButtonStyle.primary(),
onPressed: () {},
child: const Text("Save"),
),
],
),
),
);
const density = Density();
final content = tester.getRect(
find
.ancestor(of: find.text("Save"), matching: find.byType(Row))
.first,
);
final divider = tester.getRect(find.byType(Divider));
// the BAND spans the section edge to edge - its content is inset by the
// same buttonPaddingX a row uses, so the buttons line up with the controls
// above them rather than sitting on the section's edge.
expect(content.width, divider.width - density.buttonPaddingX * 2);
// and the whole thing sits under the rows
expect(
content.top,
greaterThan(tester.getRect(find.text("Username")).bottom),
);
});
testWidgets("actions collapse with the rows", (tester) async {
await tester.pumpWidget(
_host(
_section(
collapsed: true,
actions: [
Button(
style: const ButtonStyle.primary(),
onPressed: () {},
child: const Text("Save"),
),
],
),
),
);
expect(find.text("Save"), findsNothing);
expect(find.byType(Divider), findsNothing);
});
}
@@ -0,0 +1,67 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// The section's own subtitle. Sits in the header under the title, and stays
// there while the section is collapsed - it names the section rather than
// being part of its body. Sections deliberately have no `description`; a box
// of copy above the rows competed with them, and a row's own description
// covers the case anyway.
Widget _host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(child: SizedBox(width: 420, child: child)),
),
);
Widget _section({String? subtitle, bool collapsed = false}) =>
PropertiesSection(
title: "Profile",
subtitle: subtitle,
scheme: testScheme,
collapsed: collapsed,
onToggle: () {},
rows: [
PropertyRow(
label: "Username",
scheme: testScheme,
child: const TextField(),
),
],
);
void main() {
testWidgets("subtitle sits under the title, above the rows", (tester) async {
await tester.pumpWidget(_host(_section(subtitle: "a subtitle")));
final title = tester.getRect(find.text("Profile"));
final sub = tester.getRect(find.text("a subtitle"));
final row = tester.getRect(find.text("Username"));
expect(sub.top, greaterThanOrEqualTo(title.bottom));
expect(sub.left, title.left); // same column, clear of the chevron
expect(row.top, greaterThan(sub.bottom));
});
testWidgets("subtitle renders at textXxs", (tester) async {
await tester.pumpWidget(_host(_section(subtitle: "a subtitle")));
expect(
tester.widget<Text>(find.text("a subtitle")).style!.fontSize,
const Density().textXxs,
);
});
testWidgets("subtitle survives collapse", (tester) async {
await tester.pumpWidget(
_host(_section(subtitle: "a subtitle", collapsed: true)),
);
// it names the section, so it stays when the body goes
expect(find.text("a subtitle"), findsOneWidget);
expect(find.text("Username"), findsNothing);
});
}
+187
View File
@@ -0,0 +1,187 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// A property row used to be label + control and nothing else, so a page
// needing to explain a setting had to either fold it into the label or give up
// and hardcode a Text somewhere. Two slots now:
//
// subtitle - inside the label column, right aligned against the split
// description - full width under the whole row
//
// Both render at density.textXxs, which is the rung below the control font -
// it didnt exist before either, because in A&A the control font is already the
// smallest thing on screen.
Widget _host(Widget child, {Density density = const Density()}) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: density),
child: Center(child: SizedBox(width: 400, child: child)),
),
);
double _fontOf(WidgetTester tester, String text) {
final widget = tester.widget<Text>(find.text(text));
return widget.style!.fontSize!;
}
void main() {
testWidgets("subtitle and description both render at textXxs", (
tester,
) async {
await tester.pumpWidget(
_host(
PropertyRow(
label: "Username",
subtitle: "Display name used across dashboard",
description: "Changing this breaks any links to your old name.",
scheme: testScheme,
child: const SizedBox(),
),
),
);
const density = Density();
expect(density.textXxs, 9.0); // 10 * 0.875, rounded
expect(_fontOf(tester, "Display name used across dashboard"), 9.0);
expect(_fontOf(tester, "Changing this breaks any links to your old name."),
9.0);
});
testWidgets("both slots track the density", (tester) async {
await tester.pumpWidget(
_host(
PropertyRow(
label: "Username",
subtitle: "a subtitle",
scheme: testScheme,
child: const SizedBox(),
),
density: const Density(fontSize: 16.0, lineHeight: 1.25),
),
);
expect(_fontOf(tester, "a subtitle"), 14.0); // 16 * 0.875
});
testWidgets("a description makes the row taller, not the control shorter", (
tester,
) async {
Future<double> heightWith({String? description}) async {
await tester.pumpWidget(
_host(
PropertyRow(
key: const Key("row"),
label: "Username",
description: description,
scheme: testScheme,
child: const SizedBox(),
),
),
);
// the row eases between heights now, so the frame straight after the
// pump is still the old one.
await tester.pumpAndSettle();
return tester.getSize(find.byKey(const Key("row"))).height;
}
final bare = await heightWith();
final withCopy = await heightWith(description: "some copy");
// the bare row is the row minimum - controlHeight plus a step, so that
// neither the control nor the label column is the thing setting it. a
// description adds on top of that rather than squeezing into it.
expect(bare, const Density().propertyRowHeight);
expect(withCopy, greaterThan(bare));
});
testWidgets("the gap between section rows is gapXs, off the density", (
tester,
) async {
// the numbers PropertiesSection used to hardcode - 4 between rows, 8 under
// the last - are exactly gapXs and gapMd at compact, which is the tell they
// were always meant to be these tokens. at normal they should now move.
const compact = Density();
const normal = Density.normal();
expect(compact.gapXs, 4.0);
expect(normal.gapXs, 5.0);
Future<double> gapBetweenRows(Density density) async {
await tester.pumpWidget(
_host(
PropertiesSection(
title: "Profile",
scheme: testScheme,
collapsed: false,
onToggle: () {},
rows: [
for (var i = 0; i < 2; i++)
PropertyRow(
key: Key("row$i"),
label: "Row $i",
scheme: testScheme,
child: const SizedBox(),
),
],
),
density: density,
),
);
final first = tester.getRect(find.byKey(const Key("row0")));
final second = tester.getRect(find.byKey(const Key("row1")));
return second.top - first.bottom;
}
expect(await gapBetweenRows(compact), compact.gapXs);
expect(await gapBetweenRows(normal), normal.gapXs);
});
testWidgets("a subtitle doesnt change the row height", (tester) async {
// the whole reason the minimum sits above controlHeight. a label plus a
// subtitle is laid out on the font's own metrics; at plain controlHeight
// that made the LABEL COLUMN set the row height on some fonts and not
// others. both rows have to come out the same.
Future<double> h({String? subtitle}) async {
await tester.pumpWidget(
_host(
PropertyRow(
key: const Key("row"),
label: "Username",
subtitle: subtitle,
scheme: testScheme,
child: const TextField(),
),
),
);
// the row eases between heights now, so the frame straight after the
// pump is still the old one.
await tester.pumpAndSettle();
return tester.getSize(find.byKey(const Key("row"))).height;
}
expect(await h(subtitle: "a subtitle"), await h());
});
test("the row minimum clears whichever of the two is taller", () {
// at compact the LABEL COLUMN is the binding one - 26 against a 23px
// control - which is the whole reason this token exists. at the roomier
// tiers the control wins again. either way the row clears both, so a
// subtitle never changes the height.
const compact = Density();
expect(compact.labelColumnHeight, 26.0);
expect(compact.controlHeight, 23.0);
expect(compact.propertyRowHeight, 28.0);
const normal = Density.normal();
expect(normal.labelColumnHeight, 26.0);
expect(normal.propertyRowHeight, normal.controlHeight + normal.gapXxs);
for (final d in [compact, normal]) {
expect(d.propertyRowHeight, greaterThan(d.labelColumnHeight));
expect(d.propertyRowHeight, greaterThan(d.controlHeight));
}
});
}
@@ -0,0 +1,111 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// A property section is a docked surface, so its fields are always secondary -
// an outline field in there draws its own border and background against a
// panel that already has both, and reads as floating. That made `variant:` a
// parameter whose every value but one was a bug, so PropertyRow now publishes
// PropertySlotScope and the controls resolve against it.
//
// Explicit does NOT win here, unlike the label scope one widget up. These
// check exactly that.
// the fill a control actually painted, which is the thing the variant decides.
Color? _fill(WidgetTester tester, Key key) {
final box = find
.descendant(of: find.byKey(key), matching: find.byType(DecoratedBox))
.evaluate()
.map((e) => e.widget as DecoratedBox)
.map((d) => d.decoration)
.whereType<BoxDecoration>()
.firstWhere((d) => d.color != null);
return box.color;
}
Widget _host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Center(child: SizedBox(width: 400, child: child)),
),
);
void main() {
testWidgets("a field in a property row is secondary however its asked", (
tester,
) async {
await tester.pumpWidget(
_host(
Column(
mainAxisSize: MainAxisSize.min,
children: [
PropertyRow(
label: "In a row",
scheme: testScheme,
// asking for outline, explicitly, and losing
child: const TextField(
key: Key("row"),
variant: TextFieldVariant.outline,
),
),
const TextField(
key: Key("bare"),
variant: TextFieldVariant.outline,
),
],
),
),
);
expect(_fill(tester, const Key("row")), testScheme.secondary);
expect(_fill(tester, const Key("bare")), isNot(testScheme.secondary));
});
testWidgets("a labelless row still counts as a slot", (tester) async {
await tester.pumpWidget(
_host(
PropertyRow(
label: "Hidden",
scheme: testScheme,
labelless: true,
child: const TextField(
key: Key("row"),
variant: TextFieldVariant.outline,
),
),
),
);
expect(_fill(tester, const Key("row")), testScheme.secondary);
});
testWidgets("a select in a property row is secondary too", (tester) async {
await tester.pumpWidget(
_host(
PropertyRow(
label: "Role",
scheme: testScheme,
child: Select<String>(
key: const Key("row"),
value: "Owner",
variant: SelectVariant.outline,
itemBuilder: (ctx, item) => Text(item),
onChanged: (_) {},
popup: const SelectPopup(
items: SelectItemList(
children: [
SelectItemButton<String>(value: "Owner", child: Text("Owner")),
],
),
),
),
),
),
);
expect(_fill(tester, const Key("row")), testScheme.secondary);
});
}
+182
View File
@@ -0,0 +1,182 @@
import "dart:math" as math;
import "dart:ui" show Color;
import "package:flutter/widgets.dart" show Brightness;
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/theme/colour_scheme.dart";
import "package:garage_ui/theme/schemes.dart";
// ColourScheme.derive exists so a new app doesn't pay a 37-colour entry fee.
//
// It used to be a table of HSL offsets and these tests could only say "near
// enough". It's four measured laws now, taken off the ten hand authored
// schemes, so most of these check the law rather than the number - and at
// zinc's ground the numbers come out exact, which is worth pinning.
double _channel(double v) =>
v <= 0.04045 ? v / 12.92 : math.pow((v + 0.055) / 1.055, 2.4).toDouble();
double lstar(Color c) {
final y =
0.2126 * _channel(c.r) + 0.7152 * _channel(c.g) + 0.0722 * _channel(c.b);
return y > 0.008856 ? 116 * math.pow(y, 1 / 3).toDouble() - 16 : 903.3 * y;
}
void main() {
// the core of Arcs & Angles' zinc, which the laws were measured against
ColourScheme zincish() => ColourScheme.derive(
brightness: Brightness.dark,
background: const Color(0xff303030),
foreground: const Color(0xffe6e6e6),
primary: const Color(0xff4772b3),
);
// carbon's ground. 3.6 L*, so almost no room underneath - this is the one
// that used to come apart.
ColourScheme carbonish() => ColourScheme.derive(
brightness: Brightness.dark,
background: const Color(0xff0d0d0d),
foreground: const Color(0xfff2f2f2),
primary: const Color(0xffe8e8e8),
);
test("four colours produce a complete scheme", () {
final s = zincish();
expect(s.chrome, isNotNull);
expect(s.panelBorderHighlighted, isNotNull);
expect(s.rowHovered, isNotNull);
expect(s.controlBorder, isNotNull);
});
test("reproduces zinc, the scheme it was measured from", () {
final s = zincish();
// not "near" - these are the hand authored values exactly. The laws were
// read off this scheme, so anything else means a law drifted.
expect(s.chrome, const Color(0xff161616));
expect(s.card, const Color(0xff3d3d3d));
expect(s.popover, const Color(0xff181818));
expect(s.muted, const Color(0xff1d1d1d));
expect(s.surfaceSunken, const Color(0xff282828));
expect(s.mutedForeground, const Color(0xff999999));
});
// ── law 2: recessed rungs squeeze into whatever basement there is ───────
test("a ground with no basement squeezes rather than reflecting", () {
final s = carbonish();
final ground = lstar(s.background);
// the old up() reflected an offset that ran off the end, so a slot asked
// to go 12 points DOWN came out 12 points UP - lighter than the surface
// it belongs under. Every recessed slot must still be at or below ground.
for (final (name, colour) in [
("chrome", s.chrome),
("popover", s.popover),
("muted", s.muted),
("controlFill", s.controlFill),
("surfaceSunken", s.surfaceSunken),
]) {
expect(
lstar(colour),
lessThanOrEqualTo(ground + 0.01),
reason: "$name must not surface above the ground it recesses from",
);
}
});
test("squeezing keeps the order of the recessed rungs", () {
final s = carbonish();
// shallowest to deepest, which is the thing reflection destroyed
expect(lstar(s.surfaceSunken), greaterThan(lstar(s.controlFill)));
expect(lstar(s.controlFill), greaterThan(lstar(s.popover)));
expect(lstar(s.popover), greaterThanOrEqualTo(lstar(s.chrome)));
});
// ── law 3: a stroke is measured off the surface it outlines ─────────────
test("strokes sit off their own surface, not off the ground", () {
for (final s in [zincish(), carbonish(), GarageSchemes.light]) {
expect(
lstar(s.popoverBorder) - lstar(s.popover),
closeTo(9.9, 0.6),
reason: "popoverBorder is measured off popover",
);
expect(
lstar(s.controlBorder) - lstar(s.controlFill),
closeTo(11.8, 0.6),
reason: "controlBorder is measured off controlFill",
);
expect(
lstar(s.propertiesSectionBorder) - lstar(s.card),
closeTo(6.2, 0.6),
reason: "a section border outlines card, which is what fills one",
);
}
});
test("a border always reads against the surface it outlines", () {
// the bug this replaces: carbon's popover was pinned to black and its
// border derived DARKER still, so the popover had no visible edge.
for (final s in [zincish(), carbonish(), GarageSchemes.light]) {
expect((lstar(s.popoverBorder) - lstar(s.popover)).abs(), greaterThan(3));
expect(
(lstar(s.controlBorder) - lstar(s.controlFill)).abs(),
greaterThan(3),
);
}
});
// ── law 4: text is a fraction of the span, not an absolute shift ────────
test("muted text sits at the same fraction of the span on any ground", () {
for (final s in [zincish(), carbonish()]) {
final ground = lstar(s.background);
final span = lstar(s.foreground) - ground;
expect(
(lstar(s.mutedForeground) - ground) / span,
closeTo(0.607, 0.02),
reason: "zinc and carbon both measure 60.7%, on spans of 71 and 92",
);
expect((lstar(s.rowText) - ground) / span, closeTo(0.814, 0.02));
}
});
test("light schemes travel the other way", () {
final light = GarageSchemes.light;
expect(lstar(light.chrome), lessThan(lstar(light.background)));
expect(lstar(zincish().chrome), lessThan(lstar(zincish().background)));
// and a light ground has basement to spare, so nothing squeezes
expect(
lstar(light.background) - lstar(light.chrome),
closeTo(12.6, 0.6),
);
});
test("every derived slot has an override", () {
final s = ColourScheme.derive(
brightness: Brightness.dark,
background: const Color(0xff000000),
foreground: const Color(0xffffffff),
primary: const Color(0xff00ff00),
chrome: const Color(0xff123456),
card: const Color(0xff654321),
);
expect(s.chrome, const Color(0xff123456));
expect(s.card, const Color(0xff654321));
// and the override propagates to the slots measured from it
expect(
lstar(s.propertiesSectionBorder) - lstar(const Color(0xff654321)),
closeTo(6.2, 0.6),
);
});
test("GarageSchemes ship a usable pair", () {
for (final s in [GarageSchemes.dark, GarageSchemes.light]) {
expect(s.chrome, isNotNull);
expect(
(lstar(s.foreground) - lstar(s.background)).abs(),
greaterThan(50),
reason: "foreground must contrast with background",
);
}
expect(GarageSchemes.of(Brightness.dark), same(GarageSchemes.dark));
expect(GarageSchemes.of(Brightness.light), same(GarageSchemes.light));
});
}
+95
View File
@@ -0,0 +1,95 @@
import "package:flutter/foundation.dart";
import "package:flutter/services.dart";
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "package:garage_ui/platform/cursor_lock.dart";
import "support/test_scheme.dart";
// Drag-to-scrub used to be macOS only without anybody saying so. The moment
// the drag threshold was crossed the field handed everything over to
// CursorLock and ignored Flutters own pointer stream forever after - and
// CursorLock has no native handler off macOS, so on web (and windows/linux)
// the deltas it was waiting on never turned up. The field just froze on the
// value it started on.
Future<TextEditingController> pumpField(
WidgetTester tester, {
String text = "10.0",
}) async {
final controller = TextEditingController(text: text);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme),
child: Center(
child: SizedBox(
width: 200,
child: TextField(
controller: controller,
features: const [InputFeature.scrub()],
),
),
),
),
),
);
await tester.pump();
return controller;
}
void main() {
testWidgets("no native cursor lock - the pointer stream drives the scrub", (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
expect(CursorLock.isSupported, isFalse);
final controller = await pumpField(tester);
// real pointer moves arrive a few px at a time, so drive it that way
// rather than as one giant jump. default sensitivity is 0.1/px, and the
// first move only arms the drag (it's the one the click threshold eats),
// so 100 counted px = +10.
final gesture = await tester.startGesture(
tester.getCenter(find.byType(TextField)),
);
for (var i = 0; i < 21; i++) {
await gesture.moveBy(const Offset(5, 0));
await tester.pump();
}
await gesture.up();
await tester.pump();
expect(double.parse(controller.text), closeTo(20.0, 0.5));
debugDefaultTargetPlatformOverride = null;
});
testWidgets("with a native cursor lock the pointer stream is ignored", (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.macOS;
const channel = MethodChannel("garage/cursor_lock");
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (_) async => null);
final controller = await pumpField(tester);
final gesture = await tester.startGesture(
tester.getCenter(find.byType(TextField)),
);
for (var i = 0; i < 21; i++) {
await gesture.moveBy(const Offset(5, 0));
await tester.pump();
}
await gesture.up();
await tester.pump();
// the native side would have sent deltas back over the channel; nothing
// did here, so the value must not have budged.
expect(double.parse(controller.text), closeTo(10.0, 1e-9));
debugDefaultTargetPlatformOverride = null;
});
}
@@ -0,0 +1,141 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// the rows sat flush against each other and the surface took the same radius
// they do, so the corner gap didnt match the gap down the sides.
const _density = Density();
Widget _host() => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: _density),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 600,
height: 400,
child: Overlay(
initialEntries: [
OverlayEntry(
builder: (_) => Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 160,
child: Select<String>(
value: "normal",
itemBuilder: (ctx, v) => const Text("Normal"),
onChanged: (_) {},
popup: const SelectPopup(
items: SelectItemList(
children: [
SelectItemButton<String>(
value: "normal",
child: Text("Normal"),
),
SelectItemButton<String>(
value: "comfortable",
child: Text("Comfortable"),
),
],
),
),
),
),
),
),
],
),
),
),
),
);
Rect _rowOf(WidgetTester tester, String label) => tester.getRect(
find.ancestor(
of: find.text(label),
matching: find.byType(SelectItemButton<String>),
),
);
void main() {
testWidgets("theres air between one row and the next", (tester) async {
await tester.pumpWidget(_host());
await tester.tap(find.byType(Select<String>));
await tester.pumpAndSettle();
final first = _rowOf(tester, "Normal");
final second = _rowOf(tester, "Comfortable");
final gap = second.top - first.bottom;
expect(gap, greaterThan(0), reason: "rows were flush");
expect(gap, closeTo(_density.gapXxs, 0.5));
});
testWidgets("the surface rounds around the rows, less a couple of px", (
tester,
) async {
await tester.pumpWidget(_host());
await tester.tap(find.byType(Select<String>));
await tester.pumpAndSettle();
final theme = ThemeData(colorScheme: testScheme, density: _density);
final inset = theme.density.containerGap * 0.5;
final border = theme.density.controlBorderWidth;
// the surface is the one decorated box wrapping the rows
final surface = tester.widgetList<Container>(find.byType(Container)).where((
c,
) {
final d = c.decoration;
return d is BoxDecoration && d.borderRadius != null && d.border != null;
});
expect(surface, isNotEmpty);
final radii = surface
.map((c) => ((c.decoration as BoxDecoration).borderRadius
as BorderRadius).topLeft.x)
.toList();
final expected = theme.radiusMd + inset + border - 2;
expect(
radii.any((r) => (r - expected).abs() < 0.01),
isTrue,
reason: "wanted a surface at $expected, saw $radii",
);
});
testWidgets("it hangs off the trigger by gapSm, not the container gap", (
tester,
) async {
await tester.pumpWidget(_host());
final trigger = tester.getRect(find.byType(Select<String>));
await tester.tap(find.byType(Select<String>));
await tester.pumpAndSettle();
final scroller = tester.getRect(find.byType(SingleChildScrollView).first);
// the scroller starts just inside the surface's border, so back that off
// to get the surface's own top edge
final border = _density.controlBorderWidth;
final toSurface = scroller.top - border - trigger.bottom;
expect(toSurface, closeTo(_density.gapSm, 0.5));
expect(toSurface, lessThan(_density.containerGap));
});
testWidgets("the rows still sit inside the padding", (tester) async {
await tester.pumpWidget(_host());
await tester.tap(find.byType(Select<String>));
await tester.pumpAndSettle();
final row = _rowOf(tester, "Normal");
final theme = ThemeData(colorScheme: testScheme, density: _density);
final inset = theme.density.containerGap * 0.5;
// the scroller holds the padding, so the row starts one inset inside it
final scroller = tester.getRect(find.byType(SingleChildScrollView).first);
expect(row.left - scroller.left, closeTo(inset, 0.5));
expect(row.top - scroller.top, closeTo(inset, 0.5));
});
}
+137
View File
@@ -0,0 +1,137 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
Widget _select() => Select<String>(
value: "off",
itemBuilder: (ctx, v) => const Text("Off"),
onChanged: (_) {},
popup: SelectPopup(
items: SelectItemList(
children: const [
SelectItemButton<String>(value: "off", child: Text("Off")),
SelectItemButton<String>(
value: "optional",
child: Text("Optional"),
),
SelectItemButton<String>(
value: "required",
child: Text("Required"),
),
],
),
),
);
Widget _host({required Alignment where, double overlayWidth = 600}) =>
Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: overlayWidth,
height: 400,
child: Overlay(
initialEntries: [
OverlayEntry(
builder: (_) => Align(
alignment: where,
// narrow, the way a ghost select showing "Off" is narrow
child: SizedBox(width: 70, child: _select()),
),
),
],
),
),
),
),
);
void main() {
testWidgets("it sizes to its widest row, not to the room available", (
tester,
) async {
// given a ceiling, a ListView based popup filled the ceiling - a 70px
// trigger opening a popup most of 600px wide.
await tester.pumpWidget(_host(where: Alignment.topLeft));
await tester.tap(find.byType(Select<String>));
await tester.pumpAndSettle();
final row = tester.getRect(
find.ancestor(
of: find.text("Required"),
matching: find.byType(SelectItemButton<String>),
),
);
// wide enough for the longest option and its tick, nowhere near the 600
// it was offered
expect(row.width, greaterThan(70));
expect(row.width, lessThan(300));
expect(tester.takeException(), isNull);
});
testWidgets("a narrow trigger near an edge still gets a readable popup", (
tester,
) async {
// the sliver: clamped to the room on the side the placement picked by
// testing the FLOOR, the popup came out one character wide.
await tester.pumpWidget(_host(where: Alignment.topRight));
await tester.tap(find.byType(Select<String>));
await tester.pumpAndSettle();
for (final label in ["Off", "Optional", "Required"]) {
final row = tester.getRect(
find.ancestor(
of: find.text(label),
matching: find.byType(SelectItemButton<String>),
),
);
// one line each, not a column of letters
expect(row.height, lessThan(40), reason: label);
// and never narrower than the trigger it came out of
expect(row.width, greaterThanOrEqualTo(70.0), reason: label);
}
expect(tester.takeException(), isNull);
});
testWidgets("the popup grows past a narrow trigger to fit its rows", (
tester,
) async {
await tester.pumpWidget(_host(where: Alignment.topLeft));
await tester.tap(find.byType(Select<String>));
await tester.pumpAndSettle();
// the row that wrapped onto two lines when the popup was pinned to a
// 70px trigger
final optional = tester.getSize(find.text("Optional"));
expect(optional.height, lessThan(30));
expect(tester.takeException(), isNull);
});
testWidgets("and it stays inside the overlay when the trigger is at the "
"right edge", (tester) async {
// the case that broke: a select hard against the right of a settings row.
// Left aligned to the trigger, a wider popup runs off the edge and gets
// clipped by whatever bounds it - a square corner and rows cut mid-word.
await tester.pumpWidget(_host(where: Alignment.topRight));
await tester.tap(find.byType(Select<String>));
await tester.pumpAndSettle();
for (final label in ["Off", "Optional", "Required"]) {
final row = tester.getRect(
find.ancestor(
of: find.text(label),
matching: find.byType(SelectItemButton<String>),
),
);
expect(row.left, greaterThanOrEqualTo(0.0), reason: label);
expect(row.right, lessThanOrEqualTo(600.0), reason: label);
}
expect(tester.takeException(), isNull);
});
}
+154
View File
@@ -0,0 +1,154 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
Widget _host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: ThemeData(colorScheme: testScheme, density: const Density()),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(width: 400, child: child),
),
),
);
void main() {
testWidgets("rules go between entries, not around them", (tester) async {
await tester.pumpWidget(
_host(
SettingsList(
children: [
SettingsRow(label: "Full name", field: const Text("Benjamin")),
SettingsRow(label: "Nickname", field: const Text("Benji")),
SettingsRow(label: "Work", field: const Text("Engineering")),
],
),
),
);
// three rows, two rules - a rule above the first or below the last would
// draw the box this exists to not be
expect(find.byType(Divider), findsNWidgets(2));
});
testWidgets("the rule runs wider than the rows it separates", (
tester,
) async {
await tester.pumpWidget(
_host(
SettingsList(
children: [
SettingsRow(label: "Read", field: const Text("Optional")),
SettingsRow(label: "Change", field: const Text("Optional")),
],
),
),
);
final rule = tester.getRect(find.byType(Divider));
final row = tester.getRect(find.byType(SettingsRow).first);
// overshoots at both ends - a rule that stops where its content stops
// reads as the edge of a box rather than a divider
expect(rule.left, lessThan(row.left));
expect(rule.right, greaterThan(row.right));
});
testWidgets("one entry has no rule at all", (tester) async {
await tester.pumpWidget(
_host(
SettingsList(
children: [
SettingsRow(label: "Full name", field: const Text("Benjamin")),
],
),
),
);
expect(find.byType(Divider), findsNothing);
});
testWidgets("label takes the room the field doesnt", (tester) async {
await tester.pumpWidget(
_host(
SettingsList(
children: [
SettingsRow(
label: "What best describes your work?",
field: const SizedBox(width: 120, height: 20),
),
],
),
),
);
final label = tester.getRect(find.text("What best describes your work?"));
final field = tester.getRect(find.byType(SizedBox).last);
final row = tester.getRect(find.byType(SettingsRow));
// label on the left, field hard against the right edge - not floating in
// the middle at the left of its own share of the row
expect(label.left, lessThan(field.left));
expect(field.width, 120);
expect(field.right, closeTo(row.right, 1));
expect(tester.takeException(), isNull);
});
testWidgets("a field with flex children inside it doesnt assert", (
tester,
) async {
// the field slot is bounded, not unbounded - a ButtonGroup with `fill`,
// or anything else holding an Expanded, needs a width to divide up.
await tester.pumpWidget(
_host(
SettingsList(
children: [
SettingsRow(
label: "Read",
field: ButtonGroup.horizontal(
fill: true,
children: [
Button(
style: const ButtonStyle.outline(),
onPressed: () {},
child: const Text("Off"),
),
Button(
style: const ButtonStyle.outline(),
onPressed: () {},
child: const Text("Required"),
),
],
),
),
],
),
),
);
expect(tester.takeException(), isNull);
});
testWidgets("a long label doesnt push the field off the row", (
tester,
) async {
await tester.pumpWidget(
_host(
SettingsList(
children: [
SettingsRow(
label: "See your account, passkeys and sign-in activity, and "
"everything else besides",
field: const SizedBox(width: 120, height: 20),
),
],
),
),
);
expect(tester.takeException(), isNull);
expect(tester.getSize(find.byType(SizedBox).last).width, 120);
});
}
+12
View File
@@ -0,0 +1,12 @@
import "dart:ui" show Color;
import "package:flutter/widgets.dart" show Brightness;
import "package:garage_ui/theme/colour_scheme.dart";
/// A throwaway scheme for widget tests, built through the deriving factory so
/// tests don't each carry a 54-colour literal.
final testScheme = ColourScheme.derive(
brightness: Brightness.dark,
background: const Color(0xff303030),
foreground: const Color(0xffe6e6e6),
primary: const Color(0xff4772b3),
);
+69
View File
@@ -0,0 +1,69 @@
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// Every control's label is control type: density.fontSize at typography.medium.
// They sit next to each other in a popup or a form, so a menu row rendering a
// pixel larger and a weight lighter than the button beside it is a bug you can
// see. This pins them together.
void main() {
final theme = ThemeData(colorScheme: testScheme, density: const Density());
Future<TextStyle> resolve(WidgetTester t, Widget w) async {
await t.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: GarageTheme(
data: theme,
child: Center(child: SizedBox(width: 320, child: w)),
),
));
await t.pump();
final f = find.text("Label").first;
final widget = t.widget<Text>(f);
final inherited = DefaultTextStyle.of(t.element(f)).style;
return widget.style == null ? inherited : inherited.merge(widget.style);
}
final controls = <String, Widget>{
"Button": Button(
style: const ButtonStyle.ghost(),
onPressed: () {},
child: const Text("Label")),
"TextField": const TextField(placeholder: Text("Label")),
"Select": Select<String>(
value: "Label",
itemBuilder: (c, v) => Text(v),
popup: (c) => const SizedBox.shrink(),
onChanged: (_) {}),
"MenuButton": MenuPopup(
children: [MenuButton(onPressed: (_) {}, child: const Text("Label"))]),
};
for (final e in controls.entries) {
testWidgets("${e.key} label is control type", (t) async {
final s = await resolve(t, e.value);
expect(s.fontSize, theme.density.fontSize,
reason: "${e.key} should render at the control font");
expect(s.fontWeight, theme.typography.medium.fontWeight,
reason: "${e.key} should render at the control weight");
});
}
testWidgets("a menu row matches the button beside it exactly", (t) async {
final button = await resolve(t, controls["Button"]!);
final menu = await resolve(t, controls["MenuButton"]!);
expect(menu.fontSize, button.fontSize);
expect(menu.fontWeight, button.fontWeight);
expect(menu.color, button.color,
reason: "menu items read foreground, same slot as every other control");
});
testWidgets("a section label is heavier but the same size", (t) async {
final label = await resolve(
t, MenuPopup(children: const [MenuLabel(child: Text("Label"))]));
expect(label.fontSize, theme.density.fontSize);
expect(label.fontWeight, theme.typography.semiBold.fontWeight);
});
}
@@ -0,0 +1,56 @@
import "package:flutter/semantics.dart";
import "package:flutter/widgets.dart";
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/garage_ui.dart";
import "support/test_scheme.dart";
// with the semantics tree on, flutter web focuses a field by asking through
// semantics rather than by a tap on the field. It used to go unanswered.
Widget _host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(),
child: GarageTheme(
data: ThemeData(colorScheme: testScheme),
child: Overlay(
initialEntries: [
OverlayEntry(
builder: (_) => Center(child: SizedBox(width: 300, child: child)),
),
],
),
),
),
);
void main() {
for (final action in [SemanticsAction.tap, SemanticsAction.focus]) {
testWidgets("$action from semantics focuses the field", (tester) async {
final handle = tester.ensureSemantics();
final focus = FocusNode();
addTearDown(focus.dispose);
await tester.pumpWidget(_host(TextField(focusNode: focus)));
expect(focus.hasFocus, isFalse);
final node = tester.getSemantics(find.byType(EditableText));
node.owner!.performAction(node.id, action);
await tester.pump();
expect(focus.hasFocus, isTrue);
handle.dispose();
});
}
testWidgets("a disabled field doesnt offer to be focused", (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(_host(const TextField(enabled: false)));
final node = tester.getSemantics(find.byType(EditableText));
final data = node.getSemanticsData();
expect(data.hasAction(SemanticsAction.tap), isFalse);
expect(data.hasAction(SemanticsAction.focus), isFalse);
handle.dispose();
});
}
+43
View File
@@ -0,0 +1,43 @@
import "package:flutter_test/flutter_test.dart";
import "package:garage_ui/theme/theme_data.dart";
// The whole point of the ladder: text that isn't in a control has to stay in
// step with the control font. If someone retunes fontSize and textXs stops
// tracking it, a dialog title drifts away from the button under it again.
void main() {
for (final d in const [Density(), Density.normal()]) {
final name = d.control.name;
test("$name: textXs sits a step above the control font", () {
expect(d.textXs, greaterThan(d.fontSize),
reason: "dialog copy is meant to read larger than a button label");
});
test("$name: the ladder climbs and stays on whole pixels", () {
final steps = [d.textXs, d.textSm, d.textLg];
for (var i = 1; i < steps.length; i++) {
expect(steps[i], greaterThan(steps[i - 1]));
}
for (final s in steps) {
expect(s, s.roundToDouble(), reason: "$s is not a whole pixel");
}
});
}
test("the two larger tiers keep the values they replaced", () {
// these were hardcoded 12/14/18 - a retune of fontSize is meant to move
// them, but at the shipped densities they must not shift at all.
for (final d in const [Density(), Density.normal()]) {
expect(d.textXs, 11.0);
expect(d.textSm, 14.0);
expect(d.textLg, 18.0);
}
});
test("a retuned density carries the whole ladder with it", () {
const big = Density(fontSize: 20);
expect(big.textXs, 22.0);
expect(big.textSm, 28.0);
expect(big.textLg, 36.0);
});
}