volpe/lib/measurements/__tests__/plugin.test.js

615 lines
No EOL
21 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 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(/^~/);
});
});
// ─── Count Noun Pluralization ───────────────────────────────
describe('count noun pluralization', () => {
it('generates noun span for known singular noun', () => {
const html = render('- onion 1');
expect(html).toContain('class="count-noun"');
expect(html).toContain('data-singular="onion"');
expect(html).toContain('data-plural="onions"');
});
it('generates noun span for known plural noun', () => {
const html = render('- eggs 3');
expect(html).toContain('class="count-noun"');
expect(html).toContain('data-singular="egg"');
expect(html).toContain('data-plural="eggs"');
});
it('displays singular form when count is 1', () => {
const html = render('- onion 1');
expect(html).toMatch(/<span class="count-noun"[^>]*>onion<\/span>/);
});
it('displays plural form when count is not 1', () => {
const html = render('- egg 3');
expect(html).toMatch(/<span class="count-noun"[^>]*>eggs<\/span>/);
});
it('displays plural form when count is 0', () => {
const html = render('- egg 0');
expect(html).toMatch(/<span class="count-noun"[^>]*>eggs<\/span>/);
});
it('uses max value for ranges (plural)', () => {
const html = render('- onion 1-2');
// max value is 2 -> plural
expect(html).toMatch(/<span class="count-noun"[^>]*>onions<\/span>/);
});
it('adds data-has-noun on the count measurement span', () => {
const html = render('- onion 1');
expect(html).toContain('data-has-noun="true"');
});
it('does not add data-has-noun for unknown nouns', () => {
const html = render('- toaster 1');
expect(html).not.toContain('data-has-noun');
expect(html).not.toContain('count-noun');
});
it('handles multi-word nouns like bell pepper', () => {
const html = render('- bell pepper 2');
expect(html).toContain('data-singular="bell pepper"');
expect(html).toContain('data-plural="bell peppers"');
expect(html).toMatch(/<span class="count-noun"[^>]*>bell peppers<\/span>/);
});
it('handles multi-word nouns like egg yolk', () => {
const html = render('- egg yolk 1');
expect(html).toContain('data-singular="egg yolk"');
expect(html).toMatch(/<span class="count-noun"[^>]*>egg yolk<\/span>/);
});
it('corrects plural to singular for count 1', () => {
const html = render('- eggs 1');
expect(html).toMatch(/<span class="count-noun"[^>]*>egg<\/span>/);
});
it('corrects singular to plural for count > 1', () => {
const html = render('- onion 4');
expect(html).toMatch(/<span class="count-noun"[^>]*>onions<\/span>/);
});
it('works in tools section', () => {
const md = [
'## Ingredients',
'- egg 2',
].join('\n');
const html = render(md);
expect(html).toContain('class="count-noun"');
expect(html).toMatch(/<span class="count-noun"[^>]*>eggs<\/span>/);
});
it('preserves text before the noun', () => {
const html = render('- white onion 1');
// "white " should appear before the noun span
expect(html).toMatch(/white <span class="count-noun"/);
});
it('does not generate noun span for non-count measurements', () => {
const html = render('- onion 200g');
expect(html).not.toContain('count-noun');
});
});