|
| 1 | +/** |
| 2 | + * ObjectUI |
| 3 | + * Copyright (c) 2024-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * This source code is licensed under the MIT license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. |
| 7 | + */ |
| 8 | + |
| 9 | +/** |
| 10 | + * Spec-vocabulary boundary in the standalone form renderer (#3090). |
| 11 | + * |
| 12 | + * `{ field: 'x' }` is the spec form-VIEW vocabulary — an object-field |
| 13 | + * reference the standalone renderer cannot resolve (no object schema). Writing |
| 14 | + * the first test here against the unfixed renderer proved the pre-#3090 |
| 15 | + * behavior was even worse than the assumed silent drop: the entry slipped past |
| 16 | + * the `f?.name` guards into a react-hook-form Controller with |
| 17 | + * `name === undefined` and CRASHED the whole form on `name.split('.')`, |
| 18 | + * with nothing naming the culprit entry. The renderer now partitions such |
| 19 | + * entries out (the rest of the form renders) and surfaces them: an inline |
| 20 | + * alert naming the fields, and a console.error whose text doubles as the fix |
| 21 | + * instruction. |
| 22 | + */ |
| 23 | + |
| 24 | +import React from 'react'; |
| 25 | +import { describe, it, expect, vi, afterEach } from 'vitest'; |
| 26 | +import { render, screen, cleanup } from '@testing-library/react'; |
| 27 | +import { ComponentRegistry } from '@object-ui/core'; |
| 28 | +// Registers the renderers at module scope, NOT inside a `beforeAll` — there the |
| 29 | +// cold transform is billed to `hookTimeout`. See |
| 30 | +// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021). |
| 31 | +import '../../../renderers'; |
| 32 | + |
| 33 | +afterEach(() => { |
| 34 | + cleanup(); |
| 35 | + vi.restoreAllMocks(); |
| 36 | +}); |
| 37 | + |
| 38 | +function renderForm(fields: any[]) { |
| 39 | + const Form = ComponentRegistry.get('form')!; |
| 40 | + return render( |
| 41 | + <Form schema={{ type: 'form', showSubmit: false, showCancel: false, fields }} />, |
| 42 | + ); |
| 43 | +} |
| 44 | + |
| 45 | +describe('form renderer — spec-vocabulary entries surface loudly (#3090)', () => { |
| 46 | + it('renders an inline alert naming the unrenderable fields, and still renders the rest', () => { |
| 47 | + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); |
| 48 | + |
| 49 | + renderForm([ |
| 50 | + { name: 'subject', label: 'Subject', type: 'input' }, |
| 51 | + { field: 'owner', required: true }, // spec form-view shape — unrenderable here |
| 52 | + ]); |
| 53 | + |
| 54 | + // The valid field still renders… |
| 55 | + expect(document.body.querySelector('[data-field="subject"]')).toBeTruthy(); |
| 56 | + // …the spec-shaped one is called out instead of silently dropped. |
| 57 | + const alert = screen.getByTestId('form-spec-vocabulary-error'); |
| 58 | + expect(alert.textContent).toContain('owner'); |
| 59 | + expect(alert.textContent).toContain('{ name:'); |
| 60 | + |
| 61 | + // The console message is the fix instruction an agent will follow. |
| 62 | + const said = error.mock.calls.map((c) => String(c[0])).join('\n'); |
| 63 | + expect(said).toContain("{ field: 'owner' }"); |
| 64 | + expect(said).toContain('Rename the key to `name`'); |
| 65 | + }); |
| 66 | + |
| 67 | + it('renders no alert and logs nothing for a well-formed standalone form', () => { |
| 68 | + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); |
| 69 | + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 70 | + |
| 71 | + renderForm([{ name: 'subject', label: 'Subject', type: 'input' }]); |
| 72 | + |
| 73 | + expect(screen.queryByTestId('form-spec-vocabulary-error')).toBeNull(); |
| 74 | + const noise = [...error.mock.calls, ...warn.mock.calls] |
| 75 | + .map((c) => String(c[0])) |
| 76 | + .filter((m) => m.includes('[object-ui]')); |
| 77 | + expect(noise).toEqual([]); |
| 78 | + }); |
| 79 | + |
| 80 | + it('warns (but renders by `name`) when an entry mixes both vocabularies', () => { |
| 81 | + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 82 | + |
| 83 | + renderForm([{ name: 'subject', field: 'subject_line', label: 'Subject', type: 'input' }]); |
| 84 | + |
| 85 | + // `name` wins — the field renders, no destructive alert… |
| 86 | + expect(document.body.querySelector('[data-field="subject"]')).toBeTruthy(); |
| 87 | + expect(screen.queryByTestId('form-spec-vocabulary-error')).toBeNull(); |
| 88 | + // …but the ignored spec key is called out. |
| 89 | + const said = warn.mock.calls.map((c) => String(c[0])).join('\n'); |
| 90 | + expect(said).toContain('mixes vocabularies'); |
| 91 | + expect(said).toContain("{ field: 'subject_line' }"); |
| 92 | + }); |
| 93 | + |
| 94 | + it('does NOT flag the runtime metadata-object `field` slot (the #3090 pun)', () => { |
| 95 | + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); |
| 96 | + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
| 97 | + |
| 98 | + // Object-bound paths stash the resolved field-metadata OBJECT under |
| 99 | + // `field` — same key, different layer. Only a STRING `field` marks the |
| 100 | + // spec authored shape; the object slot must pass without noise. |
| 101 | + renderForm([ |
| 102 | + { name: 'amount', type: 'input', field: { type: 'currency', scale: 2 } }, |
| 103 | + ]); |
| 104 | + |
| 105 | + expect(document.body.querySelector('[data-field="amount"]')).toBeTruthy(); |
| 106 | + expect(screen.queryByTestId('form-spec-vocabulary-error')).toBeNull(); |
| 107 | + const noise = [...error.mock.calls, ...warn.mock.calls] |
| 108 | + .map((c) => String(c[0])) |
| 109 | + .filter((m) => m.includes('[object-ui]')); |
| 110 | + expect(noise).toEqual([]); |
| 111 | + }); |
| 112 | +}); |
0 commit comments