diff --git a/backend/package.json b/backend/package.json index a7ff3917..dee7d9f9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,7 +8,9 @@ "start:dev": "nest start --watch", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", - "prisma:deploy": "prisma migrate deploy" + "prisma:deploy": "prisma migrate deploy", + "test": "jest", + "test:watch": "jest --watch" }, "dependencies": { "@nestjs/common": "^10.3.0", @@ -35,6 +37,15 @@ "@types/pdf-parse": "^1.1.5", "@types/uuid": "^10.0.0", "prisma": "6.12.0", - "typescript": "^5.4.5" + "typescript": "^5.4.5", + "jest": "^29.7.0", + "ts-jest": "^29.2.6", + "@types/jest": "^29.5.14" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "moduleFileExtensions": ["js", "json", "ts"] } -} diff --git a/backend/src/common/utils/normalize-name.spec.ts b/backend/src/common/utils/normalize-name.spec.ts new file mode 100644 index 00000000..f26292f1 --- /dev/null +++ b/backend/src/common/utils/normalize-name.spec.ts @@ -0,0 +1,42 @@ +import { normalizeName } from './normalize-name'; + +describe('normalizeName', () => { + it('gör om till gemener', () => { + expect(normalizeName('Kycklingfilé')).toBe('kycklingfile'); + }); + + it('tar bort svenska diakritiska tecken', () => { + expect(normalizeName('åäö')).toBe('aao'); + expect(normalizeName('ÅÄÖ')).toBe('aao'); + }); + + it('tar bort mellanslag', () => { + expect(normalizeName('rökt paprikapulver')).toBe('roktpaprikapulver'); + }); + + it('tar bort inledande och avslutande mellanslag', () => { + expect(normalizeName(' lax ')).toBe('lax'); + }); + + it('tar bort specialtecken', () => { + expect(normalizeName('Curry (mild)')).toBe('currymild'); + }); + + it('hanterar siffror', () => { + expect(normalizeName('Omega-3')).toBe('omega3'); + }); + + it('hanterar tom sträng', () => { + expect(normalizeName('')).toBe(''); + }); + + it('hanterar sträng med bara mellanslag', () => { + expect(normalizeName(' ')).toBe(''); + }); + + it('normaliserar accenter korrekt', () => { + expect(normalizeName('Fläskfilé')).toBe('flaskfile'); + expect(normalizeName('Gräddfil')).toBe('graddfil'); + expect(normalizeName('Rödlök')).toBe('rodlok'); + }); +}); diff --git a/backend/src/quick-import/parsers/base.parser.spec.ts b/backend/src/quick-import/parsers/base.parser.spec.ts new file mode 100644 index 00000000..2ca36de3 --- /dev/null +++ b/backend/src/quick-import/parsers/base.parser.spec.ts @@ -0,0 +1,107 @@ +import { RecipeParser, ParsedRecipe } from './base.parser'; + +// Konkret testklass för att komma åt protected-metoden +class TestParser extends RecipeParser { + canHandle(_url: string): boolean { return true; } + parse(_html: string): ParsedRecipe { + return { name: '', ingredients: [] }; + } + public testParseIngredientLine(line: string) { + return this.parseIngredientLine(line); + } +} + +describe('RecipeParser.parseIngredientLine', () => { + const parser = new TestParser(); + const parse = (line: string) => parser.testParseIngredientLine(line); + + describe('enkla mängd + enhet + namn', () => { + it('parsar "150 g lax"', () => { + const result = parse('150 g lax'); + expect(result?.quantity).toBe(150); + expect(result?.unit).toBe('g'); + expect(result?.name).toBe('lax'); + }); + + it('parsar "2 dl grädde"', () => { + const result = parse('2 dl grädde'); + expect(result?.quantity).toBe(2); + expect(result?.unit).toBe('dl'); + expect(result?.name).toBe('grädde'); + }); + + it('parsar "1 msk olivolja"', () => { + const result = parse('1 msk olivolja'); + expect(result?.quantity).toBe(1); + expect(result?.unit).toBe('msk'); + expect(result?.name).toBe('olivolja'); + }); + + it('parsar "3 st ägg"', () => { + const result = parse('3 st ägg'); + expect(result?.quantity).toBe(3); + expect(result?.unit).toBe('st'); + expect(result?.name).toBe('ägg'); + }); + + it('parsar "3 ägg" (utan enhet)', () => { + const result = parse('3 ägg'); + expect(result?.quantity).toBe(3); + expect(result?.unit).toBe(''); + expect(result?.name).toBe('ägg'); + }); + }); + + describe('bråktal', () => { + it('parsar "1/2 citron"', () => { + const result = parse('1/2 citron'); + expect(result?.quantity).toBeCloseTo(0.5); + expect(result?.name).toBe('citron'); + }); + + it('parsar "1 1/2 dl mjölk"', () => { + const result = parse('1 1/2 dl mjölk'); + expect(result?.quantity).toBeCloseTo(1.5); + expect(result?.unit).toBe('dl'); + }); + }); + + describe('utan mängd', () => { + it('parsar "salt och peppar" (ingen mängd)', () => { + const result = parse('salt och peppar'); + expect(result?.quantity).toBe(0); + expect(result?.unit).toBe(''); + expect(result?.name).toBe('salt och peppar'); + }); + + it('returnerar null för tom sträng', () => { + expect(parse('')).toBeNull(); + }); + }); + + describe('med parenteser', () => { + it('parsar "1 förp handskalade räkor (à 570 g)" med note', () => { + const result = parse('1 förp handskalade räkor (à 570 g)'); + expect(result?.quantity).toBe(1); + expect(result?.unit).toBe('förp'); + expect(result?.name).toBe('handskalade räkor'); + expect(result?.note).toBe('à 570 g'); + }); + }); + + describe('kommatalstal', () => { + it('parsar "2,5 dl buljong"', () => { + const result = parse('2,5 dl buljong'); + expect(result?.quantity).toBeCloseTo(2.5); + expect(result?.unit).toBe('dl'); + }); + }); + + describe('strips HTML-taggar', () => { + it('parsar rad med HTML', () => { + const result = parse('200 g köttfärs'); + expect(result?.quantity).toBe(200); + expect(result?.unit).toBe('g'); + }); + }); +}); diff --git a/backend/src/recipes/recipes.service.spec.ts b/backend/src/recipes/recipes.service.spec.ts new file mode 100644 index 00000000..26d12b41 --- /dev/null +++ b/backend/src/recipes/recipes.service.spec.ts @@ -0,0 +1,95 @@ +import { RecipesService } from './recipes.service'; + +// Vi instansierar utan Prisma eftersom vi bara testar privata hjälpmetoder +const service = new (RecipesService as any)(null); +const convert = (q: number, from: string, to: string) => + (service as any).convertUnit(q, from, to, 'testprodukt'); +const normalize = (u: string) => (service as any).normalizeUnit(u); + +describe('RecipesService.normalizeUnit', () => { + it('normaliserar aliaser', () => { + expect(normalize('tesked')).toBe('tsk'); + expect(normalize('matsked')).toBe('msk'); + expect(normalize('gram')).toBe('g'); + expect(normalize('kilogram')).toBe('kg'); + expect(normalize('deciliter')).toBe('dl'); + expect(normalize('milliliter')).toBe('ml'); + }); + + it('hanterar gemener och blanksteg', () => { + expect(normalize(' MSK ')).toBe('msk'); + expect(normalize('G')).toBe('g'); + }); + + it('returnerar okänd enhet oförändrad', () => { + expect(normalize('kopp')).toBe('kopp'); + }); +}); + +describe('RecipesService.convertUnit', () => { + describe('viktkonvertering', () => { + it('konverterar g → kg', () => { + expect(convert(500, 'g', 'kg')).toBeCloseTo(0.5); + }); + + it('konverterar kg → g', () => { + expect(convert(1.5, 'kg', 'g')).toBeCloseTo(1500); + }); + + it('returnerar samma värde för identiska enheter', () => { + expect(convert(200, 'g', 'g')).toBe(200); + }); + }); + + describe('volymkonvertering', () => { + it('konverterar dl → ml', () => { + expect(convert(2, 'dl', 'ml')).toBeCloseTo(200); + }); + + it('konverterar ml → dl', () => { + expect(convert(150, 'ml', 'dl')).toBeCloseTo(1.5); + }); + }); + + describe('portionskonvertering', () => { + it('konverterar msk → tsk (1 msk ≈ 3 tsk)', () => { + expect(convert(2, 'msk', 'tsk')).toBeCloseTo(6); + }); + + it('konverterar tsk → msk', () => { + expect(convert(3, 'tsk', 'msk')).toBeCloseTo(1); + }); + }); + + describe('normaliserar aliaser vid konvertering', () => { + it('konverterar "gram" → "kg"', () => { + expect(convert(1000, 'gram', 'kg')).toBeCloseTo(1); + }); + + it('konverterar "matsked" → "tsk"', () => { + expect(convert(1, 'matsked', 'tsk')).toBeCloseTo(3); + }); + }); + + describe('felhantering', () => { + it('kastar fel för inkompatibla enhetstyper', () => { + expect(() => convert(100, 'g', 'dl')).toThrow(); + }); + + it('kastar fel för noll-kvantitet', () => { + expect(() => convert(0, 'g', 'kg')).toThrow(); + }); + + it('kastar fel för negativ kvantitet', () => { + expect(() => convert(-1, 'g', 'kg')).toThrow(); + }); + + it('kastar fel för tom from-enhet', () => { + expect(() => convert(100, '', 'kg')).toThrow(); + }); + + it('kastar fel för okänd enhet', () => { + expect(() => convert(100, 'kopp', 'dl')).toThrow(); + }); + }); +}); diff --git a/frontend/app/recipes/RecipeGrid.tsx b/frontend/app/recipes/RecipeGrid.tsx index 53b54af9..1b2b7128 100644 --- a/frontend/app/recipes/RecipeGrid.tsx +++ b/frontend/app/recipes/RecipeGrid.tsx @@ -29,21 +29,30 @@ function RecipePlaceholder({ name }: { name: string }) { export default function RecipeGrid({ recipes }: { recipes: Recipe[] }) { const [search, setSearch] = useState(''); + const [sort, setSort] = useState<'name' | 'newest' | 'oldest' | 'ingredients'>('newest'); + const [onlyWithImage, setOnlyWithImage] = useState(false); - const filtered = recipes.filter((r) => - r.name.toLowerCase().includes(search.toLowerCase()), - ); + const filtered = recipes + .filter((r) => r.name.toLowerCase().includes(search.toLowerCase())) + .filter((r) => !onlyWithImage || !!r.imageUrl) + .sort((a, b) => { + if (sort === 'name') return a.name.localeCompare(b.name, 'sv'); + if (sort === 'newest') return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + if (sort === 'oldest') return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + if (sort === 'ingredients') return (b.ingredients?.length ?? 0) - (a.ingredients?.length ?? 0); + return 0; + }); return (
-
+
setSearch(e.target.value)} style={{ - width: '100%', + flex: '1 1 200px', padding: '0.6rem 1rem', fontSize: '1rem', border: '1px solid #ced4da', @@ -52,11 +61,36 @@ export default function RecipeGrid({ recipes }: { recipes: Recipe[] }) { boxSizing: 'border-box', }} /> + +
{filtered.length === 0 && (

- {search ? 'Inga recept matchar sökningen.' : 'Inga recept tillagda ännu.'} + {search || onlyWithImage ? 'Inga recept matchar filtren.' : 'Inga recept tillagda ännu.'}

)} diff --git a/frontend/app/recipes/[id]/RecipeDetailClient.tsx b/frontend/app/recipes/[id]/RecipeDetailClient.tsx index 42a68fc7..3d44f22e 100644 --- a/frontend/app/recipes/[id]/RecipeDetailClient.tsx +++ b/frontend/app/recipes/[id]/RecipeDetailClient.tsx @@ -22,6 +22,13 @@ function SimpleMarkdownPreview({ text }: { text: string }) { if (line.startsWith('# ')) return

{line.slice(2)}

; if (line.startsWith('## ')) return

{line.slice(3)}

; if (line.startsWith('- ') || line.startsWith('* ')) return
• {line.slice(2)}
; + const numberedMatch = line.match(/^(\d+)\.\s+(.*)/); + if (numberedMatch) return ( +
+ {numberedMatch[1]}. + {numberedMatch[2]} +
+ ); if (line.trim() === '') return
; return
{line}
; })}