bottleneckcrm/mobile/test/screens/phone_viewport_test.dart

6021 lines
204 KiB
Dart

import 'package:bottle_crm/data/models/access_token.dart';
import 'package:bottle_crm/data/models/auth_response.dart';
import 'package:bottle_crm/data/models/dashboard_data.dart';
import 'package:bottle_crm/providers/dashboard_provider.dart';
import 'package:bottle_crm/screens/dashboard/dashboard_screen.dart';
import 'package:bottle_crm/screens/settings/more_screen.dart';
import 'package:bottle_crm/data/models/approval_rule.dart';
import 'package:bottle_crm/data/models/app_notification.dart';
import 'package:bottle_crm/data/models/business_calendar.dart';
import 'package:bottle_crm/data/models/custom_field_definition.dart';
import 'package:bottle_crm/data/models/escalation_policy.dart';
import 'package:bottle_crm/data/models/macro.dart';
import 'package:bottle_crm/data/models/mailbox.dart';
import 'package:bottle_crm/data/models/org_settings.dart';
import 'package:bottle_crm/data/models/reopen_policy.dart';
import 'package:bottle_crm/data/models/invoice.dart';
import 'package:bottle_crm/data/models/time_entry.dart';
import 'package:bottle_crm/data/models/timesheet.dart';
import 'package:bottle_crm/data/models/estimate.dart';
import 'package:bottle_crm/data/models/product.dart';
import 'package:bottle_crm/data/models/recurring_invoice.dart';
import 'package:bottle_crm/data/models/lookup_models.dart';
import 'package:bottle_crm/data/models/routing_rule.dart';
import 'package:bottle_crm/data/models/tag.dart';
import 'package:bottle_crm/providers/invoice_extras_provider.dart';
import 'package:bottle_crm/providers/invoices_provider.dart';
import 'package:bottle_crm/providers/auth_provider.dart';
import 'package:bottle_crm/providers/lookup_provider.dart';
import 'package:bottle_crm/providers/notifications_provider.dart';
import 'package:bottle_crm/providers/support_provider.dart';
import 'package:bottle_crm/providers/settings_provider.dart';
import 'package:bottle_crm/providers/timesheet_provider.dart';
import 'package:bottle_crm/screens/invoices/estimates_list_screen.dart';
import 'package:bottle_crm/screens/invoices/invoices_list_screen.dart';
import 'package:bottle_crm/data/models/invoice_template.dart';
import 'package:bottle_crm/screens/invoices/invoice_template_form_screen.dart';
import 'package:bottle_crm/screens/invoices/invoice_templates_screen.dart';
import 'package:bottle_crm/screens/invoices/new_invoice_screen.dart';
import 'package:bottle_crm/screens/invoices/new_recurring_screen.dart';
import 'package:bottle_crm/screens/invoices/products_list_screen.dart';
import 'package:bottle_crm/screens/invoices/recurring_list_screen.dart';
import 'package:bottle_crm/screens/notifications/notifications_screen.dart';
import 'package:bottle_crm/screens/support/support_list_screen.dart';
import 'package:bottle_crm/screens/settings/api_token_form_sheet.dart';
import 'package:bottle_crm/screens/settings/api_tokens_screen.dart';
import 'package:bottle_crm/screens/settings/my_api_tokens_screen.dart';
import 'package:bottle_crm/screens/settings/approval_rule_form_sheet.dart';
import 'package:bottle_crm/screens/settings/approval_rules_screen.dart';
import 'package:bottle_crm/screens/settings/business_hours_form_sheet.dart';
import 'package:bottle_crm/screens/settings/business_hours_screen.dart';
import 'package:bottle_crm/screens/settings/custom_fields_screen.dart';
import 'package:bottle_crm/screens/settings/escalation_policy_form_sheet.dart';
import 'package:bottle_crm/screens/settings/escalation_screen.dart';
import 'package:bottle_crm/screens/settings/inbound_email_screen.dart';
import 'package:bottle_crm/screens/settings/macros_screen.dart';
import 'package:bottle_crm/screens/settings/mailbox_form_sheet.dart';
import 'package:bottle_crm/screens/settings/organization_edit_screen.dart';
import 'package:bottle_crm/screens/settings/organization_screen.dart';
import 'package:bottle_crm/screens/auth/org_create_screen.dart';
import 'package:bottle_crm/screens/tickets/close_with_children_dialog.dart';
import 'package:bottle_crm/screens/tickets/macro_picker_sheet.dart';
import 'package:bottle_crm/screens/settings/settings_hub_screen.dart';
import 'package:bottle_crm/screens/settings/reopen_screen.dart';
import 'package:bottle_crm/screens/settings/routing_rule_form_sheet.dart';
import 'package:bottle_crm/screens/settings/routing_screen.dart';
import 'package:bottle_crm/screens/settings/tags_screen.dart';
import 'package:bottle_crm/data/models/crm_document.dart';
import 'package:bottle_crm/data/models/sales_goal.dart';
import 'package:bottle_crm/providers/documents_provider.dart';
import 'package:bottle_crm/providers/goals_provider.dart';
import 'package:bottle_crm/screens/documents/documents_list_screen.dart';
import 'package:bottle_crm/screens/goals/goal_form_screen.dart';
import 'package:bottle_crm/screens/goals/goal_history_screen.dart';
import 'package:bottle_crm/screens/goals/goals_screen.dart';
import 'package:bottle_crm/screens/timesheet/timesheet_screen.dart';
import 'package:bottle_crm/data/models/custom_object.dart';
import 'package:bottle_crm/data/models/models.dart' show Lead;
import 'package:bottle_crm/providers/custom_objects_provider.dart';
import 'package:bottle_crm/providers/duplicates_provider.dart';
import 'package:bottle_crm/providers/leads_provider.dart';
import 'package:bottle_crm/screens/leads/lead_detail_screen.dart';
import 'package:bottle_crm/screens/objects/object_record_detail_screen.dart';
import 'package:bottle_crm/screens/objects/object_record_form_screen.dart';
import 'package:bottle_crm/screens/objects/object_records_screen.dart';
import 'package:bottle_crm/screens/settings/object_field_sheet.dart';
import 'package:bottle_crm/data/models/account.dart';
import 'package:bottle_crm/providers/accounts_provider.dart';
import 'package:bottle_crm/screens/accounts/account_detail_screen.dart';
import 'package:bottle_crm/screens/settings/modules_screen.dart';
import 'package:bottle_crm/screens/settings/object_settings_screen.dart';
import 'package:bottle_crm/screens/settings/objects_settings_screen.dart';
import 'package:bottle_crm/core/theme/theme.dart';
import 'package:bottle_crm/widgets/common/buttons.dart';
import 'package:bottle_crm/widgets/common/menu_row.dart';
import 'package:bottle_crm/widgets/common/module_gate.dart';
import '../support/modules_fixtures.dart';
import 'package:bottle_crm/data/models/automation.dart';
import 'package:bottle_crm/screens/settings/automation/action_sheet.dart';
import 'package:bottle_crm/screens/settings/automation/automation_rule_screen.dart';
import 'package:bottle_crm/screens/settings/automation/automation_rules_screen.dart';
import 'package:bottle_crm/screens/settings/automation/automation_runs_screen.dart';
import 'package:bottle_crm/screens/settings/automation/condition_sheet.dart';
import 'package:bottle_crm/screens/settings/automation/trigger_sheet.dart';
import 'package:bottle_crm/screens/settings/automation/try_rule_sheet.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:go_router/go_router.dart';
import '../support/automation_fixtures.dart';
/// The newest screens, rendered at a real phone.
///
/// The workspace rule is that a screen is not done until it holds up at 390px,
/// and that this is verified by rendering rather than by reading the layout
/// code. The default test surface is 800x600, which is wider than any phone
/// and therefore proves nothing about one: a Row that overflows at 390 fits
/// comfortably at 800 and the test stays green.
///
/// Flutter reports an overflow as a thrown FlutterError, so
/// `tester.takeException()` returning null is the assertion. It is checked at
/// two text scales, because a phone with large text is where a Row that only
/// just fits stops fitting.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
/// An iPhone 15-ish logical viewport, the narrow end of what ships today.
void usePhone(WidgetTester tester, {double textScale = 1.0}) {
tester.view.devicePixelRatio = 3.0;
tester.view.physicalSize = const Size(390 * 3, 844 * 3);
tester.platformDispatcher.textScaleFactorTestValue = textScale;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
}
/// The screen under a router that can answer a ticket push, so tapping a
/// row lands somewhere assertable instead of throwing.
Widget routed(Widget screen) => MaterialApp.router(
routerConfig: GoRouter(
initialLocation: '/here',
routes: [
GoRoute(path: '/here', builder: (_, _) => screen),
GoRoute(
path: '/tickets/:id',
builder: (_, s) => Text('ticket ${s.pathParameters['id']}'),
),
],
),
);
Widget timesheetApp() => ProviderScope(
overrides: [timesheetProvider.overrideWith(_FakeTimesheet.new)],
child: routed(const TimesheetScreen()),
);
/// [answer] null is a server with no custom objects, which is what the hub
/// must also render without a request of its own.
Widget settingsHubApp({CustomObjectsAnswer? answer, bool isAdmin = false}) =>
ProviderScope(
key: UniqueKey(),
overrides: [
customObjectsProvider.overrideWith(() => _FakeObjectsAnswer(answer)),
isOrgAdminProvider.overrideWithValue(isAdmin),
],
child: const MaterialApp(home: SettingsHubScreen()),
);
/// [isAdmin] is overridden rather than left to fall out of a signed-in
/// profile, because both answers matter here: the read is open to a member
/// and every write is admin-only.
Widget customFieldsApp({required bool isAdmin}) => ProviderScope(
overrides: [
customFieldsProvider.overrideWith(_FakeCustomFields.new),
isOrgAdminProvider.overrideWithValue(isAdmin),
],
child: const MaterialApp(home: CustomFieldsScreen()),
);
/// [myEmail] decides ownership of the personal rows, which is what the edit
/// control turns on. `myEmailProvider` normally reads the signed-in user, so
/// it is overridden here to drive both answers.
Widget macrosApp({required bool isAdmin, String? myEmail}) => ProviderScope(
overrides: [
macrosProvider.overrideWith(_FakeMacros.new),
isOrgAdminProvider.overrideWithValue(isAdmin),
myEmailProvider.overrideWithValue(myEmail),
],
child: const MaterialApp(home: MacrosScreen()),
);
/// Both answers again: a member reads the tag list, and every write on it is
/// admin-only.
Widget tagsApp({required bool isAdmin, bool empty = false}) => ProviderScope(
overrides: [
tagSettingsProvider.overrideWith(empty ? _FakeNoTags.new : _FakeTags.new),
isOrgAdminProvider.overrideWithValue(isAdmin),
],
child: const MaterialApp(home: TagsScreen()),
);
/// The routing list, whose fixture carries every state a rule can be in:
/// a live rotation with a deactivated member, a rule that can assign nobody,
/// and one already turned off.
Widget routingApp({required bool isAdmin, bool empty = false}) =>
ProviderScope(
overrides: [
routingRulesProvider.overrideWith(
empty ? _FakeNoRoutingRules.new : _FakeRoutingRules.new,
),
isOrgAdminProvider.overrideWithValue(isAdmin),
],
child: const MaterialApp(home: RoutingScreen()),
);
/// The rule form over a button, with the people and team pickers stubbed.
/// `people` deliberately omits the deactivated assignee, because the real
/// lookup does: it is the case where opening the form could silently drop
/// part of the rule.
Widget routingFormApp({RoutingRule? existing}) => ProviderScope(
overrides: [
usersProvider.overrideWithValue(const [
UserLookup(
id: 'a1',
email: 'ada@example.com',
name: 'Ada',
role: 'USER',
isActive: true,
),
]),
teamsProvider.overrideWithValue(const [
TeamLookup(id: 't1', name: 'Billing crew'),
]),
],
child: MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => Center(
child: ElevatedButton(
onPressed: () =>
showRoutingRuleFormSheet(context, existing: existing),
child: const Text('open'),
),
),
),
),
),
);
/// The escalation list. The fixture leaves Low unconfigured on purpose, so
/// the note about priorities with no policy at all has something to say.
Widget escalationApp({required bool isAdmin, bool empty = false}) =>
ProviderScope(
overrides: [
escalationProvider.overrideWith(
empty ? _FakeNoEscalation.new : _FakeEscalation.new,
),
isOrgAdminProvider.overrideWithValue(isAdmin),
],
child: const MaterialApp(home: EscalationScreen()),
);
/// The policy form over a button. `people` omits the deactivated target for
/// the same reason it does in the routing form: the real lookup returns
/// `Profile.objects.filter(is_active=True)`, so a policy pointing at somebody
/// turned off is the case where opening the form could empty it.
Widget escalationFormApp({
EscalationPolicy? existing,
List<String> available = const ['Urgent', 'Low'],
}) => ProviderScope(
overrides: [
usersProvider.overrideWithValue(const [
UserLookup(
id: 'a1',
email: 'ada@example.com',
name: 'Ada',
role: 'USER',
isActive: true,
),
]),
teamsProvider.overrideWithValue(const [
TeamLookup(id: 't1', name: 'Support'),
]),
],
child: MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => Center(
child: ElevatedButton(
onPressed: () => showEscalationPolicyFormSheet(
context,
existing: existing,
availablePriorities: available,
),
child: const Text('open'),
),
),
),
),
),
);
/// The business-hours screen. `alwaysOn` is the state the engine reads as
/// 24/7 rather than as permanently shut.
Widget businessHoursApp({
required bool isAdmin,
bool alwaysOn = false,
bool noHolidays = false,
}) => ProviderScope(
overrides: [
businessHoursProvider.overrideWith(
noHolidays
? _FakeNoHolidayCalendar.new
: alwaysOn
? _FakeClosedCalendar.new
: _FakeCalendar.new,
),
isOrgAdminProvider.overrideWithValue(isAdmin),
],
child: const MaterialApp(home: BusinessHoursScreen()),
);
/// The holiday sheet over a button, with a calendar that already carries
/// 25 December so the duplicate warning has something to warn about.
Widget holidayFormApp() => MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => Center(
child: ElevatedButton(
onPressed: () => showHolidayFormSheet(context, _calendar()),
child: const Text('open'),
),
),
),
),
);
Widget weekFormApp() => MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => Center(
child: ElevatedButton(
onPressed: () => showBusinessHoursFormSheet(context, _calendar()),
child: const Text('open'),
),
),
),
),
);
/// The API tokens screen. The list is admin-only server-side, so `isAdmin`
/// here decides whether the screen issues it at all.
Widget tokensApp({required bool isAdmin, bool orphaned = false}) =>
ProviderScope(
overrides: [
accessTokensProvider.overrideWith(
orphaned ? _FakeOrphanedTokens.new : _FakeTokens.new,
),
isOrgAdminProvider.overrideWithValue(isAdmin),
],
child: const MaterialApp(home: ApiTokensScreen()),
);
/// Your own tokens. No role override: the screen has no gate, because the
/// endpoint is open to every member and already answers only their rows.
Widget myTokensApp({bool empty = false}) => ProviderScope(
overrides: [
myAccessTokensProvider.overrideWith(
empty ? _FakeNoMyTokens.new : _FakeMyTokens.new,
),
],
child: const MaterialApp(home: MyApiTokensScreen()),
);
/// A button that opens the create sheet, so it renders the way it is really
/// reached: over a screen, with the keyboard inset in play.
Widget tokenFormApp() => MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showApiTokenFormSheet(context),
child: const Text('open'),
),
),
),
);
/// The inbound-email screen. The read is open to any member, so `isAdmin`
/// only decides which controls show.
Widget mailboxesApp({required bool isAdmin, bool allLive = false}) =>
ProviderScope(
overrides: [
mailboxesProvider.overrideWith(
allLive ? _FakeLiveMailboxes.new : _FakeMailboxes.new,
),
isOrgAdminProvider.overrideWithValue(isAdmin),
],
child: const MaterialApp(home: InboundEmailScreen()),
);
/// A button that opens the mailbox form, so it renders the way it is really
/// reached: over a screen, with the keyboard inset in play.
Widget mailboxFormApp() => ProviderScope(
child: MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showMailboxFormSheet(context),
child: const Text('open'),
),
),
),
),
);
/// The approval-rules screen. `shadowed` adds a second rule with identical
/// conditions, written later, which is what makes the first one dead.
Widget approvalRulesApp({required bool isAdmin, bool shadowed = false}) =>
ProviderScope(
overrides: [
approvalRulesProvider.overrideWith(
shadowed ? _FakeShadowedRules.new : _FakeApprovalRules.new,
),
isOrgAdminProvider.overrideWithValue(isAdmin),
],
child: const MaterialApp(home: ApprovalRulesScreen()),
);
Widget approvalRuleFormApp() => ProviderScope(
child: MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showApprovalRuleFormSheet(context),
child: const Text('open'),
),
),
),
),
);
/// The organization screen. The read is open to any member, so `isAdmin` only
/// decides which controls show.
Widget organizationApp({required bool isAdmin, bool cascadeOn = false}) =>
ProviderScope(
overrides: [
orgSettingsProvider.overrideWith(
cascadeOn ? _FakeOrgCascadeOn.new : _FakeOrg.new,
),
orgPacksProvider.overrideWith(
(ref) async => [
VerticalPack.fromJson(const {
'id': 'real-estate',
'name': 'Real estate',
'description': 'Listings, viewings and offers',
'version': '1',
}),
],
),
isOrgAdminProvider.overrideWithValue(isAdmin),
],
child: const MaterialApp(home: OrganizationScreen()),
);
Widget organizationEditApp({required bool isAdmin}) => ProviderScope(
overrides: [
orgSettingsProvider.overrideWith(_FakeOrg.new),
orgTimezonesProvider.overrideWith(
(ref) async => const [
TimezoneOption(name: 'Asia/Kolkata', offsetMinutes: 330),
TimezoneOption(name: 'UTC', offsetMinutes: 0),
],
),
isOrgAdminProvider.overrideWithValue(isAdmin),
],
child: const MaterialApp(home: OrganizationEditScreen()),
);
/// The reopen screen. The read is admin-only server-side, so `isAdmin` here
/// decides whether the screen issues it at all.
Widget reopenApp({required bool isAdmin, bool policyOff = false}) =>
ProviderScope(
overrides: [
reopenPolicyProvider.overrideWith(
policyOff ? _FakeReopenOff.new : _FakeReopen.new,
),
isOrgAdminProvider.overrideWithValue(isAdmin),
],
child: const MaterialApp(home: ReopenScreen()),
);
/// A button that opens the picker, so the sheet is rendered the way it is
/// really reached: over a screen, with the keyboard inset in play.
Widget macroPickerApp({List<Macro>? macros}) => ProviderScope(
overrides: [
activeMacrosProvider.overrideWith(
(ref) async =>
macros ??
[
Macro.fromJson(const {
'id': 'm1',
'title': 'Password reset, standard wording agreed with legal',
'body':
'Hi there, follow the link below to reset your password '
'and let us know if it does not arrive within ten minutes.',
'scope': 'org',
'is_active': true,
}),
Macro.fromJson(const {
'id': 'm2',
'title': 'My follow-up',
'body': 'Just checking in on this one.',
'scope': 'personal',
'owner_name': 'me@example.com',
'is_active': true,
}),
],
),
],
child: MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => Center(
child: ElevatedButton(
onPressed: () => showMacroPickerSheet(context, 't42'),
child: const Text('open'),
),
),
),
),
),
);
Widget notificationsApp() => ProviderScope(
overrides: [notificationsProvider.overrideWith(_FakeNotifications.new)],
child: routed(const NotificationsScreen()),
);
/// The four automation verbs, plus one whose `data` has the wrong type, under
/// a router that answers the destinations they resolve to.
Widget automationFeedApp() => ProviderScope(
overrides: [
notificationsProvider.overrideWith(_FakeAutomationNotifications.new),
],
child: MaterialApp.router(
routerConfig: GoRouter(
initialLocation: '/here',
routes: [
GoRoute(
path: '/here',
builder: (_, _) => const NotificationsScreen(),
),
GoRoute(
path: '/deals/:id',
builder: (_, s) => Text('deal ${s.pathParameters['id']}'),
),
GoRoute(
path: '/more/settings/automation',
builder: (_, _) => const Text('the rules'),
),
GoRoute(
path: '/more/settings/automation/:ruleId',
builder: (_, s) => Text('rule ${s.pathParameters['ruleId']}'),
),
],
),
),
);
Widget invoicesApp({bool mixedCurrency = false}) => ProviderScope(
overrides: [
invoicesProvider.overrideWith(
mixedCurrency ? _FakeMixedInvoices.new : _FakeInvoices.new,
),
],
child: routed(const InvoicesListScreen()),
);
Widget estimatesApp() => ProviderScope(
overrides: [estimatesProvider.overrideWith(_FakeEstimates.new)],
child: routed(const EstimatesListScreen()),
);
Widget recurringApp() => ProviderScope(
overrides: [recurringProvider.overrideWith(_FakeRecurring.new)],
child: routed(const RecurringListScreen()),
);
/// The form with the pickers stubbed empty. It still has to lay out: an
/// org with no accounts yet is a real state, and it is the one a new user
/// hits first.
Widget newInvoiceApp() => ProviderScope(
overrides: [productsProvider.overrideWith(_FakeProducts.new)],
child: routed(const NewInvoiceScreen()),
);
Widget newRecurringApp() => ProviderScope(
overrides: [productsProvider.overrideWith(_FakeProducts.new)],
child: routed(const NewRecurringScreen()),
);
Widget productsApp() => ProviderScope(
overrides: [productsProvider.overrideWith(_FakeProducts.new)],
child: routed(const ProductsListScreen()),
);
/// Both answers matter: reading the catalogue is open to any member, and
/// every write on a template is admin-only.
Widget templatesApp({required bool isAdmin}) => ProviderScope(
overrides: [
invoiceTemplatesProvider.overrideWith(_FakeTemplates.new),
isOrgAdminProvider.overrideWithValue(isAdmin),
],
child: routed(const InvoiceTemplatesScreen()),
);
/// The form over the same catalogue. [templateId] null creates; an id edits,
/// and the fixture is what it pre-fills from, since the form reads the list
/// row rather than making a detail call.
Widget templateFormApp({String? templateId}) => ProviderScope(
overrides: [invoiceTemplatesProvider.overrideWith(_FakeTemplates.new)],
child: routed(InvoiceTemplateFormScreen(templateId: templateId)),
);
Future<void> pump(
WidgetTester tester,
Widget app, {
double textScale = 1.0,
}) async {
usePhone(tester, textScale: textScale);
await tester.pumpWidget(app);
await tester.pumpAndSettle();
}
/// Scroll a long form until [target] is built, or give up.
///
/// A `ListView` builds only what is near the viewport, so on a 390x844 phone
/// a card below the fold is not merely off-screen, it is absent from the
/// tree. That makes a bare `findsNothing` on it worthless: it passes for a
/// field that exists and one that does not. Giving up quietly is right, since
/// the tests that assert absence want to reach the bottom and find nothing.
///
/// Dragging rather than `scrollUntilVisible`: every `TextField` on the form
/// carries a `Scrollable` of its own, so the implicit scrollable lookup is
/// ambiguous here. `find.byType(ListView)` is not.
/// [step] is negative to go down the form and positive to come back up.
Future<void> scrollForm(
WidgetTester tester,
Finder target, {
double step = -320,
}) async {
for (var i = 0; i < 10 && target.evaluate().isEmpty; i++) {
await tester.drag(find.byType(ListView), Offset(0, step));
await tester.pumpAndSettle();
}
}
Future<void> submitForm(WidgetTester tester, String label) async {
final button = find.widgetWithText(FilledButton, label);
await scrollForm(tester, button);
await tester.tap(button);
await tester.pumpAndSettle();
}
/// Every object screen is checked at its fullest: the eight-field Property
/// with long labels, four link fields (one holding links the viewer cannot
/// open), three photos and two files.
Future<void> scrollToEnd(WidgetTester tester) async {
for (var i = 0; i < 8; i++) {
await tester.drag(find.byType(Scrollable).first, const Offset(0, -500));
await tester.pumpAndSettle();
}
}
Widget objRecordsApp({bool writes = true}) => ProviderScope(
overrides: [
customObjectsProvider.overrideWith(
() => _FakeObjectsAnswer(_objAnswer(writes: writes)),
),
objectDefinitionProvider.overrideWith((ref, key) async => _objDefinition),
objectRecordsProvider(
_objKey,
).overrideWith(() => _FakeObjectRecords(_objKey, _objRecordsData)),
],
child: routed(const ObjectRecordsScreen(objectKey: _objKey)),
);
Widget objDetailApp({bool canEdit = true, bool canDelete = true}) =>
ProviderScope(
overrides: [
objectDefinitionProvider.overrideWith(
(ref, key) async => _objDefinition,
),
customObjectsApiProvider.overrideWithValue(
_FakeObjectsApi(_objRecordJson(canEdit, canDelete)),
),
attachmentBytesProvider.overrideWith((ref, id) async => null),
],
child: routed(
const ObjectRecordDetailScreen(objectKey: _objKey, recordId: _objId),
),
);
Widget objFormApp({String? recordId}) => ProviderScope(
overrides: [
customObjectsProvider.overrideWith(
() => _FakeObjectsAnswer(_objAnswer(writes: true)),
),
objectDefinitionProvider.overrideWith((ref, key) async => _objDefinition),
customObjectsApiProvider.overrideWithValue(
_FakeObjectsApi(_objRecordJson(true, true)),
),
usersProvider.overrideWithValue(const [
UserLookup(
id: 'p1',
email: 'ravi@example.com',
name: 'Ravi Shankar Venkataraghavan',
role: 'USER',
isActive: true,
),
]),
],
child: routed(
ObjectRecordFormScreen(objectKey: _objKey, recordId: recordId),
),
);
Widget objSettingsListApp({required bool admin, required bool writes}) =>
ProviderScope(
overrides: [
isOrgAdminProvider.overrideWithValue(admin),
customObjectsProvider.overrideWith(
() => _FakeObjectsAnswer(_objAnswer(writes: writes)),
),
objectsSettingsProvider.overrideWith(
(ref) async => _objAnswer(writes: writes),
),
],
child: routed(const ObjectsSettingsScreen()),
);
Widget objSettingsApp({
required bool admin,
required bool writes,
bool archived = false,
}) => ProviderScope(
overrides: [
isOrgAdminProvider.overrideWithValue(admin),
customObjectsProvider.overrideWith(
() => _FakeObjectsAnswer(_objAnswer(writes: writes)),
),
objectSettingsProvider(_objKey).overrideWith(
(ref) async => ObjectSettingsData(
summary: archived ? _objSummaryArchived : _objSummary,
fields: _objFields,
),
),
],
child: routed(const ObjectSettingsScreen(objectKey: _objKey)),
);
Widget objFieldSheetApp(ObjectField? existing) => ProviderScope(
child: routed(
Scaffold(
body: Builder(
builder: (context) => Center(
child: ElevatedButton(
onPressed: () => showObjectFieldSheet(
context,
existing: existing,
linkTargets: _objAnswer(writes: true).linkTargets,
onSubmit: (_) async => null,
),
child: const Text('open'),
),
),
),
),
),
);
Widget objLeadApp() => ProviderScope(
overrides: [
leadsProvider.overrideWith(_FakeObjectLead.new),
duplicatesApiProvider.overrideWithValue(_NoObjectDuplicates()),
isOrgAdminProvider.overrideWithValue(true),
currentUserProvider.overrideWithValue(null),
customObjectsProvider.overrideWith(
() => _FakeObjectsAnswer(_objAnswer(writes: true)),
),
relatedProvider((to: 'lead', id: 'l1')).overrideWith(
(ref) async => RelatedAnswer(
canEdit: true,
groups: [
RelatedGroup(
objectKey: _objKey,
objectLabel: _objLong60,
fieldKey: 'agent',
fieldLabel: _objLong64,
records: const [
RelatedRecordRef(
id: _objId,
name: '12 Harbour Road, Old Town Quarter, Northern District',
),
],
),
],
linkFields: [
RelatedLinkField(
objectKey: _objKey,
objectLabel: _objLong60,
fieldKey: 'agent',
fieldLabel: _objLong64,
),
],
),
),
],
child: const MaterialApp(home: LeadDetailScreen(leadId: 'l1')),
);
group('timesheet at 390px', () {
testWidgets('renders a week without overflowing', (tester) async {
await pump(tester, timesheetApp());
expect(tester.takeException(), isNull);
});
testWidgets('still fits with the system font scaled up', (tester) async {
await pump(tester, timesheetApp(), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('draws the day nothing was logged on', (tester) async {
await pump(tester, timesheetApp());
// The whole reason this screen exists. A week that renders only the days
// with entries makes a gap and a quiet day look identical.
expect(find.text('Nothing logged'), findsOneWidget);
// findRichText, because the day header is spans: the weekday and the
// date share one Text so the row can ellipsize as a unit.
expect(
find.textContaining('Wednesday', findRichText: true),
findsOneWidget,
);
});
testWidgets('offers Stop on the running timer, thumb-sized', (
tester,
) async {
await pump(tester, timesheetApp());
final stop = find.widgetWithText(FilledButton, 'Stop timer');
expect(stop, findsOneWidget);
// The primary action on this screen, so it has to be reachable rather
// than merely present.
expect(tester.getSize(stop).height, greaterThanOrEqualTo(44));
});
testWidgets('week navigation has 44px targets', (tester) async {
await pump(tester, timesheetApp());
for (final tip in ['Previous week', 'Next week']) {
final size = tester.getSize(find.byTooltip(tip));
expect(size.width, greaterThanOrEqualTo(44), reason: tip);
expect(size.height, greaterThanOrEqualTo(44), reason: tip);
}
});
});
group('help at 390px', () {
/// Help has two tiers and the phone has to hold both. The self-serve tier
/// is what a community deployment serves, and it is the one a stuck user
/// on a narrow screen is most likely to see, so it is checked here rather
/// than assumed from the ticket list passing.
Widget helpApp({required bool available}) => ProviderScope(
overrides: [
supportTicketsProvider.overrideWith(
available ? _FakeEmptyQueue.new : _FakeNoQueue.new,
),
],
child: routed(const SupportListScreen()),
);
testWidgets('with no support queue it offers the routes that do work', (
tester,
) async {
await pump(tester, helpApp(available: false));
expect(find.text('Knowledge base'), findsOneWidget);
expect(find.text('Email support@bottlecrm.io'), findsOneWidget);
// No queue means no way to open a ticket, so the button must be gone.
expect(find.text('New ticket'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('with a queue it offers a ticket instead of an inbox address', (
tester,
) async {
await pump(tester, helpApp(available: true));
expect(find.text('New ticket'), findsOneWidget);
expect(find.text('Email support@bottlecrm.io'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('the self-serve tier fits with the system font scaled up', (
tester,
) async {
await pump(tester, helpApp(available: false), textScale: 1.3);
expect(tester.takeException(), isNull);
});
});
group('notifications at 390px', () {
testWidgets('renders the feed without overflowing', (tester) async {
await pump(tester, notificationsApp());
expect(tester.takeException(), isNull);
});
testWidgets('still fits with the system font scaled up', (tester) async {
await pump(tester, notificationsApp(), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('opens unread and switches to all', (tester) async {
await pump(tester, notificationsApp());
expect(find.textContaining('mentioned you on'), findsOneWidget);
expect(find.textContaining('sla breached'), findsNothing);
await tester.tap(find.text('All'));
await tester.pumpAndSettle();
// The read row, which the unread filter was hiding.
expect(find.textContaining('sla breached'), findsOneWidget);
});
testWidgets('a row with a resolvable link opens its ticket', (
tester,
) async {
await pump(tester, notificationsApp());
await tester.tap(find.textContaining('mentioned you on'));
await tester.pumpAndSettle();
expect(find.text('ticket case-9'), findsOneWidget);
});
});
group('automation notifications at 390px', () {
Future<void> reveal(WidgetTester tester, Finder target) async {
await tester.scrollUntilVisible(
target,
200,
scrollable: find.byType(Scrollable).first,
);
await tester.ensureVisible(target.first);
await tester.pumpAndSettle();
}
Finder rowOf(Finder text) =>
find.ancestor(of: text, matching: find.byType(InkWell)).first;
const message =
'Harbour Lane Logistics is won. Please review the handover notes and '
'confirm the kickoff date with the customer today.';
const markup = '<img src=x onerror=alert(1)> [open](javascript:x)';
const failed =
'The rule "$longRuleName" failed: '
"That is not a stage of this deal's pipeline.";
const paused =
'Paused because the admin who switched it on left the organization. '
'Paused: New lead: rotate owners, $longRuleName.';
const limit =
'Automation reached its limit of 1,000 runs in the hour from '
"15 Jan 2025 14:00. The rest of that hour's runs were skipped.";
for (final scale in [1.0, 1.3, 1.5]) {
testWidgets('every verb fits, text scale $scale', (tester) async {
await pump(tester, automationFeedApp(), textScale: scale);
expect(tester.takeException(), isNull);
await scrollToEnd(tester);
expect(tester.takeException(), isNull);
});
}
testWidgets('each row says who, what happened and which rule', (
tester,
) async {
await pump(tester, automationFeedApp());
expect(
find.descendant(
of: rowOf(find.text(message)),
matching: find.text('asha.rao@example.com'),
),
findsOneWidget,
);
expect(find.text(message), findsOneWidget);
expect(find.text('From the rule "$longRuleName"'), findsOneWidget);
for (final text in [failed, paused, limit]) {
await reveal(tester, find.text(text));
expect(find.text(text), findsOneWidget, reason: text);
expect(
find.descendant(
of: rowOf(find.text(text)),
matching: find.textContaining('The system'),
),
findsOneWidget,
reason: text,
);
}
});
testWidgets('markup in a message and a rule name shows as typed', (
tester,
) async {
await pump(tester, automationFeedApp());
await reveal(tester, find.text(markup));
expect(find.text(markup), findsOneWidget);
expect(find.text('From the rule "<b>Rule</b>"'), findsOneWidget);
});
testWidgets('each verb carries its icon', (tester) async {
await pump(tester, automationFeedApp());
final icons = {
message: LucideIcons.zap,
markup: LucideIcons.zap,
failed: LucideIcons.triangleAlert,
paused: LucideIcons.circlePause,
limit: LucideIcons.triangleAlert,
};
for (final e in icons.entries) {
await reveal(tester, find.text(e.key));
expect(
find.descendant(
of: rowOf(find.text(e.key)),
matching: find.byIcon(e.value),
),
findsOneWidget,
reason: e.key,
);
}
});
testWidgets('a message opens its deal', (tester) async {
await pump(tester, automationFeedApp());
await tester.tap(find.text(message));
await tester.pumpAndSettle();
expect(find.text('deal $autoDealId'), findsOneWidget);
});
testWidgets('a failed rule opens that rule', (tester) async {
await pump(tester, automationFeedApp());
await reveal(tester, find.text(failed));
await tester.tap(find.text(failed));
await tester.pumpAndSettle();
expect(find.text('rule $autoRuleId'), findsOneWidget);
});
testWidgets('a pause and the limit open the rules list', (tester) async {
for (final text in [paused, limit]) {
await pump(tester, automationFeedApp());
await reveal(tester, find.text(text));
await tester.tap(find.text(text));
await tester.pumpAndSettle();
expect(find.text('the rules'), findsOneWidget, reason: text);
}
});
testWidgets('a link that is not the app\'s opens nothing', (tester) async {
await pump(tester, automationFeedApp());
await reveal(tester, find.text(markup));
await tester.tap(find.text(markup));
await tester.pumpAndSettle();
expect(find.byType(NotificationsScreen), findsOneWidget);
expect(find.textContaining('deal '), findsNothing);
});
testWidgets('data of the wrong type says a rule sent something', (
tester,
) async {
const fallback = 'An automation rule sent a notification.';
await pump(tester, automationFeedApp());
await reveal(tester, find.text(fallback));
expect(find.text(fallback), findsWidgets);
expect(find.textContaining('The system failed'), findsNothing);
expect(
find.textContaining('a ticket that no longer has a name'),
findsNothing,
);
await tester.tap(find.text(fallback).first);
await tester.pumpAndSettle();
expect(find.text('the rules'), findsOneWidget);
});
testWidgets('an automation verb with no copy yet says a rule sent it', (
tester,
) async {
const fallback = 'An automation rule sent a notification.';
final note = find.text('automation.digest has no producer');
await pump(tester, automationFeedApp());
await reveal(tester, note);
for (final matching in [
find.text(fallback),
find.byIcon(LucideIcons.bell),
find.text('The system'),
]) {
expect(
find.descendant(of: rowOf(note), matching: matching),
findsOneWidget,
reason: '$matching',
);
}
expect(find.textContaining('The system digest'), findsNothing);
});
testWidgets('the empty feed names automation rules as a source', (
tester,
) async {
await pump(
tester,
ProviderScope(
overrides: [
notificationsProvider.overrideWith(_FakeEmptyNotifications.new),
],
child: routed(const NotificationsScreen()),
),
);
expect(
find.text(
'Notifications arrive for CRM ticket activity, automation rules, '
'and updates from BottleCRM Support.',
),
findsOneWidget,
);
});
testWidgets('no entity name follows the sentence', (tester) async {
await pump(tester, automationFeedApp());
expect(find.text(longRecordName), findsNothing);
expect(find.textContaining(longRecordName), findsNothing);
for (final text in [failed, paused]) {
await reveal(tester, find.text(text));
expect(
find.descendant(
of: rowOf(find.text(text)),
matching: find.textContaining('New lead: rotate owners'),
),
text == paused ? findsOneWidget : findsNothing,
reason: text,
);
expect(
find.descendant(
of: rowOf(find.text(text)),
matching: find.textContaining(longRuleName),
),
findsOneWidget,
reason: text,
);
}
});
testWidgets('the sentence takes the link colour only where it opens', (
tester,
) async {
await pump(tester, automationFeedApp());
expect(
tester.widget<Text>(find.text(message)).style?.color,
AppColors.primary600,
);
await reveal(tester, find.text(markup));
expect(
tester.widget<Text>(find.text(markup)).style?.color,
isNot(AppColors.primary600),
);
});
});
group('invoices at 390px', () {
testWidgets('renders the list without overflowing', (tester) async {
await pump(tester, invoicesApp());
expect(tester.takeException(), isNull);
});
testWidgets('still fits with the system font scaled up', (tester) async {
// Where the web's seven-column table would have given up. A long account
// name beside an amount, and "Partially paid" beside "912d late", are
// both wider than they look at the default 800px test surface.
await pump(tester, invoicesApp(), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('offers Send only on the invoices the API would accept', (
tester,
) async {
await pump(tester, invoicesApp());
// Draft gets Send, Overdue gets the reminder wording, and the paid one
// gets neither: the API answers 400 on a paid invoice.
expect(find.widgetWithText(OutlinedButton, 'Send'), findsOneWidget);
expect(
find.widgetWithText(OutlinedButton, 'Send a reminder'),
findsOneWidget,
);
expect(find.byType(OutlinedButton), findsNWidgets(2));
});
testWidgets('the send button is thumb-sized', (tester) async {
await pump(tester, invoicesApp());
final send = find.widgetWithText(OutlinedButton, 'Send');
expect(tester.getSize(send).height, greaterThanOrEqualTo(40));
});
testWidgets('a paid invoice shows no age, however old its due date', (
tester,
) async {
await pump(tester, invoicesApp());
// INV-0003 is Paid with a due date in 2020. "d late" against it would be
// wrong, and v1 printed exactly that.
expect(find.textContaining('d late'), findsOneWidget);
});
testWidgets('one currency puts a symbol on the header figures', (
tester,
) async {
await pump(tester, invoicesApp());
expect(find.textContaining('\$'), findsWidgets);
expect(find.textContaining('no exchange rates'), findsNothing);
});
testWidgets('two currencies get a line each and say why', (tester) async {
await pump(tester, invoicesApp(mixedCurrency: true));
// One figure per currency, each under its own symbol; never a sum.
expect(find.textContaining('€100 outstanding'), findsOneWidget);
expect(find.textContaining('\$100 outstanding'), findsOneWidget);
expect(find.textContaining('200'), findsNothing);
expect(find.textContaining('no exchange rates'), findsOneWidget);
expect(tester.takeException(), isNull);
});
});
group('the sibling invoice pages at 390px', () {
testWidgets('estimates render without overflowing', (tester) async {
await pump(tester, estimatesApp());
expect(tester.takeException(), isNull);
});
testWidgets('estimates fit with the system font scaled up', (tester) async {
await pump(tester, estimatesApp(), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('a converted estimate offers the invoice, not a conversion', (
tester,
) async {
await pump(tester, estimatesApp());
// One unconverted row offers it; the converted row offers the invoice.
expect(
find.widgetWithText(FilledButton, 'Raise an invoice'),
findsOneWidget,
);
expect(
find.widgetWithText(OutlinedButton, 'Open the invoice'),
findsOneWidget,
);
expect(find.textContaining('billed INV-0009'), findsOneWidget);
});
testWidgets('recurring renders without overflowing', (tester) async {
await pump(tester, recurringApp());
expect(tester.takeException(), isNull);
});
testWidgets('recurring fits with the system font scaled up', (
tester,
) async {
await pump(tester, recurringApp(), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('a paused schedule shows no next run date', (tester) async {
await pump(tester, recurringApp());
// The running one does; the paused one keeps the date on its row but
// must not print it, or it promises an invoice that is not coming.
expect(find.textContaining('next '), findsOneWidget);
expect(find.text('Pause'), findsOneWidget);
expect(find.text('Resume'), findsOneWidget);
});
testWidgets('a custom cadence shows its interval', (tester) async {
await pump(tester, recurringApp());
expect(find.text('Every 10 days'), findsOneWidget);
});
testWidgets('products render without overflowing', (tester) async {
await pump(tester, productsApp());
expect(tester.takeException(), isNull);
});
testWidgets('products fit with the system font scaled up', (tester) async {
await pump(tester, productsApp(), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('a non-admin sees no product write buttons', (tester) async {
// isOrgAdminProvider is false with no signed-in profile, which is the
// state this test runs in. The server refuses these writes with a 403,
// so offering them would be offering a guaranteed failure.
await pump(tester, productsApp());
expect(find.widgetWithText(OutlinedButton, 'Edit'), findsNothing);
expect(find.widgetWithText(OutlinedButton, 'Delete'), findsNothing);
expect(find.byTooltip('New product'), findsNothing);
});
testWidgets('a retired product still shows what it was billed on', (
tester,
) async {
await pump(tester, productsApp());
expect(find.text('Retired'), findsOneWidget);
expect(find.textContaining('on 12 invoices'), findsOneWidget);
});
});
group('invoice templates at 390px', () {
testWidgets('the catalogue renders without overflowing', (tester) async {
await pump(tester, templatesApp(isAdmin: true));
expect(tester.takeException(), isNull);
});
testWidgets('the catalogue fits with the system font scaled up', (
tester,
) async {
await pump(tester, templatesApp(isAdmin: true), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('a member sees the catalogue and no write control', (
tester,
) async {
// Every template write answers 403 to a member, so drawing one would be
// drawing a guaranteed failure. The read stays open: knowing which
// template an invoice prints in is useful to whoever raised it.
await pump(tester, templatesApp(isAdmin: false));
expect(find.text('House style'), findsOneWidget);
expect(find.byTooltip('New template'), findsNothing);
expect(find.byTooltip('Edit template'), findsNothing);
expect(
find.widgetWithText(OutlinedButton, 'Use for new invoices'),
findsNothing,
);
});
testWidgets('an admin gets a way in on every row', (tester) async {
await pump(tester, templatesApp(isAdmin: true));
expect(find.byTooltip('New template'), findsOneWidget);
expect(find.byTooltip('Edit template'), findsNWidgets(2));
});
testWidgets('the template already holding the default is not offered it', (
tester,
) async {
// Setting the default is a swap the model performs in one transaction,
// so there is no call that turns the flag off. Offering it on the row
// that holds it would be offering to leave the org without a default.
await pump(tester, templatesApp(isAdmin: true));
expect(
find.widgetWithText(OutlinedButton, 'Use for new invoices'),
findsOneWidget,
);
});
testWidgets('the form renders without overflowing', (tester) async {
await pump(tester, templateFormApp());
expect(tester.takeException(), isNull);
});
testWidgets('the form fits with the system font scaled up', (tester) async {
await pump(tester, templateFormApp(), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('editing pre-fills from the catalogue row', (tester) async {
await pump(tester, templateFormApp(templateId: 't1'));
expect(find.text('House style'), findsOneWidget);
expect(find.text('#3B82F6'), findsOneWidget);
await scrollForm(tester, find.text('Thanks, as ever.'));
expect(find.text('Thanks, as ever.'), findsOneWidget);
expect(find.text('Settle within 14 days.'), findsOneWidget);
});
testWidgets('the form says where the layout is edited instead', (
tester,
) async {
// `template_html` and `template_css` stay on the admin-only editor
// route: the phone neither reads nor writes them, which is why a save
// from here cannot blank the layout the web editor stored. Saying
// nothing about that would read as "this form is the whole template".
await pump(tester, templateFormApp(templateId: 't1'));
await scrollForm(tester, find.textContaining('edited on the web'));
expect(find.textContaining('edited on the web'), findsOneWidget);
expect(find.widgetWithText(TextField, 'Custom HTML'), findsNothing);
expect(find.widgetWithText(TextField, 'Custom CSS'), findsNothing);
});
testWidgets('the default switch is on create only', (tester) async {
// Carrying an off switch through an unrelated edit would demote the
// org's current default and leave it with none, so the edit form does
// not own the field at all. The web splits its two forms the same way.
await pump(tester, templateFormApp());
await scrollForm(tester, find.text('Use this for new invoices'));
expect(find.text('Use this for new invoices'), findsOneWidget);
await pump(tester, templateFormApp(templateId: 't1'));
await scrollForm(tester, find.text('Use this for new invoices'));
expect(find.text('Use this for new invoices'), findsNothing);
});
testWidgets('a name is refused where the thumb already is', (tester) async {
// The submit button is below the fold on a phone, so lighting only the
// name field's own error, several hundred pixels up, reads as a tap that
// did nothing. Both halves have to be there: the line by the button, and
// the field it points at.
await pump(tester, templateFormApp());
await submitForm(tester, 'Create template');
expect(find.text('Check the highlighted fields above.'), findsOneWidget);
await scrollForm(
tester,
find.text('Give the template a name'),
step: 320,
);
expect(find.text('Give the template a name'), findsOneWidget);
});
testWidgets('a colour that is not hex is refused the same way', (
tester,
) async {
// The serializer refuses it too, which is the check that counts. This is
// the round trip saved, and the reason the swatches are there.
await pump(tester, templateFormApp());
await tester.enterText(
find.widgetWithText(TextField, 'Template name'),
'Brand',
);
await tester.enterText(find.widgetWithText(TextField, 'Primary'), 'red');
await submitForm(tester, 'Create template');
expect(find.text('Check the highlighted fields above.'), findsOneWidget);
await scrollForm(
tester,
find.textContaining('six digit hex colour'),
step: 320,
);
expect(find.textContaining('six digit hex colour'), findsOneWidget);
});
testWidgets('tapping a swatch fills the field with its hex', (
tester,
) async {
// Typing `#3B82F6` on a soft keyboard to change a brand colour is what
// the swatches exist to avoid, so the tap has to land in the field the
// payload reads.
await pump(tester, templateFormApp());
await tester.tap(find.bySemanticsLabel('#16A34A').first);
await tester.pumpAndSettle();
expect(find.text('#16A34A'), findsOneWidget);
});
});
group('the new invoice form at 390px', () {
testWidgets('renders without overflowing', (tester) async {
await pump(tester, newInvoiceApp());
expect(tester.takeException(), isNull);
});
testWidgets('fits with the system font scaled up', (tester) async {
// The densest form in the app: two dropdowns, a date field beside a
// terms dropdown, and a pinned total bar with a button on it.
await pump(tester, newInvoiceApp(), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('will not submit until it has what the API requires', (
tester,
) async {
await pump(tester, newInvoiceApp());
// account_id, contact_id, invoice_title and at least one line are all
// required; nothing has been filled, so the button must be inert.
final button = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Create draft'),
);
expect(button.onPressed, isNull);
});
testWidgets('the contact picker waits for an account', (tester) async {
await pump(tester, newInvoiceApp());
// Pairing a contact with the wrong account is a 400, so the form asks
// for the account that narrows the list first.
expect(find.text('Pick an account first'), findsOneWidget);
});
testWidgets('says the running total includes tax and discount', (
tester,
) async {
await pump(tester, newInvoiceApp());
// Discount, tax and shipping are on this form now (PARITY B5), so the
// figure is the whole ladder and says so.
expect(find.text('with discount, tax and shipping'), findsOneWidget);
});
testWidgets('the create button is thumb-sized', (tester) async {
await pump(tester, newInvoiceApp());
final size = tester.getSize(
find.widgetWithText(FilledButton, 'Create draft'),
);
expect(size.height, greaterThanOrEqualTo(44));
});
});
group('the new schedule form at 390px', () {
testWidgets('renders without overflowing', (tester) async {
await pump(tester, newRecurringApp());
expect(tester.takeException(), isNull);
});
testWidgets('fits with the system font scaled up', (tester) async {
await pump(tester, newRecurringApp(), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('asks for the interval only on a custom cadence', (
tester,
) async {
await pump(tester, newRecurringApp());
// Monthly by default, so the field is absent.
expect(find.text('Every how many days'), findsNothing);
await tester.tap(find.text('Monthly').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Custom').last);
await tester.pumpAndSettle();
expect(find.text('Every how many days'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('a bad interval is called out on the field itself', (
tester,
) async {
// The submit button proves nothing here: it is disabled anyway while the
// account, contact, title and lines are empty, so asserting on it would
// pass with the cadence rule deleted. `cadenceIsComplete` is unit-tested
// instead; this covers the message the user actually sees.
await pump(tester, newRecurringApp());
await tester.tap(find.text('Monthly').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Custom').last);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).last, '0');
await tester.pumpAndSettle();
expect(find.text('Enter a number of days, at least 1'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('says how often the amount recurs, not just the amount', (
tester,
) async {
await pump(tester, newRecurringApp());
// A figure with no cadence beside it is the one number this screen
// must never show: it reads as a one-off total.
expect(
find.textContaining('monthly, with discounts and tax'),
findsOneWidget,
);
});
});
group('the settings cluster at 390px', () {
testWidgets('the hub renders without overflowing', (tester) async {
await pump(tester, settingsHubApp());
expect(tester.takeException(), isNull);
});
testWidgets('the hub opens to a member, not just an admin', (tester) async {
// isOrgAdminProvider is false in this test's state. The reads under the
// hub are open to any member server-side, and the web sidebar keeps the
// entry for everyone for that reason, so a lock screen here would be
// this app disagreeing with the API about who may look.
await pump(tester, settingsHubApp());
expect(find.text('Custom fields'), findsOneWidget);
expect(find.text('Tags'), findsOneWidget);
});
testWidgets('the hub shows the Objects row only when offered', (
tester,
) async {
await pump(tester, settingsHubApp());
expect(find.text('Objects'), findsNothing);
expect(tester.takeException(), isNull);
await pump(
tester,
settingsHubApp(answer: _objAnswer(writes: true), isAdmin: true),
textScale: 1.5,
);
expect(find.text('Objects'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('custom fields render without overflowing', (tester) async {
await pump(tester, customFieldsApp(isAdmin: true));
expect(tester.takeException(), isNull);
});
testWidgets('custom fields fit with the system font scaled up', (
tester,
) async {
// The row that earns this: a Wrap of up to four badges plus an option
// count, over a long label, with two buttons under it.
await pump(tester, customFieldsApp(isAdmin: true), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('a member sees the fields but no write controls', (
tester,
) async {
await pump(tester, customFieldsApp(isAdmin: false));
expect(find.text('Severity'), findsOneWidget, reason: 'the read is open');
expect(find.widgetWithText(OutlinedButton, 'Edit'), findsNothing);
expect(find.widgetWithText(OutlinedButton, 'Turn off'), findsNothing);
expect(find.byTooltip('New custom field'), findsNothing);
});
testWidgets('a turned-off field offers Turn on, not Turn off', (
tester,
) async {
await pump(tester, customFieldsApp(isAdmin: true));
expect(find.widgetWithText(OutlinedButton, 'Turn on'), findsOneWidget);
expect(find.text('Turned off'), findsOneWidget);
});
testWidgets('a required field with gaps says so on the row', (
tester,
) async {
await pump(tester, customFieldsApp(isAdmin: true));
// Marking a field required binds new saves only, so the count is the
// thing that makes "Required" honest.
expect(find.textContaining('23 records have no value'), findsOneWidget);
// One gap, so the singular branch of the banner. The fake sets
// requiredWithGaps to 1 deliberately: the plural string is the one that
// reads naturally when written, and the singular is where "1 required
// fields have" slips through.
expect(
find.textContaining('One required field has records'),
findsOneWidget,
);
});
testWidgets('the new-field sheet renders without overflowing', (
tester,
) async {
await pump(tester, customFieldsApp(isAdmin: true));
await tester.tap(find.byTooltip('New custom field'));
await tester.pumpAndSettle();
expect(find.text('New custom field'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('the sheet fills the key in from the label', (tester) async {
await pump(tester, customFieldsApp(isAdmin: true));
await tester.tap(find.byTooltip('New custom field'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextField, 'Label'),
'Deal source',
);
await tester.pumpAndSettle();
final key = tester.widget<TextField>(
find.widgetWithText(TextField, 'Key'),
);
expect(key.controller?.text, 'deal_source');
});
testWidgets('the sheet asks for options only on a dropdown', (
tester,
) async {
await pump(tester, customFieldsApp(isAdmin: true));
await tester.tap(find.byTooltip('New custom field'));
await tester.pumpAndSettle();
expect(find.text('Add option'), findsNothing);
await tester.tap(find.text('Text').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Dropdown').last);
await tester.pumpAndSettle();
expect(find.text('Add option'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('editing a field shows the frozen three as text', (
tester,
) async {
await pump(tester, customFieldsApp(isAdmin: true));
await tester.tap(find.widgetWithText(OutlinedButton, 'Edit').first);
await tester.pumpAndSettle();
// The server refuses a change to any of the three with a 400. Offering
// the input and letting the save fail teaches the rule the hard way.
expect(find.widgetWithText(TextField, 'Key'), findsNothing);
expect(find.textContaining('Fixed after creation'), findsWidgets);
expect(tester.takeException(), isNull);
});
});
group('saved replies at 390px', () {
testWidgets('render without overflowing', (tester) async {
await pump(tester, macrosApp(isAdmin: true, myEmail: 'me@example.com'));
expect(tester.takeException(), isNull);
});
testWidgets('fit with the system font scaled up', (tester) async {
await pump(
tester,
macrosApp(isAdmin: true, myEmail: 'me@example.com'),
textScale: 1.5,
);
expect(tester.takeException(), isNull);
});
testWidgets('a member may still write their own', (tester) async {
// The only settings page with a write open to everyone. Hiding the add
// button from a member would remove a feature the API grants them.
await pump(tester, macrosApp(isAdmin: false, myEmail: 'me@example.com'));
expect(find.byTooltip('New saved reply'), findsOneWidget);
});
testWidgets('a member gets no controls on a shared reply', (tester) async {
await pump(tester, macrosApp(isAdmin: false, myEmail: 'me@example.com'));
// Two rows are mine (one personal, one turned-off personal), one is the
// org macro a member may read and not touch.
expect(find.widgetWithText(OutlinedButton, 'Turn off'), findsNothing);
expect(find.widgetWithText(OutlinedButton, 'Delete'), findsOneWidget);
});
testWidgets('an admin gets Turn off on the shared reply', (tester) async {
await pump(tester, macrosApp(isAdmin: true, myEmail: 'me@example.com'));
expect(find.widgetWithText(OutlinedButton, 'Turn off'), findsOneWidget);
expect(find.widgetWithText(OutlinedButton, 'Delete'), findsOneWidget);
});
testWidgets('an admin gets nothing on somebody else personal reply', (
tester,
) async {
// The one place admin is not an override: the server answers 404 there.
await pump(
tester,
macrosApp(isAdmin: true, myEmail: 'other@example.com'),
);
expect(find.widgetWithText(OutlinedButton, 'Delete'), findsNothing);
expect(find.widgetWithText(OutlinedButton, 'Edit'), findsOneWidget);
});
testWidgets('a broken placeholder is named, not counted', (tester) async {
await pump(tester, macrosApp(isAdmin: true, myEmail: 'me@example.com'));
// The author has to know which token to fix. A typo is only obvious
// beside the right spelling. Two widgets carry the token, the body
// preview and the warning; this asserts the warning.
expect(
find.text('%custmer_name% is not recognized and goes out as written'),
findsOneWidget,
);
});
testWidgets('the form offers the scope choice only to an admin', (
tester,
) async {
await pump(tester, macrosApp(isAdmin: false, myEmail: 'me@example.com'));
await tester.tap(find.byTooltip('New saved reply'));
await tester.pumpAndSettle();
// Not a greyed control: showing a disabled "Everyone" would advertise
// an action the server answers 403 to.
expect(find.text('Who can use it'), findsNothing);
expect(find.textContaining('Saved for you alone'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('the form inserts a placeholder at the cursor', (tester) async {
await pump(tester, macrosApp(isAdmin: true, myEmail: 'me@example.com'));
await tester.tap(find.byTooltip('New saved reply'));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextField, 'Reply'), 'Hi ');
await tester.tap(find.text('%customer_name%'));
await tester.pumpAndSettle();
final body = tester.widget<TextField>(
find.widgetWithText(TextField, 'Reply'),
);
expect(body.controller?.text, 'Hi %customer_name%');
expect(tester.takeException(), isNull);
});
});
group('the saved-reply picker at 390px', () {
testWidgets('renders without overflowing', (tester) async {
await pump(tester, macroPickerApp());
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.text('Saved replies'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('fits with the system font scaled up', (tester) async {
await pump(tester, macroPickerApp(), textScale: 1.5);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('says where to write one when there are none', (tester) async {
// An empty picker with no explanation reads as broken. The settings
// page is two taps away and a member can use it.
await pump(tester, macroPickerApp(macros: const []));
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.textContaining('Organization settings'), findsOneWidget);
});
testWidgets('filters as you type', (tester) async {
await pump(tester, macroPickerApp());
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
// "follow-up", not "follow": the search reads the body as well as the
// title, and the shared reply's body says "follow the link below". That
// is deliberate, since the words you remember are usually in the reply
// rather than in its name, and it is why the narrower term is used here.
await tester.enterText(find.byType(TextField).first, 'follow-up');
await tester.pumpAndSettle();
expect(find.text('My follow-up'), findsOneWidget);
expect(find.textContaining('Password reset'), findsNothing);
});
testWidgets('searches the body, not just the title', (tester) async {
await pump(tester, macroPickerApp());
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).first, 'ten minutes');
await tester.pumpAndSettle();
expect(find.textContaining('Password reset'), findsOneWidget);
expect(find.text('My follow-up'), findsNothing);
});
testWidgets('marks which replies are shared and which are yours', (
tester,
) async {
await pump(tester, macroPickerApp());
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
// Sending a personal draft believing it is the agreed org wording is the
// mistake this label prevents.
expect(find.text('Everyone'), findsOneWidget);
expect(find.text('Just you'), findsOneWidget);
});
});
group('tags at 390px', () {
testWidgets('render without overflowing', (tester) async {
await pump(tester, tagsApp(isAdmin: true));
expect(tester.takeException(), isNull);
});
testWidgets('fit with the system font scaled up', (tester) async {
// The rows that earn this: a duplicate banner carrying two names, a
// sentence about both, and a merge button, over a tag name long enough
// to wrap on its own.
await pump(tester, tagsApp(isAdmin: true), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('a member sees the tags but no write controls', (tester) async {
await pump(tester, tagsApp(isAdmin: false));
expect(find.text('Renewals'), findsOneWidget, reason: 'the read is open');
expect(find.byTooltip('New tag'), findsNothing);
expect(find.widgetWithText(OutlinedButton, 'Turn off'), findsNothing);
expect(find.widgetWithText(OutlinedButton, 'Turn back on'), findsNothing);
});
testWidgets('a member is still told two tags overlap', (tester) async {
// Worth something without the merge: they can stop using one today.
await pump(tester, tagsApp(isAdmin: false));
expect(
find.textContaining('look like the same tag'),
findsOneWidget,
reason: 'the banner is a read, not an action',
);
expect(find.textContaining('Merge'), findsNothing);
});
testWidgets('the merge button names the tag that survives', (tester) async {
// Which way a merge runs is the whole question, and it is not reversible
// by pressing the other button.
await pump(tester, tagsApp(isAdmin: true));
expect(
find.widgetWithText(OutlinedButton, 'Merge into Renewals'),
findsOneWidget,
);
});
testWidgets('turning a tag off never promises a delete', (tester) async {
// The server soft-archives: the row stays and every record keeps the
// tag. Saying Delete would promise a removal that does not happen.
await pump(tester, tagsApp(isAdmin: true));
await tester.tap(find.widgetWithText(OutlinedButton, 'Turn off').first);
await tester.pumpAndSettle();
expect(find.text('Turn off Renewals?'), findsOneWidget);
expect(find.textContaining('9 records keep this tag'), findsOneWidget);
expect(find.textContaining('turn it back on'), findsOneWidget);
expect(find.textContaining('Delete'), findsNothing);
});
testWidgets('the merge confirm says it cannot be undone', (tester) async {
await pump(tester, tagsApp(isAdmin: true));
await tester.tap(
find.widgetWithText(OutlinedButton, 'Merge into Renewals'),
);
await tester.pumpAndSettle();
expect(find.text('Merge Renewal into Renewals?'), findsOneWidget);
expect(
find.textContaining('2 records move from Renewal to Renewals'),
findsOneWidget,
);
expect(find.textContaining('cannot be undone'), findsOneWidget);
});
testWidgets('a turned-off tag offers Turn back on, not Turn off', (
tester,
) async {
await pump(tester, tagsApp(isAdmin: true));
expect(
find.widgetWithText(OutlinedButton, 'Turn back on'),
findsOneWidget,
);
expect(find.text('Turned off'), findsOneWidget);
});
testWidgets('a row says where the tag is actually used', (tester) async {
// The web has a column per record type; a phone row has one line, so it
// lists the non-zero ones and every one of them, so the parts sum to the
// total beside them.
await pump(tester, tagsApp(isAdmin: true));
expect(find.text('9 leads'), findsOneWidget);
expect(find.text('2 leads, 1 ticket'), findsOneWidget);
});
testWidgets('an unused tag is called out while it is still on', (
tester,
) async {
await pump(tester, tagsApp(isAdmin: true));
// Below the fold on a phone, which is the point of scrolling to it: the
// badge has to survive the list, not just the first screenful.
await tester.scrollUntilVisible(find.text('Dormant'), 200);
await tester.pumpAndSettle();
expect(find.text('Unused'), findsOneWidget);
});
testWidgets('an org with no tags gets the empty state', (tester) async {
await pump(tester, tagsApp(isAdmin: true, empty: true));
expect(find.text('No tags yet'), findsOneWidget);
expect(tester.takeException(), isNull);
});
});
group('routing rules at 390px', () {
testWidgets('render without overflowing', (tester) async {
await pump(tester, routingApp(isAdmin: true));
expect(tester.takeException(), isNull);
});
testWidgets('fit with the system font scaled up', (tester) async {
await pump(tester, routingApp(isAdmin: true), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('keep the order the engine runs them in', (tester) async {
// The order is the program: the engine takes the first match. A list
// that reordered would show a different program from the one that runs.
await pump(tester, routingApp(isAdmin: true));
final first = tester.getTopLeft(find.text('Billing questions')).dy;
final second = tester.getTopLeft(find.text('Everything else')).dy;
expect(first, lessThan(second));
});
testWidgets('name the agent the engine would actually pick', (
tester,
) async {
// The fixture is built so the two ways of getting this wrong give
// different, checkable answers: the cursor is the NEXT index, not the
// last, and it indexes the active pool in id order. Adding one and
// indexing the serializer's list names Cai, who is deactivated and can
// never be picked.
await pump(tester, routingApp(isAdmin: true));
expect(find.text('Next in the rotation: Brin'), findsOneWidget);
expect(find.text('Next in the rotation: Cai'), findsNothing);
});
testWidgets('flag a rule that matches everything', (tester) async {
await pump(tester, routingApp(isAdmin: true));
expect(find.text('Matches every new ticket'), findsOneWidget);
});
testWidgets('say what happens to tickets a dead rule matches', (
tester,
) async {
await pump(tester, routingApp(isAdmin: true));
expect(
find.textContaining('fall through to the rules below'),
findsOneWidget,
);
});
testWidgets('name the deactivated member the rotation skips', (
tester,
) async {
await pump(tester, routingApp(isAdmin: true));
expect(find.text('Cai is deactivated and is skipped.'), findsOneWidget);
});
testWidgets('a member sees the rules but no write controls', (
tester,
) async {
await pump(tester, routingApp(isAdmin: false));
expect(find.text('Billing questions'), findsOneWidget);
expect(find.byTooltip('New rule'), findsNothing);
expect(find.widgetWithText(OutlinedButton, 'Edit'), findsNothing);
expect(find.widgetWithText(OutlinedButton, 'Delete'), findsNothing);
});
testWidgets('the delete confirm says it is permanent and offers the '
'reversible option', (tester) async {
// This resource hard-deletes, unlike custom fields, tags and org macros,
// so the dialog cannot borrow their wording.
await pump(tester, routingApp(isAdmin: true));
await tester.tap(find.widgetWithText(OutlinedButton, 'Delete').first);
await tester.pumpAndSettle();
expect(find.text('Delete Billing questions?'), findsOneWidget);
expect(find.textContaining('gone for good'), findsOneWidget);
expect(find.textContaining('turn it off instead'), findsOneWidget);
});
testWidgets('a turned-off rule offers Turn on, not Turn off', (
tester,
) async {
await pump(tester, routingApp(isAdmin: true));
await tester.scrollUntilVisible(find.text('Old escalation'), 200);
await tester.pumpAndSettle();
expect(find.widgetWithText(OutlinedButton, 'Turn on'), findsOneWidget);
});
testWidgets('an org with no rules is told what that means', (tester) async {
await pump(tester, routingApp(isAdmin: true, empty: true));
expect(find.text('No routing rules'), findsOneWidget);
expect(
find.textContaining('arrive unassigned'),
findsOneWidget,
reason: 'an empty list is a working state with a consequence',
);
});
});
group('the routing rule form at 390px', () {
Future<void> open(WidgetTester tester, {RoutingRule? existing}) async {
await pump(tester, routingFormApp(existing: existing));
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
testWidgets('renders without overflowing', (tester) async {
await open(tester);
expect(tester.takeException(), isNull);
});
testWidgets('fits with the system font scaled up', (tester) async {
await pump(tester, routingFormApp(), textScale: 1.5);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('warns that a rule with no conditions catches everything', (
tester,
) async {
await open(tester);
expect(find.textContaining('matches every new ticket'), findsOneWidget);
});
testWidgets('says what stopping after this rule costs', (tester) async {
// On by default, and it is the setting that turns an empty pool from
// "falls through" into "swallows the ticket".
await open(tester);
expect(
find.textContaining('No rule below this one gets a turn'),
findsOneWidget,
);
});
testWidgets('keeps a deactivated assignee the picker cannot offer', (
tester,
) async {
// The lookup returns active profiles only, so a rule pointing at a
// deactivated one would lose them just by being opened. That is a delete
// performed as a side effect of viewing.
await open(
tester,
existing: RoutingRule(
id: 'r1',
name: 'Billing questions',
strategy: RoutingRule.strategyRoundRobin,
targetAssignees: const [
RoutingTarget(id: 'a1', name: 'Ada'),
RoutingTarget(id: 'c1', name: 'Cai', isActive: false),
],
),
);
expect(find.text('Cai (deactivated)'), findsOneWidget);
});
});
group('escalation policies at 390px', () {
testWidgets('render without overflowing', (tester) async {
await pump(tester, escalationApp(isAdmin: true));
expect(tester.takeException(), isNull);
});
testWidgets('fit with the system font scaled up', (tester) async {
await pump(tester, escalationApp(isAdmin: true), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('name the SLA target, and say when it is the default', (
tester,
) async {
// The number is the promise the policy enforces, so the card that
// configures escalation has to show it. Saying which of the two it is
// matters: an unlabelled "4h" reads as somebody's decision.
await pump(tester, escalationApp(isAdmin: true));
expect(find.text('6h reply · 4h default resolve'), findsOneWidget);
});
testWidgets('offer the SLA targets on the form without overflowing', (
tester,
) async {
await pump(tester, escalationFormApp(existing: _urgentPolicy()));
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.text('First response (hours)'), findsOneWidget);
expect(find.text('Resolution (hours)'), findsOneWidget);
// Blank has to look blank, with the default named in the hint rather
// than prefilled, or clearing an override is indistinguishable from
// keeping one.
expect(find.text('4 (default)'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('sort by severity, not alphabetically', (tester) async {
// The model orders the CharField, which puts Low between High and
// Normal. Both clients override it.
await pump(tester, escalationApp(isAdmin: true));
final urgent = tester.getTopLeft(find.text('Urgent')).dy;
final high = tester.getTopLeft(find.text('High')).dy;
expect(
urgent,
lessThan(high),
reason: 'alphabetical order would put High first',
);
// The third card is below the fold on a phone, which is itself the
// assertion that it sorts last.
expect(find.text('Normal'), findsNothing);
await tester.scrollUntilVisible(find.text('Normal'), 200);
expect(find.text('Normal'), findsOneWidget);
});
testWidgets('stack the two halves rather than columning them', (
tester,
) async {
// The web puts them side by side and collapses to one column below
// 768px. Two columns at 390px does not overflow, it just leaves each
// outcome sentence about twenty characters wide, so nothing but this
// catches it.
await pump(tester, escalationApp(isAdmin: true));
final first = tester.getTopLeft(find.text('MISSED FIRST RESPONSE').first);
final second = tester.getTopLeft(find.text('MISSED RESOLUTION').first);
expect(second.dy, greaterThan(first.dy));
expect(second.dx, first.dx);
});
testWidgets('call a half with a team but no target dead', (tester) async {
// The combination the web reported as live. `_scan_org` guards the
// dispatch on the target being set, so a team alone is never told.
await pump(tester, escalationApp(isAdmin: true));
expect(
find.text(
'Nothing. No target is set, and the Support team is not notified '
'on its own.',
),
findsOneWidget,
);
});
testWidgets('total the breaches that reached nobody', (tester) async {
// 11 on the dead High half, plus both halves of the policy that is off.
await pump(tester, escalationApp(isAdmin: true));
expect(
find.textContaining('19 breaches in the last 30 days told nobody'),
findsOneWidget,
);
});
testWidgets('say a dead half acted on none of its breaches', (
tester,
) async {
await pump(tester, escalationApp(isAdmin: true));
expect(
find.text('11 in the last 30 days, none of them acted on'),
findsOneWidget,
);
});
testWidgets('warn that a reassign half never notifies the team', (
tester,
) async {
// The other half of the same correction: `_dispatch_breach` builds a
// recipient list only for the two notify actions.
await pump(tester, escalationApp(isAdmin: true));
expect(
find.text(
'The Support team is not notified here: this half only reassigns.',
),
findsOneWidget,
);
expect(find.text('Reassign to Ada.'), findsOneWidget);
});
testWidgets('name the priorities with no policy at all', (tester) async {
// Otherwise invisible: breach counts are attached to policies, so an
// unconfigured priority contributes no row and no number.
await pump(tester, escalationApp(isAdmin: true));
expect(
find.textContaining('Low has no policy, so breaches at that priority'),
findsOneWidget,
);
});
testWidgets('a member sees the policies but no write controls', (
tester,
) async {
await pump(tester, escalationApp(isAdmin: false));
expect(find.text('Urgent'), findsOneWidget);
expect(find.byTooltip('New policy'), findsNothing);
expect(find.widgetWithText(OutlinedButton, 'Edit'), findsNothing);
expect(find.widgetWithText(OutlinedButton, 'Delete'), findsNothing);
});
testWidgets('the delete confirm says it is permanent and offers the '
'reversible option', (tester) async {
await pump(tester, escalationApp(isAdmin: true));
await tester.tap(find.widgetWithText(OutlinedButton, 'Delete').first);
await tester.pumpAndSettle();
expect(find.text('Delete the Urgent policy?'), findsOneWidget);
expect(find.textContaining('gone for good'), findsOneWidget);
expect(find.textContaining('turn it off instead'), findsOneWidget);
});
testWidgets('a turned-off policy offers Turn on, not Turn off', (
tester,
) async {
await pump(tester, escalationApp(isAdmin: true));
await tester.scrollUntilVisible(find.text('Turned off'), 200);
await tester.pumpAndSettle();
expect(find.widgetWithText(OutlinedButton, 'Turn on'), findsOneWidget);
});
testWidgets('an org with no policies is told what that means', (
tester,
) async {
await pump(tester, escalationApp(isAdmin: true, empty: true));
expect(find.text('No escalation policies'), findsOneWidget);
expect(
find.textContaining('escalates to nobody'),
findsOneWidget,
reason: 'an empty list is a working state with a consequence',
);
});
});
group('the escalation policy form at 390px', () {
Future<void> open(
WidgetTester tester, {
EscalationPolicy? existing,
double textScale = 1.0,
}) async {
await pump(
tester,
escalationFormApp(existing: existing),
textScale: textScale,
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
testWidgets('renders without overflowing', (tester) async {
await open(tester);
expect(tester.takeException(), isNull);
});
testWidgets('fits with the system font scaled up', (tester) async {
await open(tester, textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('warns that a half with no target does nothing yet', (
tester,
) async {
// A create starts with both targets on Nobody, and the warning is not
// tied to the action: Notify with nobody picked is as dead as Reassign
// with nobody picked.
await open(tester);
expect(find.text(escalationNoTargetHint), findsNWidgets(2));
});
testWidgets('offers only the priorities with no policy yet', (
tester,
) async {
await open(tester);
// The priority select is the first dropdown on the sheet.
await tester.tap(find.byType(DropdownButtonFormField<String>).first);
await tester.pumpAndSettle();
// Urgent and Low are free in the fixture; High and Normal are taken, and
// offering them would only produce a 400 the admin could be spared.
expect(find.text('Low'), findsWidgets);
expect(find.text('High'), findsNothing);
expect(find.text('Normal'), findsNothing);
});
testWidgets('freezes the priority on an edit rather than offering it', (
tester,
) async {
// `EscalationPolicyDetailView.put` strips the key from the body, so a
// control here would report a change it did not make.
await open(tester, existing: _urgentPolicy());
expect(find.textContaining('fixed after it is created'), findsOneWidget);
expect(
find.widgetWithText(DropdownButtonFormField<String>, 'Priority'),
findsNothing,
);
});
testWidgets('keeps a deactivated target the picker cannot offer', (
tester,
) async {
await open(tester, existing: _deactivatedTargetPolicy());
expect(find.text('Cai (deactivated)'), findsOneWidget);
expect(
find.textContaining('waits for somebody who cannot sign in'),
findsOneWidget,
);
});
});
group('business hours at 390px', () {
testWidgets('renders without overflowing', (tester) async {
await pump(tester, businessHoursApp(isAdmin: true));
expect(tester.takeException(), isNull);
});
testWidgets('fits with the system font scaled up', (tester) async {
await pump(tester, businessHoursApp(isAdmin: true), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('names a closed day rather than leaving it blank', (
tester,
) async {
// A blank cell reads as missing data, "Closed" reads as a decision.
await pump(tester, businessHoursApp(isAdmin: true));
expect(find.text('Closed'), findsWidgets);
expect(find.text('09:00 to 17:00'), findsWidgets);
});
testWidgets('says a calendar with no open day runs the clock anyway', (
tester,
) async {
// The finding. `_has_any_open_window` false means the calendar is
// dropped and the SLA runs on the wall clock, so every row reading
// Closed means the opposite of what it looks like.
await pump(tester, businessHoursApp(isAdmin: true, alwaysOn: true));
expect(
find.textContaining('targets run around the clock'),
findsOneWidget,
);
expect(
find.textContaining('expires four hours after the ticket arrives'),
findsOneWidget,
);
});
testWidgets('does not cry wolf on an ordinary week', (tester) async {
await pump(tester, businessHoursApp(isAdmin: true));
expect(find.textContaining('around the clock'), findsNothing);
});
testWidgets('says what an empty holiday list means', (tester) async {
await pump(tester, businessHoursApp(isAdmin: true, noHolidays: true));
expect(
find.textContaining('Targets keep running on public holidays'),
findsOneWidget,
);
});
testWidgets('a member sees the week but no write controls', (tester) async {
await pump(tester, businessHoursApp(isAdmin: false));
expect(find.text('Monday'), findsOneWidget);
expect(find.byTooltip('Edit hours'), findsNothing);
expect(find.text('Add'), findsNothing);
expect(find.byTooltip('Remove this holiday'), findsNothing);
});
testWidgets('the remove confirm says the day counts as work again', (
tester,
) async {
await pump(tester, businessHoursApp(isAdmin: true));
await tester.scrollUntilVisible(
find.byTooltip('Remove this holiday'),
200,
);
await tester.tap(find.byTooltip('Remove this holiday').first);
await tester.pumpAndSettle();
expect(find.text('Remove Xmas?'), findsOneWidget);
expect(
find.textContaining('counts as working time again'),
findsOneWidget,
);
});
});
group('the business-hours forms at 390px', () {
Future<void> openWeek(WidgetTester tester, {double textScale = 1.0}) async {
await pump(tester, weekFormApp(), textScale: textScale);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
testWidgets('the week form renders without overflowing', (tester) async {
await openWeek(tester);
expect(tester.takeException(), isNull);
});
testWidgets('the week form fits with the font scaled up', (tester) async {
await openWeek(tester, textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('the week form warns live when the last day is closed', (
tester,
) async {
await openWeek(tester);
// Close the five open days; the warning appears as the last one goes.
for (var i = 0; i < 5; i++) {
await tester.tap(find.byType(Switch).at(i));
await tester.pumpAndSettle();
}
expect(
find.textContaining('targets run around the clock'),
findsOneWidget,
);
});
testWidgets('the holiday form warns before writing a date already taken', (
tester,
) async {
// The POST answers 200 with the row already stored and throws the typed
// name away, so the warning has to come before the write.
await pump(tester, holidayFormApp());
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('Pick a date'), findsOneWidget);
});
});
group('reopen policy at 390px', () {
testWidgets('renders without overflowing', (tester) async {
await pump(tester, reopenApp(isAdmin: true));
expect(tester.takeException(), isNull);
});
testWidgets('fits with the system font scaled up', (tester) async {
await pump(tester, reopenApp(isAdmin: true), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('a member is told why rather than shown a failed request', (
tester,
) async {
// `ReopenPolicyView.get` is admin-only, unlike every other read in this
// cluster, so the screen gates the read rather than issuing it.
await pump(tester, reopenApp(isAdmin: false));
expect(find.text('Administrators only'), findsOneWidget);
expect(find.byTooltip('Edit policy'), findsNothing);
});
testWidgets('shows the three figures behind the window', (tester) async {
await pump(tester, reopenApp(isAdmin: true));
expect(find.text('6'), findsOneWidget);
expect(find.text('4'), findsOneWidget);
expect(find.text('2d'), findsOneWidget);
});
testWidgets('says the misses reached nobody while the policy is on', (
tester,
) async {
await pump(tester, reopenApp(isAdmin: true));
expect(
find.textContaining('Those customers are still waiting'),
findsOneWidget,
);
});
// Present tense: with the policy off, nothing brings a ticket back, so the
// explanation cannot be written as though a reply still did. Two tests
// rather than one, because re-pumping a second ProviderScope over the first
// keeps the element tree and the assertions cross-contaminate.
testWidgets('does not describe a reopen that cannot happen', (
tester,
) async {
await pump(tester, reopenApp(isAdmin: true, policyOff: true));
await tester.scrollUntilVisible(
find.textContaining('Which addresses accept replies'),
300,
);
expect(find.textContaining('Turned on, a reply'), findsOneWidget);
});
testWidgets('describes it plainly while the policy is on', (tester) async {
await pump(tester, reopenApp(isAdmin: true));
await tester.scrollUntilVisible(
find.textContaining('Which addresses accept replies'),
300,
);
expect(find.textContaining('Turned on, a reply'), findsNothing);
expect(
find.textContaining('A reopened ticket is the same ticket'),
findsOneWidget,
);
});
testWidgets('refuses to report a zero it did not measure', (tester) async {
// The finding. With reopening off, `_evaluate_reopen` returns before the
// window comparison and nothing is ever flagged, so a plain 0 here is
// reassurance about the one thing certainly happening.
await pump(tester, reopenApp(isAdmin: true, policyOff: true));
expect(find.text('n/a'), findsOneWidget);
expect(
find.textContaining('not because nothing is being lost'),
findsOneWidget,
);
});
});
group('API tokens at 390px', () {
testWidgets('renders without overflowing', (tester) async {
await pump(tester, tokensApp(isAdmin: true));
expect(tester.takeException(), isNull);
});
testWidgets('fits with the system font scaled up', (tester) async {
await pump(tester, tokensApp(isAdmin: true), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('a member is told why rather than shown a failed request', (
tester,
) async {
// `/api/org/tokens/` is admin-only: it lists every token in the org.
await pump(tester, tokensApp(isAdmin: false));
expect(find.text('Administrators only'), findsOneWidget);
expect(find.text('New token'), findsNothing);
});
testWidgets('shows the prefix and never a full value', (tester) async {
await pump(tester, tokensApp(isAdmin: true));
expect(find.textContaining('bcrm_pat_abc...'), findsWidgets);
// The row carries no raw token because the server keeps only a hash.
expect(find.textContaining('bcrm_pat_abcdefgh'), findsNothing);
});
testWidgets('does not call a token issued today unused for 90 days', (
tester,
) async {
// The finding: null last_used_at is not "long ago", and the row has to
// agree with the count the card shows.
await pump(tester, tokensApp(isAdmin: true));
// Any phrasing of it: the row must say nothing at all about neglect.
expect(find.textContaining('never used'), findsNothing);
expect(find.textContaining('unused for'), findsNothing);
});
testWidgets('offers to clear the loose end when an owner is gone', (
tester,
) async {
await pump(tester, tokensApp(isAdmin: true, orphaned: true));
expect(find.textContaining('deactivated account'), findsWidgets);
expect(find.text('Revoke it'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('the create sheet fits over the keyboard inset', (
tester,
) async {
await pump(tester, tokenFormApp());
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('Create token'), findsOneWidget);
});
testWidgets('warns before a token that never expires', (tester) async {
await pump(tester, tokenFormApp());
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.textContaining('has to be revoked by hand'), findsNothing);
await tester.tap(find.byType(DropdownButtonFormField<String>).last);
await tester.pumpAndSettle();
await tester.tap(find.text('Never').last);
await tester.pumpAndSettle();
expect(find.textContaining('revoked by hand'), findsOneWidget);
expect(tester.takeException(), isNull);
});
});
group('your own API tokens at 390px', () {
testWidgets('renders without overflowing', (tester) async {
await pump(tester, myTokensApp());
expect(find.text('My export script'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('fits with the system font scaled up', (tester) async {
await pump(tester, myTokensApp(), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('offers creation to a member, with no role gate', (
tester,
) async {
// `/api/profile/tokens/` is open to every member. Gating this screen on
// role would invent a rule, and the absence of a self-service surface is
// exactly what left a member unable to issue themselves a token.
await pump(tester, myTokensApp());
expect(find.text('New token'), findsOneWidget);
});
testWidgets('a revoked row reads as revoked, not as live', (tester) async {
// The self endpoint sends no `is_live`. The model used to default it to
// true, which drew this row as Live and offered a Revoke button on a
// token that was already revoked.
await pump(tester, myTokensApp());
expect(find.text('Revoked'), findsOneWidget);
expect(find.text('Live'), findsOneWidget);
// One Revoke button, on the one live row.
expect(find.widgetWithText(OutlinedButton, 'Revoke'), findsOneWidget);
});
testWidgets('says what a token can do without naming an owner', (
tester,
) async {
await pump(tester, myTokensApp());
expect(find.text('Everything you can'), findsOneWidget);
expect(find.textContaining('Everything its owner can'), findsNothing);
});
testWidgets('an empty list explains what a token is for', (tester) async {
await pump(tester, myTokensApp(empty: true));
expect(find.text('No tokens yet'), findsOneWidget);
expect(tester.takeException(), isNull);
});
});
group('inbound email at 390px', () {
testWidgets('renders without overflowing', (tester) async {
await pump(tester, mailboxesApp(isAdmin: true));
expect(tester.takeException(), isNull);
});
testWidgets('fits with the system font scaled up', (tester) async {
await pump(tester, mailboxesApp(isAdmin: true), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('a member reads it, and is offered no write', (tester) async {
// GET /cases/mailboxes/ carries only IsAuthenticated + HasOrgContext.
// Hiding the list would be this app disagreeing with the API about who
// may look.
await pump(tester, mailboxesApp(isAdmin: false));
expect(find.text('support@acme.com'), findsOneWidget);
expect(find.text('Edit'), findsNothing);
expect(find.text('Delete'), findsNothing);
});
testWidgets('says which gate each silent address fails', (tester) async {
// The finding: two of these three are switched on and open nothing, and
// both clients used to draw them as working.
await pump(tester, mailboxesApp(isAdmin: true));
expect(find.text('Provider not wired up'), findsOneWidget);
expect(find.textContaining('Mailgun deliveries'), findsOneWidget);
// The third row sits below the fold at 390px.
await tester.scrollUntilVisible(find.text('Not connected yet'), 200);
expect(find.text('Not connected yet'), findsOneWidget);
expect(find.textContaining('Waiting for its SNS topic'), findsOneWidget);
});
testWidgets('counts what creates tickets, not what is switched on', (
tester,
) async {
await pump(tester, mailboxesApp(isAdmin: true));
expect(find.text('creating tickets'), findsOneWidget);
expect(find.text('1'), findsOneWidget); // one of three actually delivers
expect(find.textContaining('switched on and create'), findsOneWidget);
});
testWidgets('says nothing about silence when every address delivers', (
tester,
) async {
await pump(tester, mailboxesApp(isAdmin: true, allLive: true));
expect(find.textContaining('switched on and create'), findsNothing);
expect(find.text('Creating tickets'), findsWidgets);
});
testWidgets('never claims a shared secret proves a delivery', (
tester,
) async {
// The card this replaced said a per-address secret did it, and that the
// server minted one. Nothing in the backend compares that column.
await pump(tester, mailboxesApp(isAdmin: true));
await tester.scrollUntilVisible(
find.text('How a delivery is proved genuine'),
200,
);
expect(find.textContaining('signs each notification'), findsOneWidget);
expect(find.textContaining('shared secret'), findsNothing);
});
testWidgets('the form fits over the keyboard inset', (tester) async {
await pump(tester, mailboxFormApp());
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('New address'), findsOneWidget);
// No field for either credential, not even a disabled one.
expect(find.textContaining('secret'), findsNothing);
});
testWidgets('the provider picker marks the three that do nothing', (
tester,
) async {
await pump(tester, mailboxFormApp());
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
await tester.tap(find.byType(DropdownButtonFormField<String>).first);
await tester.pumpAndSettle();
expect(find.text('Mailgun (not wired up yet)'), findsWidgets);
expect(find.text('AWS SES'), findsWidgets);
expect(tester.takeException(), isNull);
});
});
group('approval rules at 390px', () {
testWidgets('renders without overflowing', (tester) async {
await pump(tester, approvalRulesApp(isAdmin: true));
expect(tester.takeException(), isNull);
});
testWidgets('fits with the system font scaled up', (tester) async {
await pump(tester, approvalRulesApp(isAdmin: true), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('a member reads it, and is offered no write', (tester) async {
await pump(tester, approvalRulesApp(isAdmin: false));
expect(find.text('Close review'), findsOneWidget);
expect(find.text('Edit'), findsNothing);
});
testWidgets('names the role as well as the named approvers', (
tester,
) async {
// The finding: named approvers are additive. A row listing only the
// names reads as tighter than the rule is.
await pump(tester, approvalRulesApp(isAdmin: true));
expect(
find.text('Cleared by any admin, or ada@acme.com'),
findsOneWidget,
);
});
testWidgets('flags a rule that gates closes nobody can clear', (
tester,
) async {
await pump(tester, approvalRulesApp(isAdmin: true));
expect(find.text('Nobody can clear'), findsOneWidget);
expect(find.textContaining('There is no manager role'), findsOneWidget);
});
testWidgets('says nothing about shadowing when no two rules match alike', (
tester,
) async {
await pump(tester, approvalRulesApp(isAdmin: true));
expect(find.text('Never runs'), findsNothing);
expect(find.textContaining('never runs'), findsNothing);
});
testWidgets('flags the older of two rules with identical conditions', (
tester,
) async {
await pump(tester, approvalRulesApp(isAdmin: true, shadowed: true));
expect(find.text('Never runs'), findsOneWidget);
expect(find.textContaining('was written later'), findsOneWidget);
// That row carries three flags at once, which is the widest one gets.
expect(find.text('Nobody can clear'), findsOneWidget);
expect(find.text('3 waiting'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('the widest row still fits with the font scaled up', (
tester,
) async {
await pump(
tester,
approvalRulesApp(isAdmin: true, shadowed: true),
textScale: 1.5,
);
expect(tester.takeException(), isNull);
});
testWidgets('says a ticket is gated by one rule, not by all of them', (
tester,
) async {
await pump(tester, approvalRulesApp(isAdmin: true));
await tester.scrollUntilVisible(
find.textContaining('gated by one rule'),
200,
);
expect(find.textContaining('gated by one rule'), findsOneWidget);
});
testWidgets('the form fits over the keyboard inset', (tester) async {
await pump(tester, approvalRuleFormApp());
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('New approval rule'), findsOneWidget);
});
testWidgets('the form answers who can clear it before it is saved', (
tester,
) async {
await pump(tester, approvalRuleFormApp());
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.text('Cleared by any admin'), findsOneWidget);
// Switching to the role that matches nobody, with nobody named, is the
// configuration that strands every ticket it gates.
//
// The sheet's scrollable has to be named: every TextField in it carries
// one of its own, so an unqualified `scrollUntilVisible` finds several
// and throws.
await tester.scrollUntilVisible(
find.text('Admin'),
200,
scrollable: find
.descendant(
of: find.byType(SingleChildScrollView),
matching: find.byType(Scrollable),
)
.first,
);
await tester.tap(find.text('Admin'));
await tester.pumpAndSettle();
await tester.tap(find.text('Manager (matches nobody yet)').last);
await tester.pumpAndSettle();
expect(find.text('Cleared by nobody'), findsOneWidget);
expect(find.textContaining('cannot be closed by anyone'), findsOneWidget);
expect(tester.takeException(), isNull);
});
});
group('new organization at 390px', () {
// Every pack listed, the longest description included, so the radio rows
// are at their tallest.
Widget orgCreateApp() => ProviderScope(
overrides: [
orgPacksProvider.overrideWith(
(ref) async => const [
VerticalPack(
id: 'professional-services',
name: 'Professional services',
description:
'Agencies, consultancies and accounting firms selling '
'billable work.',
),
VerticalPack(
id: 'education-admissions',
name: 'Education admissions',
description:
'Coaching centres, training institutes and colleges '
'managing admissions.',
),
VerticalPack(
id: 'events-weddings',
name: 'Events & weddings',
description:
'Wedding planners, event agencies and venues taking '
'enquiries through to a delivered event.',
),
VerticalPack(
id: 'real-estate',
name: 'Real estate',
description:
'Brokers, agencies and developers selling residential and '
'commercial property.',
),
],
),
],
child: const MaterialApp(home: OrgCreateScreen()),
);
for (final scale in [1.0, 1.5]) {
testWidgets('the industry choice fits at text scale $scale', (
tester,
) async {
await pump(tester, orgCreateApp(), textScale: scale);
expect(find.text('What kind of business is this?'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
});
group('organization at 390px', () {
testWidgets('renders without overflowing', (tester) async {
await pump(tester, organizationApp(isAdmin: true));
expect(tester.takeException(), isNull);
});
testWidgets('fits with the system font scaled up', (tester) async {
await pump(tester, organizationApp(isAdmin: true), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('a member reads it, and is offered no write', (tester) async {
// GET /api/org/settings/ is open to any member: the company profile is
// not a secret, and members legitimately see the currency and address.
await pump(tester, organizationApp(isAdmin: false));
// Twice: the header, and again as the trading name.
expect(find.text('MicroPyramid'), findsNWidgets(2));
expect(find.byTooltip('Edit details'), findsNothing);
expect(find.text('Clear sample data'), findsNothing);
await tester.scrollUntilVisible(
find.textContaining('limited to administrators'),
300,
);
expect(find.textContaining('limited to administrators'), findsOneWidget);
// Still nothing to press once the pack section is on screen.
expect(find.text('Apply'), findsNothing);
expect(find.text('Clear sample data'), findsNothing);
});
testWidgets('says the cascade switch starts the prompt unticked', (
tester,
) async {
await pump(tester, organizationApp(isAdmin: true));
await tester.scrollUntilVisible(find.text('Starts unticked'), 300);
expect(find.text('Starts unticked'), findsOneWidget);
expect(find.textContaining('starts with'), findsOneWidget);
});
testWidgets('says ticked when the setting is on', (tester) async {
// Two tests rather than one: re-pumping a second ProviderScope over the
// first keeps the element tree and the assertions cross-contaminate.
await pump(tester, organizationApp(isAdmin: true, cascadeOn: true));
await tester.scrollUntilVisible(find.text('Starts ticked'), 300);
expect(find.text('Starts ticked'), findsOneWidget);
});
testWidgets('never shows the organization API key', (tester) async {
await pump(tester, organizationApp(isAdmin: true));
await tester.scrollUntilVisible(
find.textContaining('The organization API key is not here'),
300,
);
expect(
find.textContaining('The organization API key is not here'),
findsOneWidget,
);
});
});
group('the close-with-children prompt at 390px', () {
/// A button that opens the prompt, so it renders the way it is reached.
///
/// `openNames` is the tickets that would ACTUALLY close, which is what the
/// prompt now takes. It used to take `child_count`, a different number:
/// direct children whether open or closed.
Widget closePromptApp({
required bool startsTicked,
List<String> openNames = const [
'Printer jammed on floor two',
'Toner order still pending',
'Reception printer offline as well',
],
bool truncated = false,
}) => MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showCloseWithChildrenDialog(
context,
ticketName: 'Printer offline in the Hyderabad office',
openNames: openNames,
startsTicked: startsTicked,
truncated: truncated,
),
child: const Text('open'),
),
),
),
);
Future<void> openPrompt(WidgetTester tester, {required bool ticked}) async {
await pump(tester, closePromptApp(startsTicked: ticked));
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
testWidgets('starts unticked when the org setting is off', (tester) async {
// The finding: this was hardcoded to `true`, so
// `auto_close_children_on_parent_close` reached nobody. The settings
// screen says "starts unticked"; this is what makes that true.
await openPrompt(tester, ticked: false);
expect(tester.widget<Checkbox>(find.byType(Checkbox)).value, isFalse);
expect(tester.takeException(), isNull);
});
testWidgets('starts ticked when the org setting is on', (tester) async {
await openPrompt(tester, ticked: true);
expect(tester.widget<Checkbox>(find.byType(Checkbox)).value, isTrue);
expect(tester.takeException(), isNull);
});
testWidgets('the person can still override either way', (tester) async {
await openPrompt(tester, ticked: false);
await tester.tap(find.byType(Checkbox));
await tester.pumpAndSettle();
expect(tester.widget<Checkbox>(find.byType(Checkbox)).value, isTrue);
});
testWidgets('a long subject and the checkbox fit side by side', (
tester,
) async {
await openPrompt(tester, ticked: true);
expect(tester.takeException(), isNull);
expect(find.text('Close these as well'), findsOneWidget);
});
testWidgets('names the tickets that would close, before closing them', (
tester,
) async {
// They can belong to somebody else, and nobody is asked twice.
await openPrompt(tester, ticked: true);
expect(find.text('Printer jammed on floor two'), findsOneWidget);
expect(find.text('Toner order still pending'), findsOneWidget);
expect(
find.textContaining('3 linked tickets are still open'),
findsOneWidget,
);
});
testWidgets('offers no cascade when nothing below is open', (tester) async {
// `child_count` could be 3 here and all three already closed. The old
// prompt quoted that number and offered to close them.
await pump(
tester,
closePromptApp(startsTicked: true, openNames: const []),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.textContaining('changes nothing else'), findsOneWidget);
expect(find.byType(Checkbox), findsNothing);
// And the button does not offer to close "all of them" when there is
// nothing else to close. With no checkbox on screen the seeded value is
// invisible, so the label is the only thing that shows it went wrong.
expect(find.text('Close all of them'), findsNothing);
expect(find.text('Close ticket'), findsWidgets);
});
testWidgets('admits the list is a floor when the tree was cut short', (
tester,
) async {
await pump(tester, closePromptApp(startsTicked: true, truncated: true));
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.textContaining('may be more'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('carries a reason field, which the request used to omit', (
tester,
) async {
await openPrompt(tester, ticked: true);
expect(find.text('Why (optional)'), findsOneWidget);
expect(
find.textContaining('Recorded against every ticket'),
findsOneWidget,
);
});
testWidgets('fits with the system font scaled up', (tester) async {
await pump(tester, closePromptApp(startsTicked: true), textScale: 1.5);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
});
group('organization edit at 390px', () {
testWidgets('renders without overflowing', (tester) async {
await pump(tester, organizationEditApp(isAdmin: true));
expect(tester.takeException(), isNull);
});
testWidgets('fits with the system font scaled up', (tester) async {
await pump(tester, organizationEditApp(isAdmin: true), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('a member sees the notice, not the form', (tester) async {
// The courtesy, not the control: PATCH refuses a non-admin regardless.
await pump(tester, organizationEditApp(isAdmin: false));
expect(find.text('Administrators only'), findsOneWidget);
expect(find.text('Save changes'), findsNothing);
});
testWidgets('shows the stored timezone even though it is one of many', (
tester,
) async {
// A picker that cannot display the stored value would either throw or
// quietly submit its first entry, moving the org somewhere it never
// chose. Scrolled explicitly on the form's own list: the text fields
// each carry a Scrollable of their own. Scrolled to the zone itself, not
// to the Save button, which sits a whole section further down and would
// leave the lazily built picker behind.
await pump(tester, organizationEditApp(isAdmin: true));
await tester.scrollUntilVisible(
find.textContaining('Asia/Kolkata'),
300,
scrollable: find
.descendant(
of: find.byType(ListView),
matching: find.byType(Scrollable),
)
.first,
);
expect(find.textContaining('Asia/Kolkata'), findsWidgets);
expect(tester.takeException(), isNull);
});
});
/// Goals: a four-stat header, a progress row per goal, and the board.
///
/// [isAdmin] drives both answers because the read is open to a member while
/// every write is admin-only, and [board] drives the empty leaderboard, which
/// became an ordinary outcome once the endpoint started narrowing its rows.
Widget goalsApp({
required bool isAdmin,
bool board = true,
bool empty = false,
}) => ProviderScope(
overrides: [
goalsProvider.overrideWith(
empty
? _FakeNoGoals.new
: (board ? _FakeGoals.new : _FakeGoalsNoBoard.new),
),
isOrgAdminProvider.overrideWithValue(isAdmin),
],
child: routed(const GoalsScreen()),
);
group('goals', () {
testWidgets('renders without overflowing', (tester) async {
await pump(tester, goalsApp(isAdmin: true));
expect(find.text('Q3 revenue'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('fits with the system font scaled up', (tester) async {
await pump(tester, goalsApp(isAdmin: true), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('a member gets no compose control', (tester) async {
// Creating a goal answers 403 for a member. The server is what refuses;
// this only stops offering a page that cannot save.
await pump(tester, goalsApp(isAdmin: false));
expect(find.byTooltip('New goal'), findsNothing);
await pump(tester, goalsApp(isAdmin: true));
expect(find.byTooltip('New goal'), findsOneWidget);
});
testWidgets('prices a revenue goal and counts a deals goal', (
tester,
) async {
// The two goal types are not the same quantity, and printing a deal
// count with a currency symbol in front of it is the obvious way to get
// this wrong.
await pump(tester, goalsApp(isAdmin: true));
expect(find.textContaining('3 deals'), findsWidgets);
expect(find.textContaining(r'$'), findsWidgets);
});
testWidgets(
'says why the board is empty rather than showing a bare heading',
(tester) async {
await pump(tester, goalsApp(isAdmin: false, board: false));
// Scrolled to: the board sits under the filter bar, the summary and
// every goal row, so on a 390px screen a lazy ListView has not built it
// yet when the screen first settles.
await tester.scrollUntilVisible(
find.textContaining('Nothing to rank yet'),
200,
// Named explicitly: the filter bar's chip row is a second Scrollable
// on this screen, and the default finder matches both.
scrollable: find
.descendant(
of: find.byType(ListView),
matching: find.byType(Scrollable),
)
.first,
);
expect(find.textContaining('Nothing to rank yet'), findsOneWidget);
expect(tester.takeException(), isNull);
},
);
testWidgets(
'tells a member the list is narrowed to them, not that the org has none',
(tester) async {
// The API narrows a non-admin to their own goals and their teams', so
// "there are no goals" would be a claim this screen cannot make.
await pump(tester, goalsApp(isAdmin: false, empty: true));
expect(find.textContaining('assigned to you'), findsOneWidget);
},
);
testWidgets('every goal row clears the 44px tap target for an admin', (
tester,
) async {
await pump(tester, goalsApp(isAdmin: true));
// Scoped to the list. Every InkWell on the screen would also catch the
// AppBar's icon buttons, which Material sizes at 40 on every screen in
// this app and which are not this module's to change.
final rows = find.descendant(
of: find.byType(ListView),
matching: find.byType(InkWell),
);
expect(rows, findsWidgets);
for (final row in tester.widgetList<InkWell>(rows)) {
if (row.onTap == null) continue;
expect(
tester.getSize(find.byWidget(row)).height,
greaterThanOrEqualTo(44),
);
}
});
testWidgets('names a weighted goal so its arithmetic is not a surprise', (
tester,
) async {
// A weighted goal's progress does not add up to the deals behind it, and
// somebody reconciling it against the pipeline needs to know before they
// file a bug.
await pump(tester, goalsApp(isAdmin: true));
expect(find.textContaining('Weighted (1)'), findsOneWidget);
});
testWidgets('offers the filter controls at a phone width', (tester) async {
await pump(tester, goalsApp(isAdmin: true));
expect(find.text('Running today'), findsOneWidget);
expect(find.text('Search goals by name'), findsOneWidget);
expect(tester.takeException(), isNull);
});
});
/// The form pushed on top of a parent route, because a successful save pops
/// back to the list and a single-route test router has nothing to pop.
Widget routedForm(Widget screen) => MaterialApp.router(
routerConfig: GoRouter(
initialLocation: '/here/edit',
routes: [
GoRoute(
path: '/here',
builder: (_, _) => const Scaffold(body: Text('goals list')),
routes: [GoRoute(path: 'edit', builder: (_, _) => screen)],
),
],
),
);
/// The edit form for a goal that already carries weights.
Widget goalFormApp(_CapturingGoals capture) => ProviderScope(
overrides: [
goalsProvider.overrideWith(() => capture),
isOrgAdminProvider.overrideWithValue(true),
goalProvider('g1').overrideWith(
(ref) async => SalesGoal.fromJson(const {
'id': 'g1',
'name': 'Q3 revenue',
'goal_type': 'REVENUE',
'target_value': '250000.00',
'period_type': 'QUARTERLY',
'period_start': '2026-07-01',
'period_end': '2026-09-30',
'type_weights': {'RENEWAL': 0.5},
'is_active': true,
}),
),
],
child: routedForm(const GoalFormScreen(goalId: 'g1')),
);
group('goal form weights', () {
testWidgets('opens already showing a weighting the goal carries', (
tester,
) async {
// Hidden behind a collapsed control, an existing weight is invisible, and
// the next person to edit the goal would not know its progress is scaled.
await pump(tester, goalFormApp(_CapturingGoals()));
expect(find.text('Weight by deal type'), findsOneWidget);
expect(find.widgetWithText(TextField, '0.5'), findsOneWidget);
});
testWidgets('keeps the weights when the section is collapsed and saved', (
tester,
) async {
// The web half of this had a real bug: there the inputs ARE the state, so
// collapsing removed them from the form and the save cleared the goal's
// weighting. Here the controllers belong to the State and outlive the
// widgets, so collapsing is only a view change. This pins that, because
// the two clients reaching the same outcome by different means is exactly
// the kind of thing a later refactor breaks on one side only.
final capture = _CapturingGoals();
await pump(tester, goalFormApp(capture));
await tester.ensureVisible(find.text('Weight by deal type'));
await tester.tap(find.text('Weight by deal type'));
await tester.pumpAndSettle();
expect(find.widgetWithText(TextField, '0.5'), findsNothing);
// Scrolled to: the form is taller than a 390px screen, so the save
// button is off-view when it settles.
await tester.ensureVisible(find.text('Save changes'));
await tester.pumpAndSettle();
await tester.tap(find.text('Save changes'));
await tester.pumpAndSettle();
expect(capture.body?['type_weights'], {'RENEWAL': 0.5});
});
testWidgets('clears a weight when the box is emptied', (tester) async {
// The other half of the same rule: the map is sent on every save, so an
// emptied box has to actually remove the weight rather than be ignored.
final capture = _CapturingGoals();
await pump(tester, goalFormApp(capture));
await tester.ensureVisible(find.widgetWithText(TextField, '0.5'));
await tester.enterText(find.widgetWithText(TextField, '0.5'), '');
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Save changes'));
await tester.pumpAndSettle();
await tester.tap(find.text('Save changes'));
await tester.pumpAndSettle();
expect(capture.body?['type_weights'], isEmpty);
});
});
/// Goal history: one card per finished period and goal type.
Widget historyApp({List<GoalHistoryPeriod>? periods}) => ProviderScope(
overrides: [
goalHistoryProvider.overrideWith(
(ref) async => periods ?? _historyFixture(),
),
],
child: routed(const GoalHistoryScreen()),
);
group('goal history', () {
testWidgets('renders finished periods without overflowing', (tester) async {
await pump(tester, historyApp());
expect(find.textContaining('1 May - 31 May'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('fits with the system font scaled up', (tester) async {
await pump(tester, historyApp(), textScale: 1.5);
expect(tester.takeException(), isNull);
});
testWidgets('keeps each unit in its own card', (tester) async {
// Grouping on the period alone pooled a currency target with a deals
// target and printed the sum as money. One card is one unit.
await pump(tester, historyApp());
expect(find.textContaining(r'$'), findsWidgets);
expect(find.textContaining('4 deals'), findsWidgets);
});
testWidgets('reports attainment above target rather than capping it', (
tester,
) async {
await pump(tester, historyApp());
expect(find.textContaining('134% of target'), findsOneWidget);
});
testWidgets('says nothing has finished rather than showing a bare list', (
tester,
) async {
await pump(tester, historyApp(periods: const []));
expect(find.textContaining('No finished periods yet'), findsOneWidget);
});
});
/// Documents. [myEmail] decides who uploaded what, which is the whole of the
/// edit affordance: `_may_write` is the uploader or an admin, and a share
/// grants neither.
Widget documentsApp({
required bool isAdmin,
String? myEmail,
bool empty = false,
bool fileless = false,
}) => ProviderScope(
overrides: [
documentsProvider.overrideWith(
empty
? _FakeNoDocuments.new
: fileless
? _FakeDocumentsOneFileless.new
: _FakeDocuments.new,
),
isOrgAdminProvider.overrideWithValue(isAdmin),
myEmailProvider.overrideWithValue(myEmail),
],
child: routed(const DocumentsListScreen()),
);
group('documents', () {
testWidgets('renders without overflowing', (tester) async {
await pump(
tester,
documentsApp(isAdmin: false, myEmail: 'ada@example.com'),
);
expect(find.text('Signed contract'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('fits with the system font scaled up', (tester) async {
await pump(
tester,
documentsApp(isAdmin: false, myEmail: 'ada@example.com'),
textScale: 1.5,
);
expect(tester.takeException(), isNull);
});
testWidgets('offers upload to a member, because the API does', (
tester,
) async {
// `DocumentListView.post` checks authentication and org context and
// nothing more. Gating this on role would invent a rule.
await pump(
tester,
documentsApp(isAdmin: false, myEmail: 'grace@example.com'),
);
expect(find.byTooltip('Upload'), findsOneWidget);
});
testWidgets('offers edit on your own upload and not on a share', (
tester,
) async {
// Ada uploaded the contract and the notes; the policy is Grace's. A
// share is a copy to work with, not the original to rewrite.
await pump(
tester,
documentsApp(isAdmin: false, myEmail: 'ada@example.com'),
);
expect(_editableRows(tester), 2);
});
testWidgets('an admin may edit every row', (tester) async {
await pump(
tester,
documentsApp(isAdmin: true, myEmail: 'someone@example.com'),
);
expect(_editableRows(tester), 3);
});
testWidgets('offers the download to a member, not just to a writer', (
tester,
) async {
// Reading is broad and editing is narrow, so a row somebody may open but
// not change still responds. Grace can edit one of these three and open
// all three.
await pump(
tester,
documentsApp(isAdmin: false, myEmail: 'grace@example.com'),
);
expect(_editableRows(tester), 1);
expect(_downloadableRows(tester), 3);
});
testWidgets('a row with no file behind it does not respond', (
tester,
) async {
await pump(
tester,
documentsApp(isAdmin: true, myEmail: 'ada@example.com', fileless: true),
);
// Two rows carry a file; the third does not, and tapping it would only
// reach a 404.
expect(_downloadableRows(tester), 2);
});
testWidgets(
'names who a document reaches, and says when it reaches nobody',
(tester) async {
await pump(
tester,
documentsApp(isAdmin: true, myEmail: 'ada@example.com'),
);
// A team is named with its size rather than flattened to its members.
expect(find.textContaining('Support (3)'), findsOneWidget);
expect(
find.text('Shared with nobody. Only you and an admin can open it.'),
findsOneWidget,
);
},
);
testWidgets('says the counts exclude archived rows while they are hidden', (
tester,
) async {
await pump(
tester,
documentsApp(isAdmin: true, myEmail: 'ada@example.com'),
);
expect(find.textContaining('not shown or counted'), findsOneWidget);
});
});
group('custom objects at 390px', () {
for (final scale in [1.0, 1.3, 1.5]) {
final at = 'text scale $scale';
testWidgets('the records list, $at', (tester) async {
await pump(tester, objRecordsApp(), textScale: scale);
expect(tester.takeException(), isNull);
expect(find.textContaining('Harbourfront'), findsWidgets);
});
testWidgets('the records list without writes, $at', (tester) async {
await pump(tester, objRecordsApp(writes: false), textScale: scale);
expect(tester.takeException(), isNull);
expect(find.textContaining('New '), findsNothing);
});
testWidgets('the record detail with every section, $at', (tester) async {
await pump(tester, objDetailApp(), textScale: scale);
expect(tester.takeException(), isNull);
await scrollToEnd(tester);
expect(tester.takeException(), isNull);
});
testWidgets('the record detail, read only, $at', (tester) async {
await pump(
tester,
objDetailApp(canEdit: false, canDelete: false),
textScale: scale,
);
expect(tester.takeException(), isNull);
await scrollToEnd(tester);
expect(tester.takeException(), isNull);
});
testWidgets('the record form, editing, $at', (tester) async {
await pump(tester, objFormApp(recordId: _objId), textScale: scale);
expect(tester.takeException(), isNull);
await scrollToEnd(tester);
expect(tester.takeException(), isNull);
});
testWidgets('the record form, creating, $at', (tester) async {
await pump(tester, objFormApp(), textScale: scale);
expect(tester.takeException(), isNull);
await scrollToEnd(tester);
expect(tester.takeException(), isNull);
});
testWidgets('the objects settings list, admin with writes, $at', (
tester,
) async {
await pump(
tester,
objSettingsListApp(admin: true, writes: true),
textScale: scale,
);
expect(tester.takeException(), isNull);
});
testWidgets('the objects settings list, admin without writes, $at', (
tester,
) async {
await pump(
tester,
objSettingsListApp(admin: true, writes: false),
textScale: scale,
);
expect(tester.takeException(), isNull);
expect(
find.text(
'Creating and editing is turned off for this organization.',
),
findsOneWidget,
);
});
testWidgets('the objects settings list, member, $at', (tester) async {
await pump(
tester,
objSettingsListApp(admin: false, writes: true),
textScale: scale,
);
expect(tester.takeException(), isNull);
});
testWidgets('one object settings, admin with writes, $at', (
tester,
) async {
await pump(
tester,
objSettingsApp(admin: true, writes: true),
textScale: scale,
);
expect(tester.takeException(), isNull);
await scrollToEnd(tester);
expect(tester.takeException(), isNull);
});
testWidgets('one object settings, admin without writes, $at', (
tester,
) async {
await pump(
tester,
objSettingsApp(admin: true, writes: false),
textScale: scale,
);
expect(tester.takeException(), isNull);
await scrollToEnd(tester);
expect(tester.takeException(), isNull);
});
testWidgets('one archived object settings, $at', (tester) async {
await pump(
tester,
objSettingsApp(admin: true, writes: true, archived: true),
textScale: scale,
);
expect(tester.takeException(), isNull);
await scrollToEnd(tester);
expect(tester.takeException(), isNull);
});
testWidgets('the field sheet on a link field, $at', (tester) async {
await pump(
tester,
objFieldSheetApp(_objFields.firstWhere((f) => f.key == 'agent')),
textScale: scale,
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('the field sheet on a dropdown field, $at', (tester) async {
await pump(
tester,
objFieldSheetApp(_objFields.firstWhere((f) => f.key == 'kind')),
textScale: scale,
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('the field sheet adding a field, $at', (tester) async {
await pump(tester, objFieldSheetApp(null), textScale: scale);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('a lead detail carrying Related, $at', (tester) async {
await pump(tester, objLeadApp(), textScale: scale);
expect(tester.takeException(), isNull);
await scrollToEnd(tester);
expect(tester.takeException(), isNull);
expect(find.text('Related'), findsOneWidget);
});
}
});
group('automation at 390px', () {
Widget screenApp(Widget screen) => ProviderScope(
key: UniqueKey(),
overrides: automationOverrides(),
child: MaterialApp.router(
theme: AppTheme.light,
routerConfig: GoRouter(
initialLocation: '/here',
routes: [GoRoute(path: '/here', builder: (_, _) => screen)],
),
),
);
Widget sheetApp(Future<void> Function(BuildContext context) open) =>
ProviderScope(
key: UniqueKey(),
overrides: automationOverrides(),
child: MaterialApp(
theme: AppTheme.light,
home: Scaffold(
body: Builder(
builder: (context) => Center(
child: ElevatedButton(
onPressed: () => open(context),
child: const Text('open'),
),
),
),
),
),
);
final deal = autoCatalogue.objects.first;
Future<void> dragThrough(WidgetTester tester, Finder scrollable) async {
for (var i = 0; i < 8; i++) {
await tester.drag(scrollable, const Offset(0, -400));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
}
}
Future<void> openSheet(
WidgetTester tester,
Future<void> Function(BuildContext context) open,
double scale,
) async {
await pump(tester, sheetApp(open), textScale: scale);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
await dragThrough(tester, find.byType(ListView).last);
}
void expectThumbSized(WidgetTester tester, Finder target) {
final size = tester.getSize(target);
expect(size.width, greaterThanOrEqualTo(44), reason: '$target');
expect(size.height, greaterThanOrEqualTo(44), reason: '$target');
}
final sheetActions = <String, ActionDraft>{
'create a task for someone no longer active': autoActions[0],
'notify recipients and people': autoActions[1],
'assign in turn': autoActions[2],
'set a grouped stage': ActionDraft(
type: 'set_field',
values: {'target': 'record', 'field': 'stage', 'value': 's-ent-won'},
),
'send to an endpoint': ActionDraft(
type: 'webhook',
values: {'endpoint': 'e-1'},
),
};
test('the long name is the name field limit', () {
expect(longRuleName.length, 120);
});
for (final scale in [1.0, 1.3, 1.5]) {
final at = 'text scale $scale';
testWidgets('the rules list, $at', (tester) async {
await pump(
tester,
screenApp(const AutomationRulesScreen()),
textScale: scale,
);
expect(tester.takeException(), isNull);
expect(find.text(longRuleName), findsOneWidget);
expectThumbSized(tester, find.byTooltip('Move down').first);
await scrollToEnd(tester);
expect(tester.takeException(), isNull);
});
testWidgets('a new rule, $at', (tester) async {
await pump(
tester,
screenApp(const AutomationRuleScreen()),
textScale: scale,
);
expect(tester.takeException(), isNull);
await dragThrough(tester, find.byType(SingleChildScrollView).first);
});
testWidgets('a rule with three actions and a long name, $at', (
tester,
) async {
await pump(
tester,
screenApp(const AutomationRuleScreen(ruleId: autoRuleId)),
textScale: scale,
);
expect(tester.takeException(), isNull);
expect(find.text(longRuleName), findsWidgets);
expectThumbSized(tester, find.byTooltip('Remove condition 1'));
await dragThrough(tester, find.byType(SingleChildScrollView).first);
expect(find.textContaining('3. Assign in turn to'), findsOneWidget);
expectThumbSized(tester, find.byTooltip('Move action 3 up'));
expectThumbSized(tester, find.widgetWithText(FilledButton, 'Save'));
});
testWidgets('the trigger sheet on a grouped stage, $at', (tester) async {
await openSheet(
tester,
(context) => showTriggerSheet(
context,
catalogue: autoCatalogue,
object: deal,
draft: autoTrigger.copy(),
),
scale,
);
});
for (final (i, name) in [
(1, 'grouped stages'),
(2, 'a person no longer active'),
]) {
testWidgets('the condition sheet on $name, $at', (tester) async {
await openSheet(
tester,
(context) => showConditionSheet(
context,
catalogue: autoCatalogue,
object: deal,
event: 'updated',
draft: autoConditions[i].copy(),
),
scale,
);
});
}
for (final e in sheetActions.entries) {
testWidgets('the action sheet to ${e.key}, $at', (tester) async {
await openSheet(
tester,
(context) => showActionSheet(
context,
catalogue: autoCatalogue,
object: deal,
draft: e.value.copy(),
),
scale,
);
expectThumbSized(tester, find.widgetWithText(FilledButton, 'Done'));
});
}
testWidgets('the try results, $at', (tester) async {
await openSheet(
tester,
(context) => showTryResults(
context,
recordName: longRecordName,
steps: autoTrySteps,
),
scale,
);
expect(find.text('Would fail'), findsOneWidget);
});
testWidgets('the run history with every run opened, $at', (tester) async {
await pump(
tester,
screenApp(const AutomationRunsScreen(ruleId: autoRuleId)),
textScale: scale,
);
expect(tester.takeException(), isNull);
for (final status in ['Done', 'Failed']) {
await tester.scrollUntilVisible(
find.text(status),
200,
scrollable: find.byType(Scrollable).first,
);
final pill = find.text(status).first;
await tester.ensureVisible(pill);
await tester.pumpAndSettle();
await tester.tap(pill);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
find.text(status == 'Done' ? 'Undone' : 'Skipped'),
findsWidgets,
);
}
await scrollToEnd(tester);
expect(tester.takeException(), isNull);
expect(find.text('<b>Bold</b> [open](javascript:x)'), findsOneWidget);
expectThumbSized(tester, find.widgetWithText(OutlinedButton, 'Older'));
});
}
});
group('modules screen', () {
Widget modulesApp({bool refuse = false}) => ProviderScope(
key: UniqueKey(),
overrides: modulesOverrides(
FakeOrgModules(
() async => modulesAnswer(
off: ['deals', 'tasks'],
labels: {'deals': _orgTerm40},
),
refusals: refuse
? [
'Tickets still receives submissions from 2 published web '
'forms: "Billing help", "Zed help". Unpublish them '
'first, so no submission is lost.',
'Tickets still has a mailbox that creates tickets from '
'inbound email: "support@example.com". Switch it off '
'first.',
'Leads still receives submissions from 1 published web '
'form: "Contact us". Unpublish it first.',
]
: const [],
),
),
child: MaterialApp(theme: AppTheme.light, home: const ModulesScreen()),
);
for (final scale in [1.0, 1.3, 1.5]) {
testWidgets('the list with a refusal, text scale $scale', (tester) async {
await pump(tester, modulesApp(refuse: true), textScale: scale);
await tester.tap(find.byType(Switch).at(1));
await tester.pumpAndSettle();
await tester.tap(find.text('Switch off').last);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(_orgTerm40.length, 40);
expect(find.textContaining('Billing help'), findsOneWidget);
expect(find.textContaining('inbound email'), findsOneWidget);
expect(find.textContaining('Contact us'), findsOneWidget);
expect(find.text('Web forms'), findsOneWidget);
// Three messages push the cards below the fold at large text.
await tester.drag(find.byType(Scrollable).first, const Offset(0, 800));
await tester.pumpAndSettle();
await tester.drag(find.byType(Scrollable).first, const Offset(0, -380));
await tester.pumpAndSettle();
for (final tip in ['Move up', 'Move down']) {
final size = tester.getSize(
find
.byWidgetPredicate((w) => w is IconButton && w.tooltip == tip)
.first,
);
expect(size.width, greaterThanOrEqualTo(44));
expect(size.height, greaterThanOrEqualTo(44));
}
await scrollToEnd(tester);
expect(tester.takeException(), isNull);
});
}
testWidgets('the list on a tablet, text scale 1.3', (tester) async {
tester.view.devicePixelRatio = 2.0;
tester.view.physicalSize = const Size(834 * 2, 1112 * 2);
tester.platformDispatcher.textScaleFactorTestValue = 1.3;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
await tester.pumpWidget(modulesApp());
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
await scrollToEnd(tester);
expect(tester.takeException(), isNull);
});
});
group('module off view', () {
for (final scale in [1.0, 1.3, 1.5]) {
for (final admin in [true, false]) {
testWidgets('a long label, admin $admin, text scale $scale', (
tester,
) async {
await pump(
tester,
ProviderScope(
key: UniqueKey(),
overrides: modulesOverrides(
FakeOrgModules(() async => modulesAnswer()),
admin: admin,
),
child: MaterialApp(
theme: AppTheme.light,
home: const ModuleOffView(
label: 'Opportunities and quotes for every account',
),
),
),
textScale: scale,
);
expect(tester.takeException(), isNull);
for (final button in [
find.byType(PrimaryButton),
if (admin) find.byType(OutlinedButton),
]) {
final size = tester.getSize(button);
expect(size.height, greaterThanOrEqualTo(44));
expect(size.width, greaterThanOrEqualTo(44));
}
});
}
}
});
group('account with modules switched off', () {
for (final scale in [1.0, 1.3, 1.5]) {
for (final (name, off) in [
('all on', <String>[]),
('three off', ['deals', 'tickets', 'invoices']),
]) {
testWidgets('account, text $scale, $name', (tester) async {
await pump(
tester,
ProviderScope(
key: UniqueKey(),
overrides: [
...modulesOverrides(
FakeOrgModules(() async => modulesAnswer(off: off)),
),
accountsProvider.overrideWith(_ModulesFakeAccounts.new),
duplicatesApiProvider.overrideWithValue(_NoObjectDuplicates()),
customObjectsProvider.overrideWith(
() => _FakeObjectsAnswer(null),
),
],
child: MaterialApp(
theme: AppTheme.light,
home: const AccountDetailScreen(accountId: 'a1'),
),
),
textScale: scale,
);
expect(tester.takeException(), isNull);
final shown = off.isEmpty ? findsOneWidget : findsNothing;
expect(find.textContaining('Deals ('), shown);
expect(find.textContaining('Tickets ('), shown);
expect(find.text('Open tickets'), shown);
expect(find.byTooltip('Statement'), shown);
expect(find.textContaining('Contacts ('), findsOneWidget);
expect(find.byTooltip('Edit account'), findsOneWidget);
expect(find.byTooltip('Delete account'), findsOneWidget);
_expectTappablesThumbSized(tester);
await scrollToEnd(tester);
expect(tester.takeException(), isNull);
expect(find.textContaining('Tickets ('), shown);
_expectTappablesThumbSized(tester);
});
}
}
});
group('home and More with modules switched off', () {
for (final scale in [1.0, 1.3, 1.5]) {
for (final (name, off) in [
('all on', <String>[]),
('some off', ['deals', 'leads', 'tasks']),
('all off', ['deals', 'leads', 'tasks', 'goals', 'invoices']),
]) {
final at = 'text $scale, $name';
testWidgets('home, $at', (tester) async {
await pump(
tester,
ProviderScope(
key: UniqueKey(),
overrides: [
...modulesOverrides(
FakeOrgModules(() async => modulesAnswer(off: off)),
),
authProvider.overrideWith(_ModulesFakeAuth.new),
dashboardProvider.overrideWith(_ModulesFakeDashboard.new),
notificationsProvider.overrideWith(_FakeEmptyNotifications.new),
],
child: MaterialApp(
theme: AppTheme.light,
home: const DashboardScreen(),
),
),
textScale: scale,
);
expect(tester.takeException(), isNull);
final dealsAndLeadsOff =
off.contains('deals') && off.contains('leads');
final tasksOff = off.contains('tasks');
expect(
find.text('Open Deals'),
off.contains('deals') ? findsNothing : findsOneWidget,
);
expect(
find.text('Pipeline Overview'),
off.contains('deals') ? findsNothing : findsOneWidget,
);
expect(
find.text('Hot Leads'),
off.contains('leads') ? findsNothing : findsWidgets,
);
expect(
find.text('Upcoming Tasks'),
tasksOff ? findsNothing : findsOneWidget,
);
await scrollToEnd(tester);
_expectTappablesThumbSized(tester);
await tester.drag(
find.byType(Scrollable).first,
const Offset(0, 4000),
);
await tester.pumpAndSettle();
_expectTappablesThumbSized(tester);
final fab = find.byType(FloatingActionButton);
if (dealsAndLeadsOff && tasksOff) {
expect(fab, findsNothing);
return;
}
await tester.tap(fab);
await tester.pumpAndSettle();
final tooled = off.isEmpty ? findsOneWidget : findsNothing;
expect(find.text('Add Lead'), tooled);
expect(find.text('Add Deal'), tooled);
expect(find.text('Add Task'), tooled);
expect(tester.takeException(), isNull);
});
testWidgets('More, $at', (tester) async {
await pump(
tester,
ProviderScope(
key: UniqueKey(),
overrides: [
...modulesOverrides(
FakeOrgModules(() async => modulesAnswer(off: off)),
),
customObjectsProvider.overrideWith(
() => _FakeObjectsAnswer(null),
),
notificationsProvider.overrideWith(_FakeEmptyNotifications.new),
],
child: MaterialApp(
theme: AppTheme.light,
home: const MoreScreen(),
),
),
textScale: scale,
);
expect(tester.takeException(), isNull);
final tasksRow = find.widgetWithText(MenuRow, 'Tasks');
final goalsRow = find.widgetWithText(MenuRow, 'Goals');
final taskAndGoals = off.contains('deals') && off.contains('tasks');
for (final row in [tasksRow, goalsRow]) {
expect(row, taskAndGoals ? findsNothing : findsOneWidget);
}
for (var i = 0; i < 8; i++) {
for (final row in find.byType(MenuRow).evaluate()) {
final size = tester.getSize(find.byWidget(row.widget));
expect(size.height, greaterThanOrEqualTo(44));
}
await tester.drag(
find.byType(Scrollable).first,
const Offset(0, -500),
);
await tester.pumpAndSettle();
}
expect(tester.takeException(), isNull);
});
}
}
});
}
/// The home screen's urgent badges and "See All" links were 15 to 23px tall
/// before the modules work and are not part of it, so they are excluded here
/// rather than fixed in passing.
bool _isOlderSmallLink(Element element) {
var badge = false;
var insideButton = false;
element.visitAncestorElements((e) {
final w = e.widget;
if (w.runtimeType.toString() == '_UrgentBadge') badge = true;
// A button's own InkWell is its visual size; the button pads the hit area.
if (w is IconButton || w is ButtonStyleButton) insideButton = true;
return !badge;
});
if (badge || insideButton) return true;
var seeAll = false;
void walk(Element e) {
final w = e.widget;
if (w is Text && w.data == 'See All') seeAll = true;
if (!seeAll) e.visitChildren(walk);
}
walk(element);
return seeAll;
}
/// Every tappable on screen, measured as drawn. Only what is hit-testable
/// counts: a widget scrolled out of the viewport is not built or not on screen.
void _expectTappablesThumbSized(WidgetTester tester) {
final tappables = <Finder>[
find.byType(InkWell),
find.byType(IconButton),
find.byType(ButtonStyleButton),
find.byType(FloatingActionButton),
find.byWidgetPredicate((w) => w is GestureDetector && w.onTap != null),
];
for (final finder in tappables) {
for (final element in finder.evaluate()) {
final box = element.renderObject! as RenderBox;
final rect = box.localToGlobal(Offset.zero) & box.size;
if (rect.bottom < 0 || rect.top > 844) continue;
if (_isOlderSmallLink(element)) continue;
expect(
box.size.height,
greaterThanOrEqualTo(44),
reason: '${element.widget} at $rect',
);
}
}
}
const _orgTerm40 = 'Opportunities, quotes and renewals pipes';
class _ModulesFakeAccounts extends AccountsNotifier {
@override
Future<AccountsListData> build() async => const AccountsListData();
@override
Future<Account?> getAccount(String id, {void Function()? onNotFound}) async =>
Account.fromJson(canDelete: true, {
'id': id,
'name': 'Northwind Traders International Holdings',
'currency': 'USD',
'contacts': [
{'id': 'c1', 'first_name': 'Ada', 'last_name': 'Lovelace'},
],
'opportunities': [
{'id': 'd1', 'name': 'Renewal'},
],
'cases': [
{'id': 't1', 'name': 'Printer'},
],
'tasks': [
{'id': 'k1', 'title': 'Call'},
],
'rollups': {
'won_amount': '500.00',
'won_count': 1,
'open_pipeline': '10.00',
'open_deal_count': 1,
'overdue_amount': '0',
'open_tickets': 2,
'by_currency': [
{
'currency': 'USD',
'won_amount': '500.00',
'open_pipeline': '10.00',
'overdue_amount': '0',
},
],
},
});
}
class _ModulesFakeDashboard extends DashboardNotifier {
@override
Future<DashboardData> build() async => const DashboardData(
urgentCounts: UrgentCounts(overdueTasks: 2, followupsToday: 1, hotLeads: 1),
pipelineByStage: [
PipelineStage(code: 'qualified', label: 'Qualified', count: 2, value: 40),
],
hotLeads: [HotLead(id: 'l1', firstName: 'Ada', lastName: 'Lovelace')],
tasks: [DashboardTask(id: 't1', title: 'Call Ada')],
);
@override
Future<void> refresh() async {}
}
class _ModulesFakeAuth extends AuthNotifier {
@override
AuthState build() {
const org = Organization(id: 'org-1', name: 'Test Org');
return AuthState(
user: const AuthUser(id: 'user-1', email: 'user@example.com'),
organizations: [org],
selectedOrganization: org,
isAuthenticated: true,
);
}
}
/// A working week with one holiday, which is what most orgs look like.
BusinessCalendar _calendar({bool alwaysOn = false, bool holidays = true}) {
final json = <String, dynamic>{
'id': 'c1',
'name': 'Support desk',
'timezone': 'Asia/Kolkata',
'is_default': true,
'holidays': holidays
? const [
{'id': 'h1', 'date': '2026-12-25', 'name': 'Xmas'},
]
: const [],
};
const open = {'monday', 'tuesday', 'wednesday', 'thursday', 'friday'};
for (final (_, key) in businessWeekdays) {
final isOpen = !alwaysOn && open.contains(key);
json['${key}_open'] = isOpen ? '09:00:00' : null;
json['${key}_close'] = isOpen ? '17:00:00' : null;
}
return BusinessCalendar.fromJson(json);
}
class _FakeCalendar extends BusinessHoursNotifier {
@override
Future<BusinessCalendar> build() async => _calendar();
}
class _FakeClosedCalendar extends BusinessHoursNotifier {
@override
Future<BusinessCalendar> build() async => _calendar(alwaysOn: true);
}
class _FakeNoHolidayCalendar extends BusinessHoursNotifier {
@override
Future<BusinessCalendar> build() async => _calendar(holidays: false);
}
class _FakeReopen extends ReopenPolicyNotifier {
@override
Future<ReopenPolicy> build() async => ReopenPolicy.fromJson(const {
'is_enabled': true,
'reopen_window_days': 7,
'reopen_to_status': 'Pending',
'notify_assigned': true,
'reopened_last_30d': 6,
'replies_after_window_30d': 4,
'median_days_to_reply': 2,
});
}
class _FakeReopenOff extends ReopenPolicyNotifier {
@override
Future<ReopenPolicy> build() async => ReopenPolicy.fromJson(const {
'is_enabled': false,
'reopen_window_days': 7,
'reopen_to_status': 'Pending',
'notify_assigned': true,
'reopened_last_30d': 1,
'replies_after_window_30d': 0,
'median_days_to_reply': 9,
});
}
/// A policy set on Urgent, both halves live.
EscalationPolicy _urgentPolicy() => EscalationPolicy.fromJson(const {
'id': 'e1',
'priority': 'Urgent',
'is_active': true,
// A first response target this org set, and a resolution target it never
// touched, so one card shows both halves of the "6h" / "4h default" split.
'first_response_hours': 6,
'first_response_action': 'notify',
'resolution_action': 'reassign',
'first_response_target': {
'id': 'a1',
'is_active': true,
'user_details': {'name': 'Ada', 'email': 'ada@example.com'},
},
'resolution_target': {
'id': 'a1',
'is_active': true,
'user_details': {'name': 'Ada', 'email': 'ada@example.com'},
},
'notify_team': {'id': 't1', 'name': 'Support'},
'breaches_last_30d': {'first_response': 3, 'resolution': 2},
});
/// A policy whose first-response target is somebody deactivated. The engine
/// still escalates to them, with no active filter.
EscalationPolicy _deactivatedTargetPolicy() => EscalationPolicy.fromJson(const {
'id': 'e9',
'priority': 'High',
'is_active': true,
'first_response_action': 'notify',
'resolution_action': 'notify',
'first_response_target': {
'id': 'c1',
'is_active': false,
'user_details': {'name': 'Cai', 'email': 'cai@example.com'},
},
'resolution_target': null,
'notify_team': null,
'breaches_last_30d': {'first_response': 0, 'resolution': 0},
});
/// Three policies covering every state a card has to draw, and Low left
/// unconfigured so the "no policy at all" note has something to say.
///
/// The High policy is the case the web read wrong: a `notify` half with a team
/// and no target. Its 11 breaches are the ones that reached nobody while the
/// row looked configured.
class _FakeEscalation extends EscalationNotifier {
@override
Future<EscalationState> build() async => EscalationState(
policies: sortedEscalationPolicies([
_urgentPolicy(),
EscalationPolicy.fromJson(const {
'id': 'e2',
'priority': 'High',
'is_active': true,
'first_response_action': 'notify',
'resolution_action': 'notify_and_reassign',
'first_response_target': null,
'resolution_target': {
'id': 'b1',
'is_active': true,
'user_details': {'name': 'Brin', 'email': 'brin@example.com'},
},
'notify_team': {'id': 't1', 'name': 'Support'},
'breaches_last_30d': {'first_response': 11, 'resolution': 1},
}),
EscalationPolicy.fromJson(const {
'id': 'e3',
'priority': 'Normal',
'is_active': false,
'first_response_action': 'notify',
'resolution_action': 'notify',
'first_response_target': {
'id': 'a1',
'is_active': true,
'user_details': {'name': 'Ada', 'email': 'ada@example.com'},
},
'resolution_target': {
'id': 'a1',
'is_active': true,
'user_details': {'name': 'Ada', 'email': 'ada@example.com'},
},
'notify_team': null,
'breaches_last_30d': {'first_response': 4, 'resolution': 4},
}),
]),
);
}
class _FakeNoEscalation extends EscalationNotifier {
@override
Future<EscalationState> build() async => const EscalationState();
}
/// A tag set with every state the screen has to draw: a duplicate pair, a name
/// long enough to wrap, a tag applied to nothing, and one already turned off.
class _FakeTags extends TagsNotifier {
@override
Future<TagsState> build() async => TagsState(
tags: sortedTags([
Tag.fromJson(const {
'id': 't1',
'name': 'Renewals',
'slug': 'renewals',
'is_active': true,
'usage': {'leads': 9},
}),
Tag.fromJson(const {
'id': 't2',
'name': 'Renewal',
'slug': 'renewal',
'is_active': true,
'usage': {'leads': 2},
}),
Tag.fromJson(const {
'id': 't3',
'name': 'Escalated by the customer success team',
'slug': 'escalated-by-the-customer-success-team',
'is_active': true,
'usage': {'leads': 2, 'cases': 1},
}),
Tag.fromJson(const {
'id': 't4',
'name': 'Dormant',
'slug': 'dormant',
'is_active': true,
'usage': {'leads': 0},
}),
Tag.fromJson(const {
'id': 't5',
'name': 'Legacy import',
'slug': 'legacy-import',
'is_active': false,
'usage': {'leads': 4},
}),
]),
count: 5,
active: 4,
unused: 1,
);
}
class _FakeNoTags extends TagsNotifier {
@override
Future<TagsState> build() async => const TagsState();
}
/// Three rules covering every state a row has to draw. The rotation is built
/// so the two ways of reading the cursor wrong give different answers: the
/// active pool in id order is [Ada, Brin], cursor 1 is Brin, while adding one
/// and indexing the serializer's list gives Cai, who is deactivated.
class _FakeRoutingRules extends RoutingRulesNotifier {
@override
Future<RoutingRulesState> build() async => RoutingRulesState(
rules: [
RoutingRule.fromJson(const {
'id': 'r1',
'name': 'Billing questions',
'priority_order': 10,
'is_active': true,
'strategy': 'round_robin',
'stop_processing': true,
'conditions': [
{
'field': 'priority',
'op': 'in',
'value': ['High', 'Urgent'],
},
],
'target_assignees': [
{
'id': 'a1',
'is_active': true,
'user_details': {'name': 'Ada'},
},
{
'id': 'b1',
'is_active': true,
'user_details': {'name': 'Brin'},
},
{
'id': 'c1',
'is_active': false,
'user_details': {'name': 'Cai'},
},
],
'matched_last_30d': 12,
'state': {'last_assigned_index': 1},
}),
RoutingRule.fromJson(const {
'id': 'r2',
'name': 'Everything else',
'priority_order': 200,
'is_active': true,
'strategy': 'direct',
'stop_processing': false,
'conditions': [],
'target_assignees': [],
'matched_last_30d': 0,
}),
RoutingRule.fromJson(const {
'id': 'r3',
'name': 'Old escalation',
'priority_order': 300,
'is_active': false,
'strategy': 'by_team',
'target_team': {'id': 't1', 'name': 'Billing crew'},
'matched_last_30d': 4,
}),
],
count: 3,
active: 2,
unroutedLast30d: 9,
);
}
class _FakeNoRoutingRules extends RoutingRulesNotifier {
@override
Future<RoutingRulesState> build() async => const RoutingRulesState();
}
/// One estimate still to convert, one already billed.
class _FakeEstimates extends EstimatesNotifier {
@override
Future<List<Estimate>> build() async => [
Estimate.fromJson({
'id': 'e1',
'estimate_number': 'EST-0001',
'title': 'Warehouse rebuild',
'status': 'Accepted',
'account_name': 'Northwind Traders and Logistics Incorporated',
'total_amount': '52000.00',
'currency': 'USD',
'expiry_date': '2026-09-30',
}),
Estimate.fromJson({
'id': 'e2',
'estimate_number': 'EST-0002',
'title': 'Support',
'status': 'Accepted',
'account_name': 'Initech',
'total_amount': '1000.00',
'currency': 'USD',
'converted_to_invoice': {'id': 'inv-9', 'invoice_number': 'INV-0009'},
}),
];
}
/// One running monthly schedule and one paused custom one. Both carry a next
/// generation date; only the running one may show it.
class _FakeRecurring extends RecurringNotifier {
@override
Future<List<RecurringInvoice>> build() async => [
RecurringInvoice.fromJson({
'id': 'r1',
'title': 'Managed hosting retainer',
'account_name': 'Northwind Traders and Logistics Incorporated',
'frequency': 'MONTHLY',
'is_active': true,
'auto_send': true,
'next_generation_date': '2026-09-01',
'total_amount': '1200.00',
'currency': 'USD',
'invoices_generated': 14,
}),
RecurringInvoice.fromJson({
'id': 'r2',
'title': 'Onsite visits',
'account_name': 'Initech',
'frequency': 'CUSTOM',
'custom_days': 10,
'is_active': false,
'next_generation_date': '2026-08-20',
'total_amount': '400.00',
'currency': 'USD',
}),
];
}
/// One live product and one retired that has been billed.
class _FakeProducts extends ProductsNotifier {
@override
Future<List<Product>> build() async => [
Product.fromJson({
'id': 'p1',
'name': 'Managed hosting, standard tier with onsite support',
'sku': 'MH-STD-001',
'category': 'Hosting',
'price': '1200.00',
'currency': 'USD',
'is_active': true,
'used_on': 3,
}),
Product.fromJson({
'id': 'p2',
'name': 'Legacy plan',
'sku': 'LEG-001',
'price': '99.00',
'currency': 'USD',
'is_active': false,
'used_on': 12,
}),
];
}
/// The org default plus one alternative, so the row that holds the flag and
/// the row that could take it are both on screen.
class _FakeTemplates extends InvoiceTemplatesNotifier {
@override
Future<List<InvoiceTemplate>> build() async => [
InvoiceTemplate.fromJson({
'id': 't1',
'name': 'House style',
'is_default': true,
'primary_color': '#3B82F6',
'secondary_color': '#1E40AF',
'has_logo': true,
'has_custom_html': true,
// Deliberately not the field hints, which say "Thanks for your
// business." and "Payment due within 30 days.": a fixture that matches
// its own placeholder cannot tell a pre-filled field from an empty one.
'default_notes': 'Thanks, as ever.',
'default_terms': 'Settle within 14 days.',
'footer_text': 'Questions? Ask for Dana.',
'used_on_invoices': 4,
}),
InvoiceTemplate.fromJson({
'id': 't2',
'name': 'Minimal, for the quarterly retainer statements',
'is_default': false,
'primary_color': '#4B5563',
'used_on_invoices': 0,
}),
];
}
/// Monday has one stopped entry, Tuesday a running one, Wednesday nothing.
class _FakeTimesheet extends TimesheetNotifier {
@override
Future<TimesheetWeek> build() async => TimesheetWeek(
start: DateTime(2026, 8, 3),
end: DateTime(2026, 8, 9),
profileName: 'Ada',
runningCount: 1,
days: [
TimesheetDay(
date: DateTime(2026, 8, 3),
entries: [
TimeEntry(
id: 'e1',
caseId: 'case-9',
caseName: 'Printer on fire in the third floor copy room',
profileId: 'p1',
startedAt: DateTime(2026, 8, 3, 9),
endedAt: DateTime(2026, 8, 3, 11),
durationMinutes: 120,
billable: true,
description: 'Diagnosis and a replacement fuser',
invoiceId: 'inv-2',
invoiceNumber: 'INV-0002',
),
],
),
TimesheetDay(
date: DateTime(2026, 8, 4),
entries: [
TimeEntry(
id: 'e2',
caseId: 'case-9',
caseName: 'Printer on fire',
profileId: 'p1',
startedAt: DateTime(2026, 8, 4, 8, 48),
billable: true,
liveDurationMinutes: 12,
),
],
),
TimesheetDay(date: DateTime(2026, 8, 5)),
],
);
}
Invoice _row({
required String id,
required String number,
required String status,
required String account,
required String total,
required String due,
String currency = 'USD',
String amountDue = '0.00',
}) {
return Invoice.fromJson({
'id': id,
'invoice_number': number,
'invoice_title': 'Retainer',
'status': status,
'account': 'acc-$id',
'account_name': account,
'total_amount': total,
'amount_due': amountDue,
'due_date': due,
'currency': currency,
'line_items_count': 2,
});
}
/// `YYYY-MM-DD` for the day `days` from today, for a fixture whose meaning
/// depends on being in the future or the past whenever the test runs.
String _isoDateFromToday(int days) =>
DateTime.now().add(Duration(days: days)).toIso8601String().substring(0, 10);
/// A draft, a long-overdue invoice, and one settled years ago.
class _FakeInvoices extends InvoicesNotifier {
@override
Future<InvoicesListData> build() async => InvoicesListData(
totals: const InvoiceTotals(
count: 3,
money: {
'USD': InvoiceMoneyTotals(
outstanding: 12500,
overdue: 1250,
draft: 400,
),
},
actionNeeded: 2,
),
invoices: [
_row(
id: '1',
number: 'INV-0001',
status: 'Overdue',
// Long on purpose: the name and the amount share one row.
account: 'Northwind Traders and Logistics Incorporated',
total: '1250.00',
amountDue: '1250.00',
due: '2024-01-15',
),
_row(
id: '2',
number: 'INV-0002',
status: 'Draft',
account: 'Initech',
total: '400.00',
amountDue: '400.00',
// Always a month out, so the draft is never late. A fixed date here
// went stale and gave the draft a "d late" label of its own.
due: _isoDateFromToday(30),
),
_row(
id: '3',
number: 'INV-0003',
status: 'Paid',
account: 'Umbrella',
total: '900.00',
due: '2020-03-01',
),
],
);
}
/// The same list billed in two currencies, which makes the server's sums a
/// total of unlike things. Due dates stay in the future: a fixed one aged into
/// a "Nd late" label that a test asserting no "200" would one day match.
class _FakeMixedInvoices extends InvoicesNotifier {
@override
Future<InvoicesListData> build() async => InvoicesListData(
totals: const InvoiceTotals(
count: 2,
money: {
'EUR': InvoiceMoneyTotals(outstanding: 100),
'USD': InvoiceMoneyTotals(outstanding: 100),
},
),
invoices: [
_row(
id: '1',
number: 'INV-0001',
status: 'Sent',
account: 'Northwind',
total: '100.00',
amountDue: '100.00',
due: _isoDateFromToday(30),
),
_row(
id: '2',
number: 'INV-0002',
status: 'Sent',
account: 'Initech',
total: '100.00',
amountDue: '100.00',
due: _isoDateFromToday(30),
currency: 'EUR',
),
],
);
}
class _FakeEmptyNotifications extends NotificationsNotifier {
@override
Future<NotificationFeed> build() async => const NotificationFeed();
}
class _FakeAutomationNotifications extends NotificationsNotifier {
@override
Future<NotificationFeed> build() async {
final items = [
for (final n in automationNotificationsJson())
AppNotification.fromJson(n),
];
return NotificationFeed(
items: items,
unreadCount: items.length,
total: items.length,
);
}
}
class _FakeNotifications extends NotificationsNotifier {
@override
Future<NotificationFeed> build() async => NotificationFeed(
unreadCount: 1,
total: 2,
items: [
AppNotification(
id: 'n1',
verb: 'case.mentioned',
actorName: 'Ada Lovelace',
entityName: 'Printer on fire',
commentExcerpt: 'can you take a look at this before Friday?',
link: '/tickets/case-9',
createdAt: DateTime.now().subtract(const Duration(minutes: 20)),
),
AppNotification(
id: 'n2',
verb: 'case.sla_breached',
actorName: 'Grace',
entityName: 'Handled',
link: '/tickets/case-1',
readAt: DateTime.now().subtract(const Duration(days: 1)),
createdAt: DateTime.now().subtract(const Duration(days: 2)),
),
],
);
}
/// Three fields across two record types: one required with a real gap, one
/// dropdown, and one already turned off. Between them they light every branch
/// the row and the summary have.
class _FakeCustomFields extends CustomFieldsNotifier {
@override
Future<CustomFieldsState> build() async => CustomFieldsState(
fields: [
CustomFieldDefinition.fromJson(const {
'id': 'f1',
'target_model': 'Case',
'key': 'severity',
'label': 'Severity',
'field_type': 'text',
'is_required': true,
'is_filterable': true,
'display_order': 0,
'is_active': true,
'records_missing_value': 23,
}),
CustomFieldDefinition.fromJson(const {
'id': 'f2',
'target_model': 'Case',
'key': 'root_cause',
'label': 'Root cause, as agreed with the customer at close',
'field_type': 'dropdown',
'display_order': 1,
'is_active': true,
'options': [
{'value': 'process', 'label': 'Process'},
{'value': 'people', 'label': 'People'},
],
}),
CustomFieldDefinition.fromJson(const {
'id': 'f3',
'target_model': 'Lead',
'key': 'campaign',
'label': 'Campaign',
'field_type': 'text',
'is_active': false,
}),
],
count: 3,
active: 2,
modelsExtended: 2,
requiredWithGaps: 1,
);
}
/// One shared reply, one of mine, and one of mine already turned off. Between
/// them they drive both answers of every control on the screen.
class _FakeMacros extends MacrosNotifier {
@override
Future<MacrosState> build() async => MacrosState(
macros: [
Macro.fromJson(const {
'id': 'm1',
'title': 'Password reset, standard wording agreed with legal',
'body': 'Hi %custmer_name%, follow the link to reset your password.',
'scope': 'org',
'owner_name': null,
'is_active': true,
'usage_count': 41,
'unknown_placeholders': ['%custmer_name%'],
}),
Macro.fromJson(const {
'id': 'm2',
'title': 'My follow-up',
'body': 'Just checking in on this one.',
'scope': 'personal',
'owner_name': 'me@example.com',
'is_active': true,
'usage_count': 0,
}),
Macro.fromJson(const {
'id': 'm3',
'title': 'Old holiday notice',
'body': 'We are closed until January.',
'scope': 'personal',
'owner_name': 'me@example.com',
'is_active': false,
'usage_count': 7,
}),
],
placeholders: const [
MacroPlaceholder(
token: '%customer_name%',
resolves: "The case's first contact",
),
MacroPlaceholder(token: '%case_id%', resolves: 'The case number'),
],
orgCount: 1,
personalCount: 2,
inactiveCount: 1,
brokenCount: 1,
);
}
/// Two live tokens, both used recently enough not to be flagged, and one issued
/// today that has never been used.
AccessTokensState _tokenState({bool orphaned = false}) {
final now = DateTime.now();
String ago(int days) => now.subtract(Duration(days: days)).toIso8601String();
Map<String, dynamic> row({
required String id,
required String name,
String? lastUsed,
bool ownerActive = true,
}) => {
'id': id,
'name': name,
'token_prefix': 'bcrm_pat_abc',
'scopes': const <String>[],
'expires_at': null,
'last_used_at': lastUsed,
'created_at': ago(1),
'revoked_at': null,
'is_live': true,
'owner': {
'id': 'p1',
'name': 'Ada Lovelace',
'role': 'ADMIN',
'is_active': ownerActive,
},
};
final tokens = [
AccessToken.fromJson(
row(id: 't1', name: 'Nightly export', lastUsed: ago(2)),
),
// Never used, issued yesterday: the row that used to read "never used" in
// warning colour the moment it was created.
AccessToken.fromJson(
row(id: 't2', name: 'Just made', ownerActive: !orphaned),
),
];
return AccessTokensState(
tokens: sortedTokens(tokens),
totals: TokenTotals(
count: 2,
live: 2,
orphaned: orphaned ? 1 : 0,
unused90d: 0,
),
);
}
class _FakeTokens extends AccessTokensNotifier {
@override
Future<AccessTokensState> build() async => _tokenState();
}
/// Rows shaped the way `/api/profile/tokens/` really answers them: no `owner`
/// block, and no `is_live`, which the org oversight endpoint computes and this
/// one does not send.
List<AccessToken> _myTokenRows() {
final now = DateTime.now();
String ago(int days) => now.subtract(Duration(days: days)).toIso8601String();
return sortedTokens([
AccessToken.fromJson({
'id': 'm1',
'name': 'My export script',
'token_prefix': 'bcrm_pat_abc',
'scopes': const <String>[],
'expires_at': null,
'last_used_at': ago(2),
'created_at': ago(30),
'revoked_at': null,
}),
// Revoked, and with no `is_live` on the payload. The model used to default
// that flag to true, so this row drew as Live and offered a Revoke button.
AccessToken.fromJson({
'id': 'm2',
'name': 'Old laptop',
'token_prefix': 'bcrm_pat_def',
'scopes': const ['*:read'],
'expires_at': null,
'last_used_at': null,
'created_at': ago(200),
'revoked_at': ago(5),
}),
]);
}
class _FakeMyTokens extends MyAccessTokensNotifier {
@override
Future<List<AccessToken>> build() async => _myTokenRows();
}
class _FakeNoMyTokens extends MyAccessTokensNotifier {
@override
Future<List<AccessToken>> build() async => const [];
}
class _FakeOrphanedTokens extends AccessTokensNotifier {
@override
Future<AccessTokensState> build() async => _tokenState(orphaned: true);
}
/// Three addresses: one delivering, one on an unimplemented provider, one SES
/// address that has never had its subscription confirmed. The last two are the
/// states both clients used to draw as "Creating tickets".
Map<String, dynamic> _mailboxJson({
required String id,
required String address,
String provider = 'ses',
bool isActive = true,
bool hasTopicArn = true,
}) => {
'id': id,
'address': address,
'provider': provider,
'is_active': isActive,
'has_topic_arn': hasTopicArn,
'default_priority': 'Normal',
'default_case_type': null,
'default_assignee': null,
'cases_last_30d': 3,
'last_received_at': '2026-08-01T09:00:00Z',
};
MailboxesState _mailboxState({bool allLive = false}) {
final rows = [
Mailbox.fromJson(_mailboxJson(id: 'm1', address: 'support@acme.com')),
Mailbox.fromJson(
_mailboxJson(
id: 'm2',
address: 'billing@acme.com',
provider: allLive ? 'ses' : 'mailgun',
),
),
Mailbox.fromJson(
_mailboxJson(
id: 'm3',
address: 'help@acme.com',
hasTopicArn: allLive ? true : false,
),
),
];
return MailboxesState(
mailboxes: sortedMailboxes(rows),
count: rows.length,
casesLast30d: 9,
);
}
class _FakeMailboxes extends MailboxesNotifier {
@override
Future<MailboxesState> build() async => _mailboxState();
}
class _FakeLiveMailboxes extends MailboxesNotifier {
@override
Future<MailboxesState> build() async => _mailboxState(allLive: true);
}
/// Two rules: an admin one with a named approver (so the row has to say the
/// role AND the name), and a manager one with nobody named (so nothing it
/// gates can be closed at all).
List<ApprovalRule> _approvalRules({bool shadowed = false}) => [
ApprovalRule.fromJson({
'id': 'r1',
'name': 'Close review',
'is_active': true,
'approver_role': 'ADMIN',
'approvers': [
{'id': 'p1', 'email': 'ada@acme.com', 'is_active': true},
],
'match_priority': shadowed ? null : 'Urgent',
'match_case_type': null,
'match_team': null,
'pending_count': 2,
'created_at': '2026-01-01T00:00:00Z',
}),
// In the shadowed variant this is the older twin, so it carries all three
// flags at once (nobody can clear, never runs, and approvals waiting). That
// is the widest a row gets, and it is the case that decides whether the
// badges have to wrap at 390px.
ApprovalRule.fromJson({
'id': 'r2',
'name': 'Manager sign-off',
'is_active': true,
'approver_role': 'MANAGER',
'approvers': const [],
'match_priority': null,
'match_case_type': shadowed ? null : 'Incident',
'match_team': null,
'pending_count': shadowed ? 3 : 0,
'created_at': shadowed ? '2025-06-01T00:00:00Z' : '2026-02-01T00:00:00Z',
}),
];
class _FakeApprovalRules extends ApprovalRulesNotifier {
@override
Future<ApprovalRulesState> build() async => ApprovalRulesState(
rules: _approvalRules(),
count: 2,
active: 2,
pending: 2,
);
}
class _FakeShadowedRules extends ApprovalRulesNotifier {
@override
Future<ApprovalRulesState> build() async => ApprovalRulesState(
rules: _approvalRules(shadowed: true),
count: 2,
active: 2,
pending: 2,
);
}
/// A configured org, with the cascade switch off, which is the model default.
OrgSettings _org({bool cascade = false}) => OrgSettings.fromJson({
'id': 'org-1',
'name': 'MicroPyramid',
'company_name': 'MicroPyramid Informatics Pvt Ltd',
'address_line': '12 Road',
'city': 'Hyderabad',
'state': 'Telangana',
'postcode': '500081',
'country': 'IN',
'phone': '+91 40 1234',
'email': 'hello@example.com',
'website': 'https://example.com',
'tax_id': 'GSTIN123',
'default_currency': 'USD',
'default_country': 'IN',
'currency_symbol': '\$',
'timezone': 'Asia/Kolkata',
'csat_enabled': true,
'auto_close_children_on_parent_close': cascade,
'vertical': '',
'member_count': 4,
'created_at': '2026-01-05T10:00:00Z',
});
class _FakeOrg extends OrgSettingsNotifier {
@override
Future<OrgSettings> build() async => _org();
}
class _FakeOrgCascadeOn extends OrgSettingsNotifier {
@override
Future<OrgSettings> build() async => _org(cascade: true);
}
class _FakeNoQueue extends SupportTicketsNotifier {
@override
Future<SupportTicketListData> build() async =>
throw const SupportUnavailable();
}
class _FakeEmptyQueue extends SupportTicketsNotifier {
@override
Future<SupportTicketListData> build() async => const SupportTicketListData();
}
/// Goals covering every state a row draws: a revenue goal behind pace, a
/// deals-closed goal (a different quantity, so it must not be priced), and a
/// retired one that the totals leave out.
class _FakeGoals extends GoalsNotifier {
@override
Future<GoalsData> build() async => _goalsFixture(withBoard: true);
}
/// The same list with an empty board. Not an error state: the endpoint now
/// narrows its rows the way the list does, so somebody with no current monthly
/// goal of their own sees nothing to rank.
class _FakeGoalsNoBoard extends GoalsNotifier {
@override
Future<GoalsData> build() async => _goalsFixture(withBoard: false);
}
List<GoalHistoryPeriod> _historyFixture() => [
GoalHistoryPeriod.fromJson(const {
'period_start': '2026-04-01',
'period_end': '2026-06-30',
'period_type': 'QUARTERLY',
'goal_type': 'REVENUE',
'goals_count': 1,
'attained_count': 1,
'target': 984000,
'achieved': 1318859,
'percent': 134,
'goals': [
{
'id': 'h1',
'name': 'Q2 revenue quota',
'goal_type': 'REVENUE',
'target_value': '984000.00',
'progress_value': 1318859,
'progress_percent': 100,
},
],
}),
GoalHistoryPeriod.fromJson(const {
'period_start': '2026-05-01',
'period_end': '2026-05-31',
'period_type': 'MONTHLY',
'goal_type': 'DEALS_CLOSED',
'goals_count': 4,
'attained_count': 0,
'target': 56,
'achieved': 4,
'percent': 7,
'goals': [
{
'id': 'h2',
'name': 'May new logo drive',
'goal_type': 'DEALS_CLOSED',
'target_value': '18',
'progress_value': 2,
'progress_percent': 11,
},
],
}),
];
/// Records the body the form sends instead of calling the API.
class _CapturingGoals extends GoalsNotifier {
Map<String, dynamic>? body;
@override
Future<GoalsData> build() async => const GoalsData();
@override
Future<ApiResponse<Map<String, dynamic>>> updateGoal(
String id,
Map<String, dynamic> payload,
) async {
body = payload;
return ApiResponse(success: true, data: const {}, statusCode: 200);
}
}
class _FakeNoGoals extends GoalsNotifier {
@override
Future<GoalsData> build() async => const GoalsData();
}
GoalsData _goalsFixture({required bool withBoard}) {
final goals = sortGoalsByUrgency([
SalesGoal.fromJson(const {
'id': 'g1',
'name': 'Q3 revenue',
'goal_type': 'REVENUE',
'type_weights': {'RENEWAL': 0.5},
'target_value': '250000.00',
'period_type': 'QUARTERLY',
'period_start': '2026-07-01',
'period_end': '2026-09-30',
'assigned_to': 'p1',
'assigned_to_detail': {
'id': 'p1',
'user_details': {'name': 'Ada Lovelace', 'email': 'ada@example.com'},
},
'is_active': true,
'progress_value': 90000,
'progress_percent': 36,
'status': 'behind',
}),
SalesGoal.fromJson(const {
'id': 'g2',
'name': 'New logos this month',
'goal_type': 'DEALS_CLOSED',
'target_value': '8',
'period_type': 'MONTHLY',
'period_start': '2026-08-01',
'period_end': '2026-08-31',
'team': 't1',
'team_detail': {'id': 't1', 'name': 'Support'},
'is_active': true,
'progress_value': 3,
'progress_percent': 37,
'status': 'on_track',
}),
SalesGoal.fromJson(const {
'id': 'g3',
'name': 'Last year, closed out',
'goal_type': 'REVENUE',
'target_value': '900000.00',
'period_type': 'YEARLY',
'period_start': '2025-01-01',
'period_end': '2025-12-31',
'is_active': false,
'progress_value': 900000,
'progress_percent': 100,
'status': 'completed',
}),
]);
return GoalsData(
goals: goals,
leaderboard: withBoard
? [
GoalLeaderRow.fromJson(const {
'rank': 1,
'goal_id': 'g1',
'goal_name': 'Q3 revenue',
// A name, not an email. The endpoint used to send the address
// here and again under an `email` key.
'user': {'id': 'p1', 'name': 'Ada Lovelace'},
'target': 250000.0,
'achieved': 260000.0,
'percent': 104,
}),
]
: const [],
totals: goalTotals(goals, today: '2026-08-08'),
);
}
/// Three documents: one Ada uploaded and shared with a team, one Ada uploaded
/// and shared with nobody, and one Grace uploaded. The third is what proves a
/// share does not carry a right to rewrite.
class _FakeDocuments extends DocumentsNotifier {
@override
Future<DocumentsData> build() async {
final documents = [
CrmDocument.fromJson(const {
'id': 'd1',
'title': 'Signed contract',
'document_file': 'documents/2026/signed-contract.pdf',
'status': 'active',
'shared_to': [],
'teams': [
{'id': 't1', 'name': 'Support', 'member_count': 3},
],
'size_bytes': 284000,
'created_at': '2026-08-05T10:00:00Z',
'created_by': {
'id': 'u1',
'name': 'Ada Lovelace',
'email': 'ada@example.com',
},
}),
CrmDocument.fromJson(const {
'id': 'd2',
'title': 'Discovery notes, first call with a very long customer name',
'document_file': 'documents/2026/notes.md',
'status': 'active',
'shared_to': [],
'teams': [],
'size_bytes': null,
'created_at': '2026-08-04T10:00:00Z',
'created_by': {
'id': 'u1',
'name': 'Ada Lovelace',
'email': 'ada@example.com',
},
}),
CrmDocument.fromJson(const {
'id': 'd3',
'title': 'Refund policy',
'document_file': 'documents/2026/refunds.docx',
'status': 'active',
'shared_to': [
{
'id': 'p1',
'user_details': {
'name': 'Ada Lovelace',
'email': 'ada@example.com',
},
},
],
'teams': [],
'size_bytes': 12000,
'created_at': '2026-08-03T10:00:00Z',
'created_by': {
'id': 'u2',
'name': 'Grace Hopper',
'email': 'grace@example.com',
},
}),
];
return DocumentsData(
documents: documents,
totals: documentTotals(documents),
);
}
}
class _FakeNoDocuments extends DocumentsNotifier {
@override
Future<DocumentsData> build() async => const DocumentsData();
}
/// The same three rows, with the last one carrying no file.
///
/// A Document row can exist with an empty `document_file`; the list still
/// returns it, and the download endpoint 404s on it, so the row must not offer
/// one.
class _FakeDocumentsOneFileless extends _FakeDocuments {
@override
Future<DocumentsData> build() async {
final base = await super.build();
final documents = [
...base.documents.take(2),
CrmDocument.fromJson(const {
'id': 'd4',
'title': 'A record with no file',
'document_file': '',
'status': 'active',
'shared_to': [],
'teams': [],
'created_by': {
'id': 'u1',
'name': 'Ada Lovelace',
'email': 'ada@example.com',
},
}),
];
return DocumentsData(
documents: documents,
totals: documentTotals(documents),
);
}
}
/// Rows the viewer may edit.
///
/// Counted from the pencil buttons, not from tappable InkWells: since the row
/// itself became the download, every row with a file has an `onTap` whether or
/// not the viewer may edit it, so counting InkWells now measures "has a file".
int _editableRows(WidgetTester tester) =>
tester.widgetList(find.byTooltip('Manage this document')).length;
/// Rows offering a download, which is every row with a file behind it,
/// regardless of role. Reading is the broad privilege.
///
/// Counted from the download glyph rather than from tappable InkWells: an
/// IconButton builds one of its own, so the edit pencil would be counted as a
/// fourth downloadable row.
int _downloadableRows(WidgetTester tester) =>
tester.widgetList(find.byIcon(LucideIcons.download)).length;
const _objKey = 'property';
const _objId = '11111111-1111-1111-1111-111111111111';
const _objLeadId = '55555555-5555-5555-5555-555555555555';
/// 60 and 64 characters, the longest an object or field label may be.
final _objLong60 = ('Harbourfront residential ' * 3).substring(0, 60);
final _objLong64 = ('Estimated annual rental yield percentage ' * 2).substring(
0,
64,
);
const _objAddress =
'12 Harbour Road, Old Town Quarter, Northern District, Greater Metropolitan Area, Postcode 12345';
final _objSummary = ObjectSummary(
key: _objKey,
label: _objLong60,
labelPlural: _objLong60,
nameLabel: 'Address',
description: 'Every property we list, sell or manage for an owner.',
);
final _objSummaryArchived = ObjectSummary(
key: _objKey,
label: _objLong60,
labelPlural: _objLong60,
nameLabel: 'Address',
isActive: false,
);
final _objFields = [
ObjectField(
key: 'beds',
label: _objLong64,
fieldType: 'number',
displayOrder: 1,
isRequired: true,
),
const ObjectField(
key: 'baths',
label: 'Baths',
fieldType: 'number',
displayOrder: 2,
),
const ObjectField(
key: 'listed',
label: 'Listed',
fieldType: 'checkbox',
displayOrder: 3,
),
ObjectField(
key: 'kind',
label: 'Kind',
fieldType: 'dropdown',
displayOrder: 4,
options: [
const CustomFieldOption(value: 'flat', label: 'Flat'),
CustomFieldOption(value: 'house', label: _objLong64),
],
),
const ObjectField(
key: 'price',
label: 'Price',
fieldType: 'number',
displayOrder: 5,
),
const ObjectField(
key: 'built',
label: 'Built on',
fieldType: 'date',
displayOrder: 6,
),
const ObjectField(
key: 'city',
label: 'City',
fieldType: 'text',
displayOrder: 7,
),
const ObjectField(
key: 'notes',
label: 'Notes',
fieldType: 'textarea',
displayOrder: 8,
),
ObjectField(
key: 'agent',
label: _objLong64,
fieldType: 'link',
displayOrder: 9,
target: 'lead',
many: true,
),
const ObjectField(
key: 'buyers',
label: 'Buyers',
fieldType: 'link',
displayOrder: 10,
target: 'contact',
many: true,
),
const ObjectField(
key: 'seller',
label: 'Seller',
fieldType: 'link',
displayOrder: 11,
target: 'account',
many: false,
),
const ObjectField(
key: 'twin',
label: 'Twin property',
fieldType: 'link',
displayOrder: 12,
target: 'obj:property',
many: false,
),
const ObjectField(
key: 'old',
label: 'Old note',
fieldType: 'text',
displayOrder: 13,
isActive: false,
),
];
final _objDefinition = ObjectDefinition(
summary: _objSummary,
fields: _objFields,
);
CustomObjectsAnswer _objAnswer({required bool writes}) => CustomObjectsAnswer(
hasCustomObjects: writes,
objects: [
_objSummary,
const ObjectSummary(
key: 'vehicle',
label: 'Vehicle',
labelPlural: 'Vehicles',
isActive: false,
),
],
linkTargets: const [
LinkTarget(key: 'lead', label: 'Lead', labelPlural: 'Leads'),
LinkTarget(key: 'contact', label: 'Contact', labelPlural: 'Contacts'),
LinkTarget(key: 'account', label: 'Account', labelPlural: 'Accounts'),
LinkTarget(
key: 'obj:property',
label: 'Property',
labelPlural: 'Properties',
),
],
);
final _objRecordsData = ObjectRecordsData(
count: 3,
records: [
ObjectRecord(
id: _objId,
name: _objAddress,
customFields: const {
'beds': 3,
'baths': 2,
'city': 'Greater Metropolitan Area',
},
assignedTo: const [
PersonRef(id: 'p1'),
PersonRef(id: 'p2'),
],
),
const ObjectRecord(id: '2', name: '4 Mill Road', customFields: {'beds': 1}),
const ObjectRecord(id: '3', name: 'Untitled'),
],
);
Map<String, dynamic> _objAttachment(String id, String name, bool image) => {
'id': id,
'file_name': name,
'is_image': image,
'can_delete': true,
'created_by': {'id': 'p2', 'name': 'Meera', 'email': 'meera@example.com'},
'created_at': '2026-10-01T10:00:00Z',
};
Map<String, dynamic> _objRecordJson(bool canEdit, bool canDelete) => {
'id': _objId,
'name': _objAddress,
'custom_fields': {
'beds': 3,
'baths': 2,
'listed': true,
'kind': 'house',
'price': 1250000,
'built': '1998-04-12',
'city': 'Greater Metropolitan Area',
'notes':
'A long note about the property that wraps across several lines on a phone, mentioning the garden, the harbour view, and the history of the building.',
},
'assigned_to': [
{
'id': 'p1',
'name': 'Ravi Shankar Venkataraghavan',
'email': 'ravi@example.com',
},
{'id': 'gone', 'name': 'Old Hand', 'email': 'old@example.com'},
],
'created_by': {'id': 'p2', 'name': 'Meera', 'email': 'meera@example.com'},
'created_at': '2026-10-01T10:00:00Z',
'updated_at': '2026-10-02T10:00:00Z',
'links': {
'agent': {
'many': true,
'records': [
{
'id': _objLeadId,
'object': 'lead',
'name': 'Asha Raman-Venkataraghavan',
},
],
'hidden': 2,
},
'buyers': {
'many': true,
'records': [
{'id': 'c1', 'object': 'contact', 'name': 'Bela Nagy'},
{'id': 'c2', 'object': 'contact', 'name': 'Chen Wei'},
],
'hidden': 0,
},
'seller': {'many': false, 'records': const [], 'hidden': 1},
'twin': {
'many': false,
'records': [
{'id': _objId, 'object': 'obj:property', 'name': '4 Mill Road'},
],
'hidden': 0,
},
},
'can_edit': canEdit,
'can_delete': canDelete,
'attachments': [
_objAttachment('a1', 'front-of-the-house-from-the-harbour-side.jpg', true),
_objAttachment('a2', 'kitchen.png', true),
_objAttachment('a3', 'garden-in-spring-with-the-old-oak-tree.webp', true),
_objAttachment(
'a4',
'survey-report-and-structural-assessment-final-signed-version.pdf',
false,
),
_objAttachment('a5', 'floor-plan.pdf', false),
],
};
class _FakeObjectsAnswer extends CustomObjectsNotifier {
_FakeObjectsAnswer(this.answer);
final CustomObjectsAnswer? answer;
@override
Future<CustomObjectsAnswer?> build() async => answer;
}
class _FakeObjectRecords extends ObjectRecordsNotifier {
_FakeObjectRecords(super.key, this.data);
final ObjectRecordsData data;
@override
Future<ObjectRecordsData> build() async => data;
}
class _FakeObjectsApi extends CustomObjectsApi {
_FakeObjectsApi(this.record);
final Map<String, dynamic> record;
@override
Future<ApiResponse<Map<String, dynamic>>> fetchRecord(
String key,
String id,
) async => ApiResponse(success: true, data: record, statusCode: 200);
}
class _NoObjectDuplicates extends DuplicatesApi {
@override
Future<RecordDuplicates> forRecord(DuplicateModule module, String id) async =>
const RecordDuplicates();
}
class _FakeObjectLead extends LeadsNotifier {
@override
Future<LeadsListData> build() async => const LeadsListData();
@override
Future<LeadDetail?> getLeadDetail(
String id, {
void Function()? onNotFound,
}) async => LeadDetail(
lead: Lead.fromJson({
'id': id,
'first_name': 'Asha',
'last_name': 'Raman',
'status': 'assigned',
'created_by': {'email': 'me@example.com'},
}),
canDelete: true,
);
}