import 'package:flutter_test/flutter_test.dart'; import 'package:groci/models/line_item.dart'; import 'package:groci/models/product.dart'; import 'package:groci/utils/product_names.dart'; import 'test_helpers.dart'; Product _product(int id, String name, {String? supermarket}) => Product( id: id, name: name, lastPrice: 1, timesSeen: 1, updatedAt: DateTime(2026, 1, 1), supermarket: supermarket, ); void main() { ensureTestSqlite(); group('nameConflictGroups', () { test('groups products that differ only by case at the same store', () { final groups = nameConflictGroups([ _product(1, 'Broccoli', supermarket: 'Jumbo'), _product(2, 'BROCCOLI', supermarket: 'Jumbo'), _product(3, 'Milk', supermarket: 'Jumbo'), ]); expect(groups, hasLength(1)); expect(groups.single.map((product) => product.id), [1, 2]); }); test('ignores same name at different stores', () { final groups = nameConflictGroups([ _product(1, 'Broccoli', supermarket: 'Jumbo'), _product(2, 'BROCCOLI', supermarket: 'Lidl'), ]); expect(groups, isEmpty); }); }); group('buildNameConflictGroups', () { test('finds conflicts from trip lines linked to different products', () { final groups = buildNameConflictGroups( products: [ _product(1, 'Broccoli', supermarket: 'Jumbo'), _product(2, 'BROCCOLI', supermarket: 'Jumbo'), ], shopLines: const [ (productId: 1, name: 'Broccoli', supermarket: 'Jumbo'), (productId: 2, name: 'BROCCOLI', supermarket: 'Jumbo'), ], ); expect(groups, hasLength(1)); expect(groups.single.products.map((product) => product.id), [1, 2]); expect(groups.single.supermarket, 'Jumbo'); }); }); group('findSpellingVariants', () { test('finds multiple receipt spellings for one product at one store', () { final variants = findSpellingVariants( products: [_product(1, 'Broccoli', supermarket: 'Jumbo')], shopLines: const [ (productId: 1, name: 'Broccoli', supermarket: 'Jumbo'), (productId: 1, name: 'BROCCOLI', supermarket: 'Jumbo'), ], ); expect(variants, hasLength(1)); expect(variants.single.product.id, 1); expect(variants.single.supermarket, 'Jumbo'); expect(variants.single.variants, ['Broccoli', 'BROCCOLI']); }); test('ignores different spellings at different stores', () { final variants = findSpellingVariants( products: [_product(1, 'Broccoli', supermarket: 'Jumbo')], shopLines: const [ (productId: 1, name: 'Broccoli', supermarket: 'Jumbo'), (productId: 1, name: 'BROCCOLI', supermarket: 'Lidl'), ], ); expect(variants, isEmpty); }); }); test('same name at different supermarkets creates separate products', () async { final repository = memoryRepository(); await repository.saveShop( supermarket: 'Jumbo', shoppedAt: DateTime(2026, 8, 1), items: [LineItem(id: '1', name: 'Milk', price: 1.5)], ); await repository.saveShop( supermarket: 'Lidl', shoppedAt: DateTime(2026, 8, 2), items: [LineItem(id: '2', name: 'Milk', price: 1.2)], ); final products = await repository.getAll(); expect(products, hasLength(2)); expect(products.map((p) => p.supermarket).toSet(), {'Jumbo', 'Lidl'}); expect(await repository.getProductNameIssues(), isEmpty); }); test('getNameConflictGroups finds duplicates in the database', () async { final repository = memoryRepository(); await seedCaseConflictCatalog(repository); final groups = await repository.getNameConflictGroups(); expect(groups, hasLength(1)); expect(groups.single.map((product) => product.name).toSet(), { 'Broccoli', 'BROCCOLI', }); }); test('getProductNameIssues finds receipt spelling variants', () async { final repository = memoryRepository(); await repository.saveShop( supermarket: 'Jumbo', shoppedAt: DateTime(2026, 8, 1), items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)], ); await repository.saveShop( supermarket: 'Jumbo', shoppedAt: DateTime(2026, 8, 2), items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)], ); final issues = await repository.getProductNameIssues(); expect(issues.mergeGroups, isEmpty); expect(issues.spellingVariants, hasLength(1)); expect(issues.spellingVariants.single.supermarket, 'Jumbo'); expect(issues.spellingVariants.single.variants, ['Broccoli', 'BROCCOLI']); }); test('dismissNameIssue hides a spelling variant', () async { final repository = memoryRepository(); await repository.saveShop( supermarket: 'Jumbo', shoppedAt: DateTime(2026, 8, 1), items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)], ); await repository.saveShop( supermarket: 'Jumbo', shoppedAt: DateTime(2026, 8, 2), items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)], ); final issue = (await repository.getProductNameIssues()).spellingVariants.single; await repository.dismissNameIssue(issue.key); expect(await repository.getProductNameIssues(), isEmpty); }); test('mergeProducts combines purchases onto the kept product', () async { final repository = memoryRepository(); await seedCaseConflictCatalog(repository); await repository.mergeProducts(keepId: 1, sourceIds: [2]); final after = await repository.getAll(); expect(after, hasLength(1)); expect(after.single.name, 'Broccoli'); expect(after.single.timesSeen, 2); expect(await repository.getPurchases(1), hasLength(2)); }); test('unifyProductSpelling updates catalog and receipt lines', () async { final repository = memoryRepository(); await repository.saveShop( supermarket: 'Jumbo', shoppedAt: DateTime(2026, 8, 1), items: [LineItem(id: '1', name: 'Broccoli', price: 1.5)], ); await repository.saveShop( supermarket: 'Jumbo', shoppedAt: DateTime(2026, 8, 2), items: [LineItem(id: '2', name: 'BROCCOLI', price: 1.8)], ); final product = (await repository.getAll()).single; await repository.unifyProductSpelling( productId: product.id, name: 'BROCCOLI', supermarket: 'Jumbo', ); final updated = (await repository.getProduct(product.id))!; expect(updated.name, 'BROCCOLI'); expect((await repository.getProductNameIssues()).isEmpty, isTrue); }); }