- Garden and plant dialogs keep centimeters as the source of truth (LengthField in lib/units.ts): a no-change Save no longer rewrites 900 cm as 899.922 or a 45 cm spacing as 44.958, bumping versions and writing bogus history entries on the way. - The UI stamps every date with the browser's local day (lib/dates.ts). Journal notes already did; plop placement, fill and removal now do too, so a 9 pm placement isn't "planted tomorrow". The fill endpoint gained an optional plantedAt; API and agent callers still default to UTC today. - Removing an object that holds plants asks first and says how many go with it. An empty one still goes straight away (one Undo restores it). - The expanded plant card's action row wraps instead of clipping "Delete". - Monogram lettering switches to a dark ink on pale marker colors (garlic, cabbage, marigold) instead of near-white on near-white. - Copy-as-plan proposes the next free year and warns when the typed name already exists, so two gardens can't both read as "the 2027 plan". - Plan cards show the base name with a "2027 plan" tag, so the year — the point of the name — survives truncation. - A rejected model spec now says which model and why: a wrapped ErrInvalidInput's reason reaches the client as the 400's message, and the Settings field shows it inline instead of toasting "invalid input". Also defuses a clock bomb in TestRemainingReturnsWhenAPlantingIsRemoved, which only passed while the real date was before 2026-08-01. Co-Authored-By: Claude Fable 5 <[email protected]>
277 lines
10 KiB
TypeScript
277 lines
10 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
import {
|
||
cmFromFtIn,
|
||
convertDimensionField,
|
||
dimensionField,
|
||
editDimensionField,
|
||
editSpacingField,
|
||
spacingField,
|
||
cmFromMeters,
|
||
cmFromSpacing,
|
||
dimensionInputMode,
|
||
displayFromCm,
|
||
formatCm,
|
||
formatDimensionInput,
|
||
formatDimensions,
|
||
formatFtIn,
|
||
formatLength,
|
||
formatSize,
|
||
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')
|
||
})
|
||
})
|
||
|
||
describe('formatFtIn', () => {
|
||
it('drops zero parts the way a gardener says it', () => {
|
||
expect(formatFtIn(91)).toBe('3′')
|
||
expect(formatFtIn(183)).toBe('6′')
|
||
expect(formatFtIn(40)).toBe('1′4″')
|
||
expect(formatFtIn(20)).toBe('8″')
|
||
expect(formatFtIn(200)).toBe('6′7″')
|
||
expect(formatFtIn(300)).toBe('9′10″')
|
||
})
|
||
|
||
it('rounds to the nearest inch and carries 12″ up', () => {
|
||
expect(formatFtIn(30.48)).toBe('1′')
|
||
expect(formatFtIn(29.5)).toBe('1′') // 11.6″ → 12″ → 1′
|
||
expect(formatFtIn(0)).toBe('0″')
|
||
})
|
||
|
||
it('signs only a value that rounds to something', () => {
|
||
expect(formatFtIn(-91)).toBe('-3′')
|
||
expect(formatFtIn(-0.1)).toBe('0″')
|
||
})
|
||
})
|
||
|
||
describe('formatSize / formatLength', () => {
|
||
it('reads feet compactly and meters as before', () => {
|
||
expect(formatSize(91, 183, 'imperial')).toBe('3′ × 6′')
|
||
expect(formatSize(91, 183, 'metric')).toBe('0.91 m × 1.83 m')
|
||
expect(formatLength(1219, 'imperial')).toBe('40′')
|
||
})
|
||
})
|
||
|
||
describe('LengthField', () => {
|
||
it('keeps the stored centimeters through a unit switch and back', () => {
|
||
let f = dimensionField(900, 'imperial')
|
||
expect(f.text).toBe('29′ 6.3″')
|
||
f = convertDimensionField(f, 'metric')
|
||
expect(f.text).toBe('9')
|
||
f = convertDimensionField(f, 'imperial')
|
||
expect(f).toEqual({ text: '29′ 6.3″', cm: 900 })
|
||
})
|
||
|
||
it('moves the centimeters only when the text is edited', () => {
|
||
expect(editDimensionField("15' 6\"", 'imperial').cm).toBe(472.44)
|
||
const typo = editDimensionField('nope', 'imperial')
|
||
expect(typo.cm).toBeNull()
|
||
// A typo survives a unit switch as typed rather than turning into a number.
|
||
expect(convertDimensionField(typo, 'metric')).toEqual(typo)
|
||
})
|
||
|
||
it('spacing: 45 cm reads 17.7 in and stays 45 cm until typed over', () => {
|
||
const f = spacingField(45, 'imperial')
|
||
expect(f).toEqual({ text: '17.7', cm: 45 })
|
||
expect(editSpacingField('18', 'imperial').cm).toBe(45.72)
|
||
expect(editSpacingField('', 'imperial').cm).toBeNull()
|
||
expect(editSpacingField('25', 'metric').cm).toBe(25)
|
||
})
|
||
})
|