import { describe, expect, it } from 'vitest' import { cmFromFtIn, cmFromMeters, cmFromSpacing, dimensionInputMode, displayFromCm, formatCm, formatDimensionInput, formatDimensions, formatSpacing, parseDimension, spacingFromCm, } from './units' describe('cm conversions', () => { // Every cm column is REAL, so entry is exact — no whole-cm quantization. A 1 ft // grid is 30.48 cm, not 30, or the error accumulates across the garden (#47). it('feet+inches → exact cm', () => { expect(cmFromFtIn(1)).toBe(30.48) expect(cmFromFtIn(4)).toBe(121.92) expect(cmFromFtIn(8)).toBe(243.84) expect(cmFromFtIn(4, 6)).toBe(137.16) }) it('meters → exact cm', () => { expect(cmFromMeters(10)).toBe(1000) expect(cmFromMeters(1.22)).toBe(122) expect(cmFromMeters(0.305)).toBe(30.5) }) it('rounds away float dust, not precision', () => { // 12 × 2.54 is 30.479999999999997 in IEEE-754; 1 ft must read as 30.48. expect(cmFromFtIn(0, 12)).toBe(30.48) expect(cmFromSpacing(12, 'imperial')).toBe(30.48) expect(cmFromSpacing(1, 'imperial')).toBe(2.54) }) it('round-trips typed → cm → displayed at display precision', () => { for (const ft of [0.5, 1, 2.5, 4, 8, 12.5, 24]) { expect(displayFromCm(parseDimension(String(ft), 'imperial')!, 'imperial')).toBe(ft) } for (const m of [0.15, 1, 1.22, 3.05, 10, 24.5]) { expect(displayFromCm(parseDimension(String(m), 'metric')!, 'metric')).toBe(m) } }) }) describe('displayFromCm', () => { it('metric keeps cm precision', () => { expect(displayFromCm(1000, 'metric')).toBe(10) expect(displayFromCm(122, 'metric')).toBe(1.22) }) it('imperial rounds to 0.1 ft so integers stay clean', () => { expect(displayFromCm(122, 'imperial')).toBe(4) // not 4.0026 expect(displayFromCm(244, 'imperial')).toBe(8) // not 8.01 }) }) describe('formatCm', () => { it('metric shows meters', () => { expect(formatCm(122, 'metric')).toBe('1.22 m') expect(formatCm(1000, 'metric')).toBe('10 m') }) it('imperial shows feet and inches, carrying 12″ up', () => { expect(formatCm(122, 'imperial')).toBe('4′ 0″') expect(formatCm(244, 'imperial')).toBe('8′ 0″') expect(formatCm(137, 'imperial')).toBe('4′ 6″') }) it('formats width × height', () => { expect(formatDimensions(122, 244, 'imperial')).toBe('4′ 0″ × 8′ 0″') }) }) describe('plant spacing (small scale)', () => { it('formats cm in metric, whole inches in imperial', () => { expect(formatSpacing(15, 'metric')).toBe('15 cm') expect(formatSpacing(15, 'imperial')).toBe('6″') // 15/2.54 = 5.9 → 6 expect(formatSpacing(30, 'imperial')).toBe('12″') }) it('parses an entered value back to exact cm', () => { expect(cmFromSpacing(15, 'metric')).toBe(15) expect(cmFromSpacing(6, 'imperial')).toBe(15.24) }) it('prefills an input from cm', () => { expect(spacingFromCm(15, 'metric')).toBe(15) expect(spacingFromCm(30, 'imperial')).toBe(11.8) // 30/2.54 = 11.81 → 11.8 expect(spacingFromCm(30.48, 'imperial')).toBe(12) // an exactly-12in grid reads back as 12 }) }) describe('parseDimension (imperial compound entry)', () => { const cm = (inches: number) => inches * 2.54 it('round-trips the whole-inch marks decimal feet could not represent', () => { // The table from #59: at 0.1 ft granularity only 0", 6" and 12" were exact. for (let inch = 0; inch <= 12; inch++) { const input = `2' ${inch}"` const parsed = parseDimension(input, 'imperial') expect(parsed).toBeCloseTo(cm(24 + inch), 9) expect(formatDimensionInput(parsed!, 'imperial')).toBe(`${inch === 12 ? 3 : 2}′ ${inch === 12 ? 0 : inch}″`) } }) it('accepts both quote styles and the spelled-out units', () => { const want = cm(31) for (const input of [`2' 7"`, '2′ 7″', '2ft 7in', '2 ft 7 in', "2' 7", '31"', '31 inches', '2FT 7IN']) { expect(parseDimension(input, 'imperial')).toBeCloseTo(want, 9) } }) it('still reads a bare number as feet', () => { expect(parseDimension('2.5', 'imperial')).toBeCloseTo(cm(30), 9) expect(parseDimension('24', 'imperial')).toBeCloseTo(cm(288), 9) }) it('reads feet alone', () => { expect(parseDimension("2'", 'imperial')).toBeCloseTo(cm(24), 9) expect(parseDimension('2ft', 'imperial')).toBeCloseTo(cm(24), 9) }) // The bug this issue would otherwise produce: attaching the sign to the feet // term alone gives -2ft + 6in = -18", not -30". it('applies the sign to the whole value, not just the feet', () => { expect(parseDimension(`-2' 6"`, 'imperial')).toBeCloseTo(cm(-30), 9) expect(parseDimension('−2′ 6″', 'imperial')).toBeCloseTo(cm(-30), 9) // unicode minus expect(parseDimension('-31"', 'imperial')).toBeCloseTo(cm(-31), 9) expect(parseDimension('-2', 'imperial')).toBeCloseTo(cm(-24), 9) }) it('rejects garbage rather than committing a zero', () => { for (const input of ['', ' ', 'abc', `'`, `"`, `2' 7" 3`, '2 3', 'ft', '--2', '2..5']) { expect(parseDimension(input, 'imperial')).toBeNull() } }) }) describe('parseDimension (metric)', () => { it('takes decimal meters, with or without the unit', () => { expect(parseDimension('1.22', 'metric')).toBe(122) expect(parseDimension('1.22m', 'metric')).toBe(122) expect(parseDimension('10 m', 'metric')).toBe(1000) expect(parseDimension('-3', 'metric')).toBe(-300) }) it('does not accept feet-and-inches syntax', () => { expect(parseDimension(`2' 7"`, 'metric')).toBeNull() expect(parseDimension('31"', 'metric')).toBeNull() }) it('rejects garbage', () => { expect(parseDimension('', 'metric')).toBeNull() expect(parseDimension('abc', 'metric')).toBeNull() }) }) describe('formatDimensionInput', () => { it('renders imperial as feet and inches, carrying 12″ up', () => { expect(formatDimensionInput(0, 'imperial')).toBe('0′ 0″') expect(formatDimensionInput(30.48, 'imperial')).toBe('1′ 0″') expect(formatDimensionInput(78.74, 'imperial')).toBe('2′ 7″') expect(formatDimensionInput(-76.2, 'imperial')).toBe('-2′ 6″') }) it('keeps one decimal inch, so a dragged position reads honestly', () => { // A bed dragged to an arbitrary cm must not render as a whole inch — that's // what would let a mere blur snap it. expect(formatDimensionInput(80.90185676392574, 'imperial')).toBe('2′ 7.9″') }) it('is the exact string parseDimension round-trips', () => { for (const value of [0, 30.48, 78.74, 80.9, -76.2, 731.52]) { const text = formatDimensionInput(value, 'imperial') const back = parseDimension(text, 'imperial') expect(formatDimensionInput(back!, 'imperial')).toBe(text) } }) it('leaves metric as decimal meters', () => { expect(formatDimensionInput(122, 'metric')).toBe('1.22') expect(formatDimensionInput(1000, 'metric')).toBe('10') }) }) describe('negative values that round to zero', () => { // A hair below zero is 0′ 0″. "-0′ 0″" would be both wrong and, since // parseDimension gives -0, not something that round-trips. it('does not sign a value that rounded to nothing', () => { expect(formatDimensionInput(-0.1, 'imperial')).toBe('0′ 0″') expect(formatDimensionInput(-0, 'imperial')).toBe('0′ 0″') expect(formatCm(-0.1, 'imperial')).toBe('0′ 0″') }) it('still signs a value that rounded to something', () => { expect(formatDimensionInput(-1, 'imperial')).toBe('-0′ 0.4″') expect(formatDimensionInput(-76.2, 'imperial')).toBe('-2′ 6″') expect(formatCm(-76.2, 'imperial')).toBe('-2′ 6″') }) }) describe('dimensionInputMode', () => { it("gives imperial the full keyboard, since ' and \" are on no keypad", () => { expect(dimensionInputMode('imperial')).toBe('text') expect(dimensionInputMode('metric')).toBe('decimal') }) })