Seed-packet scan UI: camera/upload → proposal → confirm (#102)
Build image / build-and-push (push) Successful in 6s
Build image / build-and-push (push) Successful in 6s
Adds the mobile-first UI for the seed-packet capture backend (live since #94): a capability-gated "Scan a packet" entry in the Plants catalog opens a camera/upload → editable proposal → confirm flow that creates a plant (new or matched) + a seed lot. Frontend only. Closes #102 — the last open child of epic #96. Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #119.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
lotDefaults,
|
||||
newPlantDefaults,
|
||||
packetProposalSchema,
|
||||
seedPacketSchema,
|
||||
PACKET_PLANT_COLOR,
|
||||
PACKET_PLANT_ICON,
|
||||
} from './seedPacket'
|
||||
|
||||
// A full proposal as the backend sends it, for the schema + prefill tests.
|
||||
const proposal = {
|
||||
packet: {
|
||||
species: 'garlic',
|
||||
variety: 'Music',
|
||||
category: 'vegetable',
|
||||
vendor: "Johnny's",
|
||||
sku: 'G-123',
|
||||
lotCode: 'L9',
|
||||
packedForYear: 2026,
|
||||
daysToMaturity: 90,
|
||||
spacingCm: 15,
|
||||
seedCount: 12,
|
||||
},
|
||||
candidates: [
|
||||
{
|
||||
plant: {
|
||||
id: 7,
|
||||
name: 'Music',
|
||||
category: 'vegetable',
|
||||
spacingCm: 15,
|
||||
color: '#fff',
|
||||
icon: '🧄',
|
||||
notes: '',
|
||||
version: 1,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
reason: 'exact name',
|
||||
},
|
||||
],
|
||||
suggestedName: 'Music',
|
||||
suggestedCategory: 'vegetable',
|
||||
}
|
||||
|
||||
describe('seedPacketSchema', () => {
|
||||
it('fills defaults for a sparse packet (only what was printed)', () => {
|
||||
// A packet where the model only read a species — everything else absent.
|
||||
const p = seedPacketSchema.parse({ species: 'basil' })
|
||||
expect(p.species).toBe('basil')
|
||||
expect(p.variety).toBe('')
|
||||
expect(p.spacingCm).toBeNull()
|
||||
expect(p.seedCount).toBeNull()
|
||||
expect(p.packedForYear).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('packetProposalSchema', () => {
|
||||
it('parses a full proposal including candidates', () => {
|
||||
const p = packetProposalSchema.parse(proposal)
|
||||
expect(p.candidates).toHaveLength(1)
|
||||
expect(p.candidates[0].plant.id).toBe(7)
|
||||
expect(p.candidates[0].reason).toBe('exact name')
|
||||
expect(p.suggestedName).toBe('Music')
|
||||
})
|
||||
|
||||
it('defaults candidates to empty when absent', () => {
|
||||
const p = packetProposalSchema.parse({ packet: { species: 'kale' } })
|
||||
expect(p.candidates).toEqual([])
|
||||
expect(p.suggestedName).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('newPlantDefaults', () => {
|
||||
it('prefills name/category/spacing/days/vendor from the proposal', () => {
|
||||
const np = newPlantDefaults(packetProposalSchema.parse(proposal))
|
||||
expect(np.name).toBe('Music')
|
||||
expect(np.category).toBe('vegetable')
|
||||
expect(np.spacingCm).toBe(15)
|
||||
expect(np.daysToMaturity).toBe(90)
|
||||
expect(np.vendor).toBe("Johnny's")
|
||||
// No icon/color on a packet — placeholders the user can change later.
|
||||
expect(np.icon).toBe(PACKET_PLANT_ICON)
|
||||
expect(np.color).toBe(PACKET_PLANT_COLOR)
|
||||
})
|
||||
|
||||
it('falls back to a safe category and default spacing when the packet lacks them', () => {
|
||||
const np = newPlantDefaults(
|
||||
packetProposalSchema.parse({
|
||||
packet: { species: 'mystery' },
|
||||
suggestedName: 'mystery',
|
||||
suggestedCategory: 'not-a-real-category',
|
||||
}),
|
||||
)
|
||||
// An unknown category must not leak through — CreatePlant would reject it.
|
||||
expect(np.category).toBe('vegetable')
|
||||
// No printed spacing → the same default the manual form uses.
|
||||
expect(np.spacingCm).toBe(30)
|
||||
expect(np.daysToMaturity).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('lotDefaults', () => {
|
||||
it('reads a seed count as "<n> seeds"', () => {
|
||||
const lot = lotDefaults(seedPacketSchema.parse(proposal.packet))
|
||||
expect(lot.quantity).toBe(12)
|
||||
expect(lot.unit).toBe('seeds')
|
||||
expect(lot.vendor).toBe("Johnny's")
|
||||
expect(lot.sku).toBe('G-123')
|
||||
expect(lot.lotCode).toBe('L9')
|
||||
expect(lot.packedForYear).toBe(2026)
|
||||
})
|
||||
|
||||
it('defaults to one packet when no seed count is printed', () => {
|
||||
const lot = lotDefaults(seedPacketSchema.parse({ species: 'basil' }))
|
||||
expect(lot.quantity).toBe(1)
|
||||
expect(lot.unit).toBe('packets')
|
||||
expect(lot.packedForYear).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user