d9f992ca9a
- Added structured warning system with `AdminAiWarning` type in backend and Flutter - Implemented detailed reason descriptors with `FlyerReasonDescriptor` for parse and match operations - Added `legacyWarnings` field to maintain backward compatibility - Enhanced AI trace service to collect and format warnings with item-level context - Updated flyer import services to include detailed reason descriptions in responses - Added Swedish diacritic preservation for cheese variants (Prästost, Herrgårdsost, Grevéost) - Implemented UTF-8 content validation for AI responses - Added new reason code definitions in `reason-codes.ts` - Updated Flutter UI to display structured warnings with severity indicators - Added error report generation and copy functionality in admin panel - Added comprehensive test coverage for new warning system and cheese normalization BREAKING CHANGE: AI trace warnings are now structured objects instead of simple strings
220 lines
6.1 KiB
TypeScript
220 lines
6.1 KiB
TypeScript
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { FlyerNormalizerService } from './flyer-normalizer.service';
|
|
|
|
describe('FlyerNormalizerService', () => {
|
|
let service: FlyerNormalizerService;
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [FlyerNormalizerService],
|
|
}).compile();
|
|
|
|
service = module.get<FlyerNormalizerService>(FlyerNormalizerService);
|
|
});
|
|
|
|
it('should be defined', () => {
|
|
expect(service).toBeDefined();
|
|
});
|
|
|
|
describe('normalize', () => {
|
|
it('should normalize a valid item', () => {
|
|
const items = [
|
|
{
|
|
rawName: 'KALLRÖKT LAX, GRAVAD LAX',
|
|
normalizedName: 'kallrökt lax gravad lax',
|
|
category: 'Fisk',
|
|
price: 39.9,
|
|
comparisonPrice: 266.0,
|
|
unit: 'kg',
|
|
offer: ['Max 3 köp/hushåll'],
|
|
confidence: 0.85,
|
|
reasonCodes: ['ai_parsed'],
|
|
},
|
|
];
|
|
|
|
const result = service.normalize(items);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].rawName).toBe('KALLRÖKT LAX, GRAVAD LAX');
|
|
expect(result[0].price).toBe(39.9);
|
|
expect(result[0].priceUnit).toBe('kg');
|
|
expect(result[0].categoryHint).toBe('Fisk');
|
|
});
|
|
|
|
it('should handle missing fields gracefully', () => {
|
|
const items = [
|
|
{
|
|
name: 'PRODUKT',
|
|
// andra fält saknas
|
|
},
|
|
];
|
|
|
|
const result = service.normalize(items);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].rawName).toBe('PRODUKT');
|
|
expect(result[0].price).toBeNull();
|
|
expect(result[0].categoryHint).toBeNull();
|
|
});
|
|
|
|
it('should skip items without name', () => {
|
|
const items = [
|
|
{ price: 100 }, // no name
|
|
{ rawName: 'VALID PRODUCT', price: 50 },
|
|
];
|
|
|
|
const result = service.normalize(items);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].rawName).toBe('VALID PRODUCT');
|
|
});
|
|
|
|
it('should normalize units correctly', () => {
|
|
const items = [
|
|
{ rawName: 'Mjölk', unit: 'L' },
|
|
{ rawName: 'Smör', unit: 'styck' },
|
|
{ rawName: 'Socker', unit: 'KG' },
|
|
];
|
|
|
|
const result = service.normalize(items);
|
|
|
|
expect(result).toHaveLength(3);
|
|
expect(result[0].priceUnit).toBe('l');
|
|
expect(result[1].priceUnit).toBe('st');
|
|
expect(result[2].priceUnit).toBe('kg');
|
|
});
|
|
|
|
it('should parse Swedish prices correctly', () => {
|
|
const items = [
|
|
{ rawName: 'Produkt1', price: '39,90' },
|
|
{ rawName: 'Produkt2', price: 39.9 },
|
|
{ rawName: 'Produkt3', price: '100' },
|
|
];
|
|
|
|
const result = service.normalize(items);
|
|
|
|
expect(result[0].price).toBe(39.9);
|
|
expect(result[1].price).toBe(39.9);
|
|
expect(result[2].price).toBe(100);
|
|
});
|
|
|
|
it('should return empty list for non-array input', () => {
|
|
const result = service.normalize(null as any);
|
|
expect(result).toEqual([]);
|
|
|
|
const result2 = service.normalize(undefined as any);
|
|
expect(result2).toEqual([]);
|
|
});
|
|
|
|
it('splits listed cheese variants into separate products', () => {
|
|
const items = [
|
|
{
|
|
rawName: 'PRÄST®, HERRGÅRD®, GREVÉ®',
|
|
brand: 'ARLA KO',
|
|
unit: 'kg',
|
|
comparisonPrice: '79,90',
|
|
offer: ['Max 3 förp/hushåll'],
|
|
},
|
|
];
|
|
|
|
const result = service.normalize(items);
|
|
|
|
expect(result).toHaveLength(3);
|
|
expect(result.map((item) => item.rawName)).toEqual(['Prästost', 'Herrgårdsost', 'Grevéost']);
|
|
expect(result.every((item) => item.brand === 'Arla Ko')).toBe(true);
|
|
expect(result.every((item) => item.categoryHint === 'Hårdost')).toBe(true);
|
|
expect(result[0].parseReasons).toContain('split_cheese_variants');
|
|
});
|
|
|
|
it('normalizes PRAST token to Prästost', () => {
|
|
const items = [{ rawName: 'PRAST, GREVE', brand: 'ARLA KO' }];
|
|
|
|
const result = service.normalize(items);
|
|
|
|
expect(result.map((item) => item.rawName)).toContain('Prästost');
|
|
});
|
|
|
|
it('normalizes GREVE token to Grevéost', () => {
|
|
const items = [{ rawName: 'GREVE, PRAST', brand: 'ARLA KO' }];
|
|
|
|
const result = service.normalize(items);
|
|
|
|
expect(result.map((item) => item.rawName)).toContain('Grevéost');
|
|
});
|
|
|
|
it('keeps single cheese item unsplit but normalizes brand/category', () => {
|
|
const items = [
|
|
{
|
|
rawName: 'Prästost',
|
|
brand: 'arla ko',
|
|
},
|
|
];
|
|
|
|
const result = service.normalize(items);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].rawName).toBe('Prästost');
|
|
expect(result[0].brand).toBe('Arla Ko');
|
|
expect(result[0].categoryHint).toBe('Hårdost');
|
|
});
|
|
|
|
it('fixes known OCR typo for spröd', () => {
|
|
const items = [
|
|
{
|
|
rawName: 'Pröd Bakad Firre',
|
|
brand: 'Findus',
|
|
},
|
|
];
|
|
|
|
const result = service.normalize(items);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].rawName).toBe('Spröd Bakad Firre');
|
|
expect(result[0].normalizedName).toBe('spröd bakad firre');
|
|
});
|
|
|
|
it('does not apply spröd typo fix outside known fish context', () => {
|
|
const items = [
|
|
{
|
|
rawName: 'Pröd tvättmedel',
|
|
brand: 'Test',
|
|
},
|
|
];
|
|
|
|
const result = service.normalize(items);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].rawName).toBe('Pröd tvättmedel');
|
|
});
|
|
|
|
it('fixes herggårdsost only in cheese context', () => {
|
|
const items = [
|
|
{
|
|
rawName: 'Herggårdsost 31%',
|
|
brand: 'Arla Ko',
|
|
},
|
|
];
|
|
|
|
const result = service.normalize(items);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].rawName).toContain('Herrgårdsost');
|
|
});
|
|
|
|
it('fixes greveost typo in cheese context and preserves é', () => {
|
|
const items = [
|
|
{
|
|
rawName: 'Greveost skivad',
|
|
brand: 'Arla Ko',
|
|
},
|
|
];
|
|
|
|
const result = service.normalize(items);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].rawName).toContain('Grevéost');
|
|
expect(result[0].normalizedName).toContain('grevéost');
|
|
});
|
|
});
|
|
});
|