From c2b917717c8c836e9887e7b337e14892ce809a78 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:58:00 -0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20test:=20Pin=20Add=20Entry=20Flow=20?= =?UTF-8?q?for=20Array=20Config=20Collections=20(#44,=20#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding an entry to an array-object collection prepends an empty entry at index 0, while per-entry edits flow through indexed paths like modelSpecs.list.0. Before the applyConfigEdit indexed-merge branch landed, typing into the new entry deleted the pending whole-array edit and left only the bare indexed edit, which then resolved against the baseline array. The new entity overwrote the first existing entry in the UI and in the save payload, and for a previously-unset array the save shipped an indexed fieldPath the backend could not apply, so the toast reported success while nothing persisted. The unit tests for applyConfigEdit cover the merge in isolation but nothing exercised the component flow end to end. These tests drive the real SingleFieldRenderer, ArrayObjectField, and ObjectEntryCard through ConfigPage-equivalent state handling (applyConfigEdit, mergeIndexedArrayEdits, buildSavePayload) for both reported scenarios: typing into a newly added modelSpecs entry must keep the existing entries, and a new entry in a previously-unset endpoints.azureOpenAI.groups array must reach the save payload as the full array path. Both fail when the indexed-merge branch is removed and pass on main. --- .../configuration/FieldRenderer.test.tsx | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/src/components/configuration/FieldRenderer.test.tsx b/src/components/configuration/FieldRenderer.test.tsx index 8a23a885..25ce1063 100644 --- a/src/components/configuration/FieldRenderer.test.tsx +++ b/src/components/configuration/FieldRenderer.test.tsx @@ -1,8 +1,11 @@ +import { useMemo, useState } from 'react'; import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import type * as t from '@/types'; import { SingleFieldRenderer, FieldRenderer, renderInlineField } from './FieldRenderer'; +import { applyConfigEdit, buildSavePayload, mergeIndexedArrayEdits } from './utils'; import { createField } from '@/test/fixtures'; +import { flattenObject } from '@/utils'; vi.mock('@/hooks/useLocalize', () => ({ default: () => (key: string) => key, @@ -716,3 +719,154 @@ describe('renderInlineField masked secrets (collection entries)', () => { expect(screen.getByText('com_config_secret_replace')).toBeInTheDocument(); }); }); + +/** + * Integration flow for array-object collections, wired the same way ConfigPage + * wires SingleFieldRenderer: edits accumulate through the real applyConfigEdit, + * indexed edits merge into the baseline through the real mergeIndexedArrayEdits, + * and getValue resolves pending edits by exact path. Regression coverage for + * issues #44 and #105, where typing into a newly-added (prepended) entry turned + * the pending whole-array edit into a bare indexed edit that resolved against + * the baseline array, overwriting the first existing entry and saving an + * indexed fieldPath instead of the full array. + */ +function ArrayFlowHarness({ + baselineConfig, + sectionKey, + fieldPath, + onState, +}: { + baselineConfig: Record; + sectionKey: string; + fieldPath: string; + onState: (state: { editedValues: t.FlatConfigMap; touchedPaths: Set }) => void; +}) { + const [editedValues, setEditedValues] = useState({}); + const [touchedPaths, setTouchedPaths] = useState>(() => new Set()); + onState({ editedValues, touchedPaths }); + + const flatBaseline = useMemo(() => flattenObject(baselineConfig), [baselineConfig]); + const handleFieldChange = (path: string, value: t.ConfigValue) => { + setTouchedPaths((prev) => new Set(prev).add(path)); + setEditedValues((prev) => + applyConfigEdit(prev, path, value, flatBaseline, new Set(), new Set()), + ); + }; + + const activeConfigValues = useMemo(() => { + const indexedEdits = Object.entries(editedValues).filter(([k]) => /\.\d+$/.test(k)); + if (indexedEdits.length === 0) return baselineConfig; + return mergeIndexedArrayEdits(baselineConfig, indexedEdits); + }, [baselineConfig, editedValues]); + + const getValueWithEdits = (path: string, fallback: t.ConfigValue): t.ConfigValue => + path in editedValues ? editedValues[path] : fallback; + + const leafKey = fieldPath.split('.').pop()!; + const field = createField({ + key: leafKey, + path: fieldPath, + type: 'array', + isArray: true, + children: [ + createField({ key: 'name', path: `${fieldPath}.name` }), + createField({ key: 'group', path: `${fieldPath}.group` }), + ], + }); + + const segments = fieldPath.split('.'); + let sectionValue: t.ConfigValue = activeConfigValues[sectionKey]; + for (const seg of segments.slice(1)) { + sectionValue = + sectionValue && typeof sectionValue === 'object' && !Array.isArray(sectionValue) + ? (sectionValue as Record)[seg] + : undefined; + } + + return ( + + ); +} + +const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve(null))); + +describe('array-object add-entry flow (issues #44, #105)', () => { + it('keeps existing entries when typing into a newly-added entry', () => { + const state = { editedValues: {} as t.FlatConfigMap, touchedPaths: new Set() }; + const baselineConfig = { + modelSpecs: { + list: [ + { name: 'agent-one', group: 'a' }, + { name: 'agent-two', group: 'b' }, + ], + }, + }; + render( + Object.assign(state, s)} + />, + ); + + fireEvent.click(screen.getByText('com_ui_add_item')); + expect(state.editedValues['modelSpecs.list']).toEqual([ + {}, + { name: 'agent-one', group: 'a' }, + { name: 'agent-two', group: 'b' }, + ]); + + const nameInput = document.getElementById('com_config_entry_n-name')!; + expect(nameInput).not.toBeNull(); + fireEvent.change(nameInput, { target: { value: 'agent-new' } }); + fireEvent.blur(nameInput); + + expect(state.editedValues['modelSpecs.list']).toEqual([ + { name: 'agent-new' }, + { name: 'agent-one', group: 'a' }, + { name: 'agent-two', group: 'b' }, + ]); + expect(state.editedValues).not.toHaveProperty('modelSpecs.list.0'); + expect(screen.getAllByText('agent-one').length).toBeGreaterThan(0); + expect(screen.getAllByText('agent-two').length).toBeGreaterThan(0); + expect(screen.getAllByText('agent-new').length).toBeGreaterThan(0); + }); + + it('saves a new entry in a previously-unset array as the full array path', async () => { + const state = { editedValues: {} as t.FlatConfigMap, touchedPaths: new Set() }; + const baselineConfig = { + endpoints: { azureOpenAI: { titleModel: 'gpt-4o-mini' } }, + }; + render( + Object.assign(state, s)} + />, + ); + + fireEvent.click(screen.getByText('com_ui_add_item')); + await nextFrame(); + await nextFrame(); + + const groupInput = document.getElementById('com_config_entry_n-group')!; + expect(groupInput).not.toBeNull(); + fireEvent.change(groupInput, { target: { value: 'gpt-5.1' } }); + fireEvent.blur(groupInput); + + const { saves } = buildSavePayload(state.touchedPaths, state.editedValues, new Set()); + expect(saves).toEqual([ + { fieldPath: 'endpoints.azureOpenAI.groups', value: [{ group: 'gpt-5.1' }] }, + ]); + expect(saves.some((s) => /\.\d+$/.test(s.fieldPath))).toBe(false); + }); +});