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 hybrid attributes for weight-to-weight alt', () => { const md = '- butter 227g (8 oz)'; const html = render(md); 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"'); }); });