import { describe, it, expect } from 'vitest'; const markdownIt = require('markdown-it'); const measurementPlugin = require('../plugin'); function render(md) { const mdit = markdownIt().use(measurementPlugin); return mdit.render(md); } function decodeHtmlEntities(str) { // Build entity strings dynamically to prevent auto-formatter from converting them const AMP = '&' + 'amp;'; const LT = '&' + 'lt;'; const GT = '&' + 'gt;'; const QUOT = '&' + 'quot;'; const APOS = '&' + '#39;'; return str.split(AMP).join('&').split(LT).join('<').split(GT).join('>').split(APOS).join("'").split(QUOT).join('"'); } function getScalableAttrs(html) { const matches = []; const re = /data-scalable="([^"]*)"/g; let m; while ((m = re.exec(html)) !== null) { matches.push(JSON.parse(decodeHtmlEntities(m[1]))); } return matches; } function hasScalable(html) { return /data-scalable/.test(html); } // ─── Tools Section: Bare Counts Should NOT Scale ────────── describe('tools section scaling', () => { it('does not make bare counts scalable in Tools section (cooking tools)', () => { const md = [ '## Tools', '- pots 2', ].join('\n'); const html = render(md); // The "2" should be wrapped as a measurement but NOT have data-scalable expect(html).toContain('class="measurement"'); expect(hasScalable(html)).toBe(false); }); it('does not make "mixing bowls 2" scalable in Tools section', () => { const md = [ '## Tools', '- mixing bowls 2', ].join('\n'); const html = render(md); expect(html).toContain('class="measurement"'); expect(hasScalable(html)).toBe(false); }); it('does not make "sauce pan 2" scalable in Tools section', () => { const md = [ '## Tools', '- sauce pan 2', ].join('\n'); const html = render(md); expect(html).toContain('class="measurement"'); expect(hasScalable(html)).toBe(false); }); // ─── Tools Section: Serving Vessel Keywords SHOULD Scale ── it('makes "mug 1" scalable (serving vessel keyword)', () => { const md = [ '## Tools', '- mug 1', ].join('\n'); const html = render(md); const scalables = getScalableAttrs(html); const countScalable = scalables.find(s => s.type === 'count'); expect(countScalable).toBeDefined(); expect(countScalable.base).toBe(1); }); it('makes "highball 1" scalable (serving vessel keyword)', () => { const md = [ '## Tools', '- highball 1', ].join('\n'); const html = render(md); const scalables = getScalableAttrs(html); const countScalable = scalables.find(s => s.type === 'count'); expect(countScalable).toBeDefined(); expect(countScalable.base).toBe(1); }); it('makes "lowball 1" scalable (serving vessel keyword)', () => { const md = [ '## Tools', '- lowball 1', ].join('\n'); const html = render(md); const scalables = getScalableAttrs(html); const countScalable = scalables.find(s => s.type === 'count'); expect(countScalable).toBeDefined(); expect(countScalable.base).toBe(1); }); it('makes "mule glass 1" scalable (serving vessel keyword)', () => { const md = [ '## Tools', '- mule glass 1', ].join('\n'); const html = render(md); const scalables = getScalableAttrs(html); const countScalable = scalables.find(s => s.type === 'count'); expect(countScalable).toBeDefined(); expect(countScalable.base).toBe(1); }); // ─── Tools Section: Counts WITH Dimensions SHOULD Scale ── it('makes counts scalable when alongside dimensions (serving vessels)', () => { const md = [ '## Tools', '- 9 x 13 inch glass baking dish 1', ].join('\n'); const html = render(md); // Should have scalable data for the count "1" (serving vessel) const scalables = getScalableAttrs(html); const countScalable = scalables.find(s => s.type === 'count'); expect(countScalable).toBeDefined(); expect(countScalable.base).toBe(1); }); it('makes counts scalable for 3D dimensioned vessels', () => { const md = [ '## Tools', '- 8x6x2 inch pyrex dishes 4', ].join('\n'); const html = render(md); const scalables = getScalableAttrs(html); const countScalable = scalables.find(s => s.type === 'count'); expect(countScalable).toBeDefined(); expect(countScalable.base).toBe(4); }); it('makes counts scalable for tools with weight measurements', () => { const md = [ '## Tools', '- 64 oz mason jar 4', ].join('\n'); const html = render(md); const scalables = getScalableAttrs(html); const countScalable = scalables.find(s => s.type === 'count'); expect(countScalable).toBeDefined(); expect(countScalable.base).toBe(4); }); // ─── Counts OUTSIDE Tools Section Should Still Scale ────── it('makes bare counts scalable outside Tools section (ingredients)', () => { const md = [ '## Ingredients', '- eggs 3', ].join('\n'); const html = render(md); const scalables = getScalableAttrs(html); const countScalable = scalables.find(s => s.type === 'count'); expect(countScalable).toBeDefined(); expect(countScalable.base).toBe(3); }); it('makes bare counts scalable in Steps section', () => { const md = [ '## Steps', '- use 3 tortillas', ].join('\n'); const html = render(md); const scalables = getScalableAttrs(html); const countScalable = scalables.find(s => s.type === 'count'); expect(countScalable).toBeDefined(); expect(countScalable.base).toBe(3); }); // ─── Section Boundary Detection ─────────────────────────── it('exits tools section when next heading at same level appears', () => { const md = [ '## Tools', '- pots 2', '## Steps', '- use 3 eggs', ].join('\n'); const html = render(md); // "pots 2" count should NOT be scalable (tools section) // "3" in steps should be scalable const scalables = getScalableAttrs(html); const countScalable = scalables.find(s => s.type === 'count'); expect(countScalable).toBeDefined(); expect(countScalable.base).toBe(3); }); it('handles Tools as h3 heading', () => { const md = [ '### Tools', '- pots 2', ].join('\n'); const html = render(md); expect(html).toContain('class="measurement"'); expect(hasScalable(html)).toBe(false); }); // ─── Full Recipe Simulation ─────────────────────────────── it('handles a full recipe with ingredients, tools, and steps', () => { const md = [ '## Ingredients', '- onion 1', '- ground beef 2lbs', '- cheddar shredded 2 cups', '## Tools', '- pan', '- mixing bowl', '- 9 x 13 inch glass baking dish 1', '## Steps', '- preheat oven to 350 °F', '- bake for 30 minutes', ].join('\n'); const html = render(md); const scalables = getScalableAttrs(html); // Ingredients should have scalable counts and weights/volumes const weightScalables = scalables.filter(s => s.type === 'weight'); const volumeScalables = scalables.filter(s => s.type === 'volume'); expect(weightScalables.length).toBeGreaterThan(0); expect(volumeScalables.length).toBeGreaterThan(0); // The "1" count after "baking dish" in tools should be scalable // (it's alongside a dimension measurement) const countScalables = scalables.filter(s => s.type === 'count'); const dishCount = countScalables.find(s => s.base === 1); expect(dishCount).toBeDefined(); // The ingredient "onion 1" count should also be scalable // Both should exist in countScalables expect(countScalables.length).toBeGreaterThanOrEqual(1); }); }); // ─── Hybrid Weight/Volume Measurements ─────────────────── function getHybridAttrs(html) { const matches = []; const re = /data-hybrid="true"[^>]*data-volume-default="([^"]*)"[^>]*data-volume-alt="([^"]*)"(?:[^>]*data-volume-scalable="([^"]*)")?/g; let m; while ((m = re.exec(html)) !== null) { matches.push({ volumeDefault: decodeHtmlEntities(m[1]), volumeAlt: decodeHtmlEntities(m[2]), volumeScalable: m[3] ? JSON.parse(decodeHtmlEntities(m[3])) : null, }); } return matches; } describe('hybrid weight/volume measurements', () => { it('adds hybrid data attributes for weight with volume alt', () => { const md = '- unsalted butter 80g (6 tablespoons)'; const html = render(md); expect(html).toContain('data-hybrid="true"'); expect(html).toContain('data-volume-default='); expect(html).toContain('data-volume-alt='); expect(html).toContain('data-volume-scalable='); }); it('does NOT add manual hybrid attributes for weight-to-weight alt', () => { // weight-to-weight alt doesn't produce manual hybrid volume data, // but density-based auto hybrid will still kick in for known ingredients const md = '- cheddar 227g (8 oz)'; const html = render(md); // "cheddar" is not in the density table, so no hybrid at all expect(html).not.toContain('data-hybrid'); }); it('generates correct volume texts for simple hybrid', () => { const md = '- unsalted butter 80g (6 tablespoons)'; const html = render(md); const hybrids = getHybridAttrs(html); expect(hybrids.length).toBe(1); // 6 tbsp = 6 * 14.787 = 88.722ml -> metric: "88.7ml" expect(hybrids[0].volumeDefault).toBe('88.7ml'); expect(hybrids[0].volumeAlt).toBe('6 tbsp'); }); it('handles nested hybrid with intermediate', () => { const md = '- milk 860g (800mL (3 1/3 cups))'; const html = render(md); const hybrids = getHybridAttrs(html); expect(hybrids.length).toBe(1); expect(hybrids[0].volumeDefault).toBe('800ml'); }); it('includes volume scalable data with base in ml', () => { const md = '- unsalted butter 80g (6 tablespoons)'; const html = render(md); const hybrids = getHybridAttrs(html); expect(hybrids.length).toBe(1); expect(hybrids[0].volumeScalable).toBeDefined(); expect(hybrids[0].volumeScalable.type).toBe('volume'); // 6 tablespoons = 6 * 14.787 = 88.722 ml expect(hybrids[0].volumeScalable.base).toBeCloseTo(88.722, 0); }); it('includes volume scalable data for nested hybrid', () => { const md = '- milk 860g (800mL (3 1/3 cups))'; const html = render(md); const hybrids = getHybridAttrs(html); expect(hybrids.length).toBe(1); expect(hybrids[0].volumeScalable.type).toBe('volume'); expect(hybrids[0].volumeScalable.base).toBeCloseTo(800, 0); }); it('still has weight data attributes alongside hybrid attrs', () => { const md = '- unsalted butter 80g (6 tablespoons)'; const html = render(md); // Should still have the weight default/alt expect(html).toContain('data-default="80g"'); expect(html).toContain('data-measurement-type="weight"'); }); }); // ─── Density Table ────────────────────────────────────────── const { matchDensity } = require('../densities'); describe('matchDensity', () => { it('returns density for known ingredient', () => { expect(matchDensity('butter')).toBe(0.96); }); it('is case-insensitive', () => { expect(matchDensity('Butter')).toBe(0.96); expect(matchDensity('FLOUR')).toBe(0.53); }); it('returns null for unknown ingredient', () => { expect(matchDensity('dragon fruit')).toBeNull(); }); it('matches longest keyword first (brown sugar vs sugar)', () => { expect(matchDensity('brown sugar')).toBe(0.93); expect(matchDensity('sugar')).toBe(0.85); }); it('matches longest keyword first (olive oil vs oil)', () => { expect(matchDensity('olive oil')).toBe(0.92); expect(matchDensity('vegetable oil')).toBe(0.92); }); it('matches ingredient within longer text', () => { expect(matchDensity('unsalted butter')).toBe(0.96); expect(matchDensity('all-purpose flour')).toBe(0.53); }); it('returns null for empty/null input', () => { expect(matchDensity('')).toBeNull(); expect(matchDensity(null)).toBeNull(); }); }); // ─── Density-Based Auto Hybrid Generation ─────────────────── function getWeightAttrs(html) { const matches = []; const re = /data-hybrid="true"[^>]*data-weight-default="([^"]*)"[^>]*data-weight-alt="([^"]*)"(?:[^>]*data-weight-scalable="([^"]*)")?/g; let m; while ((m = re.exec(html)) !== null) { matches.push({ weightDefault: decodeHtmlEntities(m[1]), weightAlt: decodeHtmlEntities(m[2]), weightScalable: m[3] ? JSON.parse(decodeHtmlEntities(m[3])) : null, }); } return matches; } describe('density-based auto hybrid: weight -> volume', () => { it('auto-generates volume data for weight measurement with known ingredient', () => { const md = '- butter 227g'; const html = render(md); expect(html).toContain('data-hybrid="true"'); expect(html).toContain('data-volume-default='); expect(html).toContain('data-volume-alt='); expect(html).toContain('data-volume-scalable='); }); it('generates correct volume from density (butter 227g)', () => { const md = '- butter 227g'; const html = render(md); const hybrids = getHybridAttrs(html); expect(hybrids.length).toBe(1); // 227g / 0.96 density = 236.46ml -> "236.5ml" metric expect(hybrids[0].volumeDefault).toBe('236.5ml'); expect(hybrids[0].volumeScalable).toBeDefined(); expect(hybrids[0].volumeScalable.type).toBe('volume'); expect(hybrids[0].volumeScalable.base).toBeCloseTo(236.46, 0); }); it('generates correct volume from density (flour 500g)', () => { const md = '- all-purpose flour 500g'; const html = render(md); const hybrids = getHybridAttrs(html); expect(hybrids.length).toBe(1); // 500g / 0.53 density = 943.4ml -> "943.4ml" expect(hybrids[0].volumeDefault).toBe('943.4ml'); }); it('does not generate hybrid for unknown ingredient weight', () => { const md = '- mystery powder 100g'; const html = render(md); expect(html).not.toContain('data-hybrid'); }); it('handles range weight measurements with density', () => { const md = '- butter 200-250g'; const html = render(md); const hybrids = getHybridAttrs(html); expect(hybrids.length).toBe(1); // 200/0.96 = 208.3ml, 250/0.96 = 260.4ml expect(hybrids[0].volumeDefault).toMatch(/208.*-.*260/); expect(hybrids[0].volumeScalable.base).toHaveLength(2); }); }); describe('density-based auto hybrid: volume -> weight', () => { it('auto-generates weight data for volume measurement with known ingredient', () => { const md = '- flour 2 cups'; const html = render(md); expect(html).toContain('data-hybrid="true"'); expect(html).toContain('data-weight-default='); expect(html).toContain('data-weight-alt='); expect(html).toContain('data-weight-scalable='); }); it('generates correct weight from density (flour 2 cups)', () => { const md = '- flour 2 cups'; const html = render(md); const weightAttrs = getWeightAttrs(html); expect(weightAttrs.length).toBe(1); // 2 cups = 2 * 236.588ml = 473.176ml, * 0.53 density = 250.8g expect(weightAttrs[0].weightDefault).toBe('250.8g'); expect(weightAttrs[0].weightScalable).toBeDefined(); expect(weightAttrs[0].weightScalable.type).toBe('weight'); expect(weightAttrs[0].weightScalable.base).toBeCloseTo(250.78, 0); }); it('generates correct weight from density (milk 1 cup)', () => { const md = '- milk 1 cup'; const html = render(md); const weightAttrs = getWeightAttrs(html); expect(weightAttrs.length).toBe(1); // 1 cup = 236.588ml, * 1.03 density = 243.69g expect(weightAttrs[0].weightDefault).toBe('243.7g'); }); it('does not generate hybrid for unknown ingredient volume', () => { const md = '- unicorn tears 1 cup'; const html = render(md); expect(html).not.toContain('data-hybrid'); }); it('handles range volume measurements with density', () => { const md = '- sugar 1-2 cups'; const html = render(md); const weightAttrs = getWeightAttrs(html); expect(weightAttrs.length).toBe(1); // 1 cup = 236.588ml * 0.85 = 201.1g, 2 cups = 473.176ml * 0.85 = 402.2g expect(weightAttrs[0].weightDefault).toMatch(/201.*-.*402/); expect(weightAttrs[0].weightScalable.base).toHaveLength(2); }); }); describe('density vs manual hybrid precedence', () => { it('manual hybrid annotation takes precedence over density table', () => { const md = '- unsalted butter 80g (6 tablespoons)'; const html = render(md); const hybrids = getHybridAttrs(html); expect(hybrids.length).toBe(1); // Should use the manual 6 tablespoons, not density-computed value expect(hybrids[0].volumeDefault).toBe('88.7ml'); expect(hybrids[0].volumeAlt).toBe('6 tbsp'); }); it('weight-to-weight alt does not trigger density hybrid (butter 227g (8 oz))', () => { // "227g (8 oz)" is weight-to-weight alt, no manual hybrid volume data, // but density should still auto-generate volume data const md = '- butter 227g (8 oz)'; const html = render(md); // getHybridVolumeData returns null for weight-to-weight, so density kicks in expect(html).toContain('data-hybrid="true"'); const hybrids = getHybridAttrs(html); expect(hybrids.length).toBe(1); }); }); describe('density with approximate values', () => { it('preserves approximate prefix in density-generated volume', () => { const md = '- butter ~200g'; const html = render(md); const hybrids = getHybridAttrs(html); expect(hybrids.length).toBe(1); expect(hybrids[0].volumeDefault).toMatch(/^~/); }); it('preserves approximate prefix in density-generated weight', () => { const md = '- flour ~2 cups'; const html = render(md); const weightAttrs = getWeightAttrs(html); expect(weightAttrs.length).toBe(1); expect(weightAttrs[0].weightDefault).toMatch(/^~/); }); });