* api: detect an absent copy body by io.EOF from the decoder rather than
Content-Length. A chunked request reports -1, not 0, so the length test
read an empty chunked body as malformed and 400'd instead of taking the
default-name path. Covered by a new unknown-length test.
* store: hoist the garden_objects/plantings INSERTs into shared
objectInsert/plantingInsert statements with matching *InsertArgs
helpers. CopyGarden had its own copies of both column lists, so a
column added to the table could be wired into Create* and silently
dropped from a copy.
* store: move the queryer interface to sqlite.go, next to the other
shared query plumbing, instead of users.go.
* web: derive a copy's prefilled name via defaultCopyName, mirroring the
server's copyName including its 200-BYTE cap. The inline
`${name} (copy)` both duplicated the suffix and, for a garden whose
name was already at the cap, prefilled an over-long name that the
server rejected with a 400. Unit-tested, including multi-byte
truncation on a code-point boundary.
* test: drop a throwaway `_ = kept`.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -6,7 +6,7 @@ import { Modal } from '@/components/ui/Modal'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { useCopyGarden, type Garden } from '@/lib/gardens'
|
||||
import { defaultCopyName, useCopyGarden, type Garden } from '@/lib/gardens'
|
||||
|
||||
/**
|
||||
* Duplicate a garden under a new name. The name is prefilled with the server's
|
||||
@@ -17,7 +17,7 @@ import { useCopyGarden, type Garden } from '@/lib/gardens'
|
||||
export function CopyGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
||||
const copy = useCopyGarden()
|
||||
const navigate = useNavigate()
|
||||
const [name, setName] = useState(`${garden.name} (copy)`)
|
||||
const [name, setName] = useState(() => defaultCopyName(garden.name))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defaultCopyName } from './gardens'
|
||||
|
||||
// The server caps a garden name at 200 BYTES and derives a copy's name the same
|
||||
// way (copyName in internal/service/gardens.go). These cases pin the mirror: an
|
||||
// over-long prefill would come back as a 400 rather than a copy.
|
||||
describe('defaultCopyName', () => {
|
||||
it('appends the suffix to a short name', () => {
|
||||
expect(defaultCopyName('Home')).toBe('Home (copy)')
|
||||
})
|
||||
|
||||
it('keeps a derived name within the 200-byte cap', () => {
|
||||
const name = 'a'.repeat(200)
|
||||
const derived = defaultCopyName(name)
|
||||
expect(new TextEncoder().encode(derived).length).toBeLessThanOrEqual(200)
|
||||
expect(derived.endsWith(' (copy)')).toBe(true)
|
||||
})
|
||||
|
||||
it('truncates multi-byte names on a code-point boundary', () => {
|
||||
const name = 'é'.repeat(100) // 200 bytes, exactly at the cap
|
||||
const derived = defaultCopyName(name)
|
||||
expect(new TextEncoder().encode(derived).length).toBeLessThanOrEqual(200)
|
||||
// No replacement character: nothing was cut mid-code-point.
|
||||
expect(derived).not.toContain('�')
|
||||
})
|
||||
|
||||
it('does not leave a dangling space before the suffix', () => {
|
||||
const name = 'x'.repeat(192) + ' y' // truncation lands on the space
|
||||
expect(defaultCopyName(name)).toBe('x'.repeat(192) + ' (copy)')
|
||||
})
|
||||
})
|
||||
@@ -86,6 +86,36 @@ export function useUpdateGarden() {
|
||||
})
|
||||
}
|
||||
|
||||
// Mirrors the server's garden-name cap (maxGardenNameLen in
|
||||
// internal/service/gardens.go). It is a BYTE cap, not a character count.
|
||||
const MAX_GARDEN_NAME_BYTES = 200
|
||||
const COPY_SUFFIX = ' (copy)'
|
||||
|
||||
/** Trim s to at most maxBytes of UTF-8, never splitting a code point. */
|
||||
function truncateUtf8(s: string, maxBytes: number): string {
|
||||
const enc = new TextEncoder()
|
||||
if (enc.encode(s).length <= maxBytes) return s
|
||||
let out = ''
|
||||
let used = 0
|
||||
for (const ch of s) {
|
||||
const size = enc.encode(ch).length
|
||||
if (used + size > maxBytes) break
|
||||
out += ch
|
||||
used += size
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The name a copy gets by default. Mirrors the server's copyName (same file as
|
||||
* the cap above) so the prefilled value is one the server will accept — without
|
||||
* the byte-capped truncation, copying a garden whose name is already at the cap
|
||||
* would prefill an over-long name and fail with a 400.
|
||||
*/
|
||||
export function defaultCopyName(name: string): string {
|
||||
return truncateUtf8(name, MAX_GARDEN_NAME_BYTES - COPY_SUFFIX.length).trim() + COPY_SUFFIX
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a garden the actor owns, including its objects and current plantings.
|
||||
* An omitted/blank name lets the server derive "<source> (copy)". The copy does
|
||||
|
||||
Reference in New Issue
Block a user