mirror of
https://github.com/kuhyx/testsAndMisc-archive.git
synced 2026-11-10 20:27:04 +01:00
billsplit was a standalone Flutter app in ~/Downloads with one commit and no git remote — that directory was the only copy of its history. Three things the move needed beyond copying the tree: - The root .gitignore silently swallowed 32 of its files (*.png, *.ico, *.manifest). Ignored files do not error on `git add`, they just vanish, so this would have committed an app that cannot build. Added a trailing block of ! overrides plus .binary-allowlist (which did not exist yet) — the documented in-repo exception for binaries a build genuinely needs. The ../testsAndMisc_binaries/ symlink scheme does not apply: those directories are gone, and a symlinked PNG breaks a fresh clone. - billsplit/.github/workflows/ci.yaml was inert once nested; GitHub only reads workflows at the repository root. Relocated with a working-directory and a billsplit/** path filter. - The app's coverage gate moved to python_pkg/billsplit_coverage/ so every Python file stays under python_pkg/, and gained a test suite at 100% branch coverage as that location requires. billsplit/ now holds only Dart. Also replaces the stock Flutter icon with a family icon from the shared generator. The glyph is a filled silhouette because the family's 72px stroke cannot carry a fine torn edge, and the split is an evenodd-cut perforation so the holes stay transparent on the foreground and monochrome layers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
190 lines
6.2 KiB
Dart
190 lines
6.2 KiB
Dart
import 'dart:io';
|
|
import 'dart:math';
|
|
|
|
import 'package:billsplit/domain/eparagon_parser.dart';
|
|
import 'package:billsplit/domain/models.dart';
|
|
import 'package:billsplit/domain/split_engine.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
List<Person> makePeople(int n, {Set<int> nonDrinkers = const {}}) => [
|
|
for (var i = 0; i < n; i++)
|
|
Person(id: 'p$i', name: 'P$i', drinks: !nonDrinkers.contains(i)),
|
|
];
|
|
|
|
void main() {
|
|
test('splitAmount: exact sum, deterministic, rotation moves the extra', () {
|
|
final shares = splitAmount(100, ['b', 'a', 'c']); // rotation 0
|
|
expect(shares.values.reduce((a, b) => a + b), 100);
|
|
expect(shares['a'], 34); // sorted order, start at index 0
|
|
expect(shares['b'], 33);
|
|
expect(shares['c'], 33);
|
|
final rotated = splitAmount(100, ['b', 'a', 'c'], rotation: 1);
|
|
expect(rotated.values.reduce((a, b) => a + b), 100);
|
|
expect(rotated['b'], 34); // start shifted by one
|
|
// Same inputs -> same output (deterministic).
|
|
expect(splitAmount(100, ['b', 'a', 'c'], rotation: 1), rotated);
|
|
});
|
|
|
|
test('splitAmount: negative totals (returns) also sum exactly', () {
|
|
final shares = splitAmount(-100, ['a', 'b', 'c']);
|
|
expect(shares.values.reduce((a, b) => a + b), -100);
|
|
expect(shares.values.every((v) => v == -33 || v == -34), isTrue);
|
|
});
|
|
|
|
test('splitAmount fuzz: sum invariant over random cases', () {
|
|
final rnd = Random(42);
|
|
for (var i = 0; i < 500; i++) {
|
|
final n = 1 + rnd.nextInt(9);
|
|
final total = rnd.nextInt(100000) - 5000;
|
|
final ids = List.generate(n, (j) => 'p$j');
|
|
final shares = splitAmount(total, ids, rotation: rnd.nextInt(1000));
|
|
expect(
|
|
shares.values.fold(0, (a, b) => a + b),
|
|
total,
|
|
reason: 'total=$total n=$n',
|
|
);
|
|
final values = shares.values.toList();
|
|
expect(
|
|
values.reduce(max) - values.reduce(min),
|
|
lessThanOrEqualTo(1),
|
|
reason: 'shares differ by more than 1 grosz',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('modes resolve correctly and intersect with receipt participants', () {
|
|
final people = makePeople(4, nonDrinkers: {3});
|
|
final group = Group(id: 'g1', name: 'Meat', memberIds: {'p0', 'p2'});
|
|
final receipt = Receipt(
|
|
id: 'r',
|
|
title: 't',
|
|
participantIds: {'p0', 'p1', 'p3'}, // p2 not on this receipt
|
|
);
|
|
Item mk(Assignment a) =>
|
|
Item(id: Ids.next(), name: 'x', totalGr: 300, assignment: a);
|
|
|
|
expect(
|
|
resolveParticipants(mk(Assignment()), receipt, people, [group])
|
|
.map((p) => p.id),
|
|
['p0', 'p1', 'p3'],
|
|
);
|
|
expect(
|
|
resolveParticipants(
|
|
mk(Assignment(mode: AssignMode.drinkers)),
|
|
receipt,
|
|
people,
|
|
[group],
|
|
).map((p) => p.id),
|
|
['p0', 'p1'],
|
|
);
|
|
expect(
|
|
resolveParticipants(
|
|
mk(Assignment(mode: AssignMode.nonDrinkers)),
|
|
receipt,
|
|
people,
|
|
[group],
|
|
).map((p) => p.id),
|
|
['p3'],
|
|
);
|
|
// Group intersected with receipt pool: p2 excluded.
|
|
expect(
|
|
resolveParticipants(
|
|
mk(Assignment(mode: AssignMode.group, groupId: 'g1')),
|
|
receipt,
|
|
people,
|
|
[group],
|
|
).map((p) => p.id),
|
|
['p0'],
|
|
);
|
|
expect(
|
|
resolveParticipants(
|
|
mk(Assignment(mode: AssignMode.custom, customIds: {'p1', 'p2'})),
|
|
receipt,
|
|
people,
|
|
[group],
|
|
).map((p) => p.id),
|
|
['p1'],
|
|
);
|
|
// Missing group -> unassigned.
|
|
expect(
|
|
resolveParticipants(
|
|
mk(Assignment(mode: AssignMode.group, groupId: 'gone')),
|
|
receipt,
|
|
people,
|
|
[group],
|
|
),
|
|
isEmpty,
|
|
);
|
|
});
|
|
|
|
test('real receipt: 8 people, all drinkers — reconciles to 597.55', () {
|
|
final receipt = parseEParagon(
|
|
File('test/fixtures/sample_receipt.json').readAsStringSync(),
|
|
);
|
|
final people = makePeople(8);
|
|
receipt.participantIds.addAll(people.map((p) => p.id));
|
|
|
|
final split = computeSplit(receipt, people, []);
|
|
expect(split.unassigned, isEmpty);
|
|
// Exact reconciliation is the hard invariant.
|
|
expect(split.assignedTotalGr, 59755);
|
|
// 59755 / 8 = 7469.375 -> everyone lands within a few grosze of that;
|
|
// remainder rotation spreads the extras instead of stacking them on the
|
|
// alphabetically-first person.
|
|
final totals = split.perPerson.values.map((t) => t.totalGr).toList();
|
|
for (final t in totals) {
|
|
expect(t, inInclusiveRange(7469 - 15, 7470 + 15));
|
|
}
|
|
// Alcohol: 79.98 + 28.74 = 108.72 split over 8 -> 13.59 each ±2 gr.
|
|
final alcohol = split.perPerson.values.map((t) => t.alcoholGr).toList();
|
|
expect(alcohol.fold(0, (a, b) => a + b), 10872);
|
|
for (final a in alcohol) {
|
|
expect(a, inInclusiveRange(1357, 1361));
|
|
}
|
|
});
|
|
|
|
test('real receipt: 2 non-drinkers pay no alcohol, totals still exact', () {
|
|
final receipt = parseEParagon(
|
|
File('test/fixtures/sample_receipt.json').readAsStringSync(),
|
|
);
|
|
final people = makePeople(8, nonDrinkers: {6, 7});
|
|
receipt.participantIds.addAll(people.map((p) => p.id));
|
|
|
|
final split = computeSplit(receipt, people, []);
|
|
expect(split.assignedTotalGr, 59755);
|
|
expect(split.perPerson['p6']!.alcoholGr, 0);
|
|
expect(split.perPerson['p7']!.alcoholGr, 0);
|
|
// 10872 over 6 drinkers = 1812 each ±2 gr, summing exactly.
|
|
final drinkerAlcohol = [
|
|
for (var i = 0; i < 6; i++) split.perPerson['p$i']!.alcoholGr,
|
|
];
|
|
expect(drinkerAlcohol.fold(0, (a, b) => a + b), 10872);
|
|
for (final a in drinkerAlcohol) {
|
|
expect(a, inInclusiveRange(1810, 1814));
|
|
}
|
|
// Non-drinkers pay strictly less.
|
|
expect(
|
|
split.perPerson['p7']!.totalGr,
|
|
lessThan(split.perPerson['p0']!.totalGr),
|
|
);
|
|
});
|
|
|
|
test('items with no possible participants are reported, not lost', () {
|
|
final people = makePeople(2); // both drinkers
|
|
final receipt = Receipt(id: 'r', title: 't', participantIds: {'p0', 'p1'});
|
|
receipt.items.add(
|
|
Item(
|
|
id: 'i1',
|
|
name: 'water',
|
|
totalGr: 500,
|
|
assignment: Assignment(mode: AssignMode.nonDrinkers),
|
|
),
|
|
);
|
|
receipt.items.add(Item(id: 'i2', name: 'bread', totalGr: 300));
|
|
|
|
final split = computeSplit(receipt, people, []);
|
|
expect(split.unassigned.map((i) => i.id), ['i1']);
|
|
expect(split.assignedTotalGr, 300);
|
|
});
|
|
}
|