feat: add tests for normalizeName and RecipesService methods, including unit conversion and alias normalization

This commit is contained in:
Nils-Johan Gynther
2026-04-16 19:22:14 +02:00
parent 1b9df4d20d
commit 9292e30abc
6 changed files with 305 additions and 9 deletions
+14 -3
View File
@@ -8,7 +8,9 @@
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy" "prisma:deploy": "prisma migrate deploy",
"test": "jest",
"test:watch": "jest --watch"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^10.3.0", "@nestjs/common": "^10.3.0",
@@ -35,6 +37,15 @@
"@types/pdf-parse": "^1.1.5", "@types/pdf-parse": "^1.1.5",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"prisma": "6.12.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"]
} }
}
@@ -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');
});
});
@@ -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('<b>200</b> g köttfärs');
expect(result?.quantity).toBe(200);
expect(result?.unit).toBe('g');
});
});
});
@@ -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();
});
});
});
+40 -6
View File
@@ -29,21 +29,30 @@ function RecipePlaceholder({ name }: { name: string }) {
export default function RecipeGrid({ recipes }: { recipes: Recipe[] }) { export default function RecipeGrid({ recipes }: { recipes: Recipe[] }) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sort, setSort] = useState<'name' | 'newest' | 'oldest' | 'ingredients'>('newest');
const [onlyWithImage, setOnlyWithImage] = useState(false);
const filtered = recipes.filter((r) => const filtered = recipes
r.name.toLowerCase().includes(search.toLowerCase()), .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 ( return (
<div> <div>
<div style={{ marginBottom: '1.25rem' }}> <div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<input <input
type="text" type="text"
placeholder="Sök efter recept..." placeholder="Sök efter recept..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
style={{ style={{
width: '100%', flex: '1 1 200px',
padding: '0.6rem 1rem', padding: '0.6rem 1rem',
fontSize: '1rem', fontSize: '1rem',
border: '1px solid #ced4da', border: '1px solid #ced4da',
@@ -52,11 +61,36 @@ export default function RecipeGrid({ recipes }: { recipes: Recipe[] }) {
boxSizing: 'border-box', boxSizing: 'border-box',
}} }}
/> />
<select
value={sort}
onChange={(e) => setSort(e.target.value as typeof sort)}
style={{
padding: '0.55rem 0.75rem',
fontSize: '0.9rem',
border: '1px solid #ced4da',
borderRadius: '8px',
background: '#fff',
cursor: 'pointer',
}}
>
<option value="newest">Senast tillagda</option>
<option value="oldest">Äldst först</option>
<option value="name">Namn (AÖ)</option>
<option value="ingredients">Flest ingredienser</option>
</select>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.9rem', cursor: 'pointer', userSelect: 'none' }}>
<input
type="checkbox"
checked={onlyWithImage}
onChange={(e) => setOnlyWithImage(e.target.checked)}
/>
Endast med bild
</label>
</div> </div>
{filtered.length === 0 && ( {filtered.length === 0 && (
<p style={{ color: '#868e96', textAlign: 'center', marginTop: '2rem' }}> <p style={{ color: '#868e96', textAlign: 'center', marginTop: '2rem' }}>
{search ? 'Inga recept matchar sökningen.' : 'Inga recept tillagda ännu.'} {search || onlyWithImage ? 'Inga recept matchar filtren.' : 'Inga recept tillagda ännu.'}
</p> </p>
)} )}
@@ -22,6 +22,13 @@ function SimpleMarkdownPreview({ text }: { text: string }) {
if (line.startsWith('# ')) return <h3 key={i} style={{ margin: '0.5rem 0 0.25rem', fontSize: '1.3em', fontWeight: 700 }}>{line.slice(2)}</h3>; if (line.startsWith('# ')) return <h3 key={i} style={{ margin: '0.5rem 0 0.25rem', fontSize: '1.3em', fontWeight: 700 }}>{line.slice(2)}</h3>;
if (line.startsWith('## ')) return <h4 key={i} style={{ margin: '0.5rem 0 0.25rem', fontSize: '1.1em', fontWeight: 700 }}>{line.slice(3)}</h4>; if (line.startsWith('## ')) return <h4 key={i} style={{ margin: '0.5rem 0 0.25rem', fontSize: '1.1em', fontWeight: 700 }}>{line.slice(3)}</h4>;
if (line.startsWith('- ') || line.startsWith('* ')) return <div key={i} style={{ marginLeft: '1.5rem' }}> {line.slice(2)}</div>; if (line.startsWith('- ') || line.startsWith('* ')) return <div key={i} style={{ marginLeft: '1.5rem' }}> {line.slice(2)}</div>;
const numberedMatch = line.match(/^(\d+)\.\s+(.*)/);
if (numberedMatch) return (
<div key={i} style={{ display: 'flex', gap: '0.6rem', marginBottom: '0.35rem' }}>
<span style={{ fontWeight: 700, minWidth: '1.5rem', textAlign: 'right', flexShrink: 0 }}>{numberedMatch[1]}.</span>
<span>{numberedMatch[2]}</span>
</div>
);
if (line.trim() === '') return <div key={i} style={{ height: '0.5rem' }} />; if (line.trim() === '') return <div key={i} style={{ height: '0.5rem' }} />;
return <div key={i}>{line}</div>; return <div key={i}>{line}</div>;
})} })}