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); }); });