326 lines
No EOL
11 KiB
JavaScript
326 lines
No EOL
11 KiB
JavaScript
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"');
|
|
});
|
|
}); |