Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/member-pins-8071-near-miss-four.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
---

Test-only change (objectui#8071, first declared slice). Registers per-block member
pins for `object-form.fields` and `object-grid`'s `exportOptions`, `bulkActions`
and `bulkActionDefs`, deletes their four `MEMBER_PIN_EXEMPTIONS` entries and
lowers `MEMBER_PIN_EXEMPTION_CEILING` 62 -> 58. Every touched file is a
`__tests__` file; no published runtime source, no `package.json` publish-contract
field, no behaviour change.
67 changes: 60 additions & 7 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1969,6 +1969,31 @@ const MULTI_KIND_MEMBER_CONTRACTS: Record<string, string> = {
// demonstration the paragraph above claims: FOUR new array keys at once could
// not be absorbed by room, because there is none to absorb them with.
//
// ⚠️ AND THE TRANSITION HAS STARTED SPENDING ITSELF DOWN. objectui#8071's
// first slice converted FOUR keys from exemption to pin — `object-form.fields`,
// `object-grid.exportOptions`, `object-grid.bulkActions` and
// `object-grid.bulkActionDefs` — the four the card singled out because a
// near-miss contract test already existed for each. Measured over this file's
// own ledger, before and after: 90 array/object-armed inputs, 28 pinned, 62
// exempt -> 90, 32 pinned, 58 exempt. The population does not move (a pin and
// an exemption are the two halves of one partition), and
// `MEMBER_PIN_EXEMPTION_CEILING` moves WITH the list, 62 -> 58 — see its own
// docblock for why a ceiling that does not follow the count down is a budget
// rather than a ratchet.
//
// ⛔ What those near-miss tests were NOT. Two of the three the card named cover
// a NEIGHBOURING key rather than the one they were credited with.
// `sectionFields.spec-parity.test.ts` pins `sections[].fields`, whose member is
// the spec `FormFieldSchema` object keyed on `field`, while the top-level
// `object-form.fields` reads BARE NAMES and drops that very object in silence;
// `bulk-action-spec-parity.test.ts` pins the `BulkActionDef` TYPE against the
// spec and never touches either key's member read. So three of these four pins
// are new behavioural files rather than one added assertion, and the fourth
// (`exportOptions`) grew a third direction on an existing scanner. The locator
// would have accepted the first file on its STRINGS alone — it already contains
// both `object-form` and `fields` — which is the concrete reason `MEMBER_PINS`
// is reviewed rather than computed.
//
// 58 is the transition case, and this file already owns the pattern for it —
// `OFF_SPEC_EXEMPTIONS` / `UNPUBLISHED_EXEMPTIONS` / `OFF_SPEC_ARM_EXEMPTIONS`
// are explicit, reasoned, issue-backed, and go RED once stale. This is the same
Expand Down Expand Up @@ -2101,6 +2126,22 @@ const MEMBER_PINS: Record<string, MemberPin> = {
file: 'packages/plugin-calendar/src/__tests__/ObjectCalendar.recordSourceMembers-8314.test.tsx',
pins: 'The members are read EXACTLY as `data`\'s are (same record keys, same per-member unscheduled treatment), plus the two POSITION claims its description makes and no other direction of this gate can see: it is rung 2 of the shared record-source ladder, so an authored `data` wins and this key contributes nothing, and it is read ABOVE `objectName`, so a calendar carrying both draws the inline rows and never queries the object. Both negatives are proven through the same wait a CONTROL row shows a real query completing in, so "no query" can never read as a race. The spec row is `z.array(z.unknown())` — unconstrained members, read site is the whole contract (objectui#8314).',
},
'object-form.fields': {
file: 'packages/plugin-form/src/__tests__/objectFormFieldsMembers-8071.test.tsx',
pins: 'Members are BARE FIELD NAMES resolved against the object schema — authored order preserved (against a control with no `fields`, whose order differs), a name the object does not declare dropped rather than rendered as an untyped stub, and the `{ name }` object spelling recorded as the read site\'s tolerance rather than a second contract. The sharp row is the one no other file can make: `object-form` carries a SECOND surface spelled `fields` (`sections[].fields`), whose canonical member is the spec `FormFieldSchema` object keyed on `field` — and that exact entry as a member of the TOP-LEVEL key resolves to no name and is dropped in SILENCE (no throw, no warning), with the same entry inside a section rendering as the live control so the negative cannot come from an object that never renders that field. Asserted through the real `ObjectForm`, because the sink is its own `fieldsToShow` loop rather than the `normalizeSectionField` chokepoint the sibling key uses. The spec row is `z.array(z.unknown())` and the registration declares no `of`, so the read site is the whole member contract (objectui#8071).',
},
'object-grid.bulkActionDefs': {
file: 'packages/plugin-grid/src/__tests__/bulkActionMembers-8071.test.tsx',
pins: 'Members are FULL `BulkActionDef` OBJECTS, left as authored and never resolved against `objectDef.actions` — proven with a def naming an action the object does NOT declare, which still renders carrying its authored label. The negative is the pair\'s sharper half and is NOT silent: a bare-name member (the sibling key\'s vocabulary, which nothing on either declared side refuses) reaches `BulkActionBar` with no `name` and `formatActionLabel(undefined)` THROWS during render, taking the whole selection bar down — pinned as current behaviour, filed as objectui#8730, and it reds when that lands. The spec row is `z.array(z.unknown())`, so the read site is the whole member contract (objectui#8071).',
},
'object-grid.bulkActions': {
file: 'packages/plugin-grid/src/__tests__/bulkActionMembers-8071.test.tsx',
pins: 'Members are BARE ACTION NAMES resolved against `objectDef.actions` and PROMOTED — read off a button carrying the object action\'s own label, which is deliberately not the humanized form of the name, so a renderer treating the member as a display string could not pass. Two companions make it a reading rather than a claim: a name matching no declared action still reaches the bar BY NAME (the `registerHandler` path), and an object member — the `page:header.actions` hole (objectstack#11592) transposed onto this key — is skipped by `resolveBulkActions`\' `typeof name !== \'string\'` guard with no diagnostic at all. `selection` is declared explicitly on every row, because the grid derives multi-select from these very keys and a negative row would otherwise lose its selection UI for the reason under test. The spec row is `z.array(z.unknown())` (objectui#8071).',
},
'object-grid.exportOptions': {
file: 'packages/plugin-grid/src/__tests__/ObjectGrid.exportOptionsKeys.test.ts',
pins: 'The member KEY SET the renderer reads off `schema.exportOptions` (and off the alias bound to it), scanned out of `ObjectGrid.tsx` with comments and string literals stripped, against the `object-grid` REGISTRATION\'s own member enumeration — which is this block\'s only statement of member shape, since the registration declares `type: \'object\'` with no `of` and the spec row is `z.unknown()`. Two directions: the registration may advertise NO member key the renderer ignores (the declaration-side form of the objectstack#11592 hole), and the reverse gap is asserted as an EXACT named set — `streaming`, read at two sites to choose server-streamed vs client-assembled export and absent from the enumeration, filed as objectui#8731 — so a second undocumented key cannot join it and landing the fix reds the row. Carries objectui#4535\'s read-subset-of-declared-type direction as before (objectui#8071).',
},
'object-grid.data': {
file: 'packages/plugin-grid/src/__tests__/gridDataInputContract.test.ts',
pins: 'The `object` arm is `ViewDataSchema` discriminated on `provider`: each of the four providers parses, none of them is an array, the declaration is one shape across both registered tags so the alias cannot drift, and it is pinned at compile time too (objectui#5090).',
Expand Down Expand Up @@ -2244,7 +2285,6 @@ const MEMBER_PIN_EXEMPTIONS: Record<string, string> = {
// object-form
'object-form.customFields': AWAITING_A_PIN,
'object-form.dataSource': AWAITING_A_PIN,
'object-form.fields': AWAITING_A_PIN,
'object-form.initialData': AWAITING_A_PIN,
'object-form.initialValues': AWAITING_A_PIN,
'object-form.mobile': AWAITING_A_PIN,
Expand All @@ -2254,12 +2294,9 @@ const MEMBER_PIN_EXEMPTIONS: Record<string, string> = {
// object-grid
'object-grid.aggregations': AWAITING_A_PIN,
'object-grid.batchActions': AWAITING_A_PIN,
'object-grid.bulkActionDefs': AWAITING_A_PIN,
'object-grid.bulkActions': AWAITING_A_PIN,
'object-grid.columns': AWAITING_A_PIN,
'object-grid.conditionalFormatting': AWAITING_A_PIN,
'object-grid.dataSource': AWAITING_A_PIN,
'object-grid.exportOptions': AWAITING_A_PIN,
'object-grid.filter': AWAITING_A_PIN,
'object-grid.grouping': AWAITING_A_PIN,
'object-grid.navigation': AWAITING_A_PIN,
Expand Down Expand Up @@ -2380,8 +2417,24 @@ const NEWLY_JUDGED_UNPINNED_MEMBERS = [
* if `states the size of the population it judges` and
* `no spec-carried block is registered but unloaded` ever stop holding, this
* paragraph stops being true and the number owes a re-derivation, not a bump.
*
* ## 62 -> 58, and why the number MOVES WITH the list rather than trailing it
*
* objectui#8071's first slice converted four keys to pins — `object-form.fields`
* and `object-grid`'s `exportOptions` / `bulkActions` / `bulkActionDefs` — and
* deleted their four entries. A ceiling left at 62 would then have held FOUR
* unused slots, and unused slots are exactly the thing this constant exists to
* deny: the next genuinely new array-typed key could have been greened with an
* exemption entry and no pin, which is the "voluntary" state the ratchet was
* built to end. So the ceiling follows the list down in the same change that
* shortens it. That is what "may only ever go DOWN" has to mean to be worth
* anything — a number nobody lowers is a budget, not a ratchet.
*
* ⇒ The rule for every future slice of objectui#8071: delete the entry, register
* the pin, and set this constant to the new count. Not to the new count plus
* room.
*/
const MEMBER_PIN_EXEMPTION_CEILING = 62;
const MEMBER_PIN_EXEMPTION_CEILING = 58;

/**
* Every test file a member pin can live in, as LAZY `?raw` loaders.
Expand All @@ -2398,7 +2451,7 @@ const MEMBER_PIN_EXEMPTION_CEILING = 62;
* LAZY on purpose. `eager: true` would inline the raw text of every test file in
* the repo — 2,000+ files, ~3 MB — into this module on every run of a gate that
* already loads the whole registration graph. Lazy hands back loaders, so the
* cost is the glob itself plus one read per REGISTERED pin (21 today).
* cost is the glob itself plus one read per REGISTERED pin (32 today).
*/
const PIN_SOURCES = import.meta.glob(
[
Expand Down Expand Up @@ -3734,7 +3787,7 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide)

it('the member-pin exemption list only ratchets DOWN', () => {
// The other half. A new array-typed key must be answered with a pin, not
// with a 63rd entry carrying the same reason as its neighbours — that move
// with a 59th entry carrying the same reason as its neighbours — that move
// is what made the discipline voluntary in the first place, one layer in.
expect(Object.keys(MEMBER_PIN_EXEMPTIONS).length).toBeLessThanOrEqual(
MEMBER_PIN_EXEMPTION_CEILING,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* `object-form.fields` — the MEMBER shape this renderer reads (objectui#8071,
* criterion from objectui#8068).
*
* The registration declares `{ name: 'fields', type: 'array' }` with no `of`
* and no description, and the spec's `object-form.fields` row is
* `z.array(z.unknown())` carrying the prose "Limit/order the fields shown". So
* NOTHING on either declared side says what a member is: every coarse member
* kind parses, and the read site is the whole member contract. That is exactly
* the population objectui#8068 refuses to leave unwatched, and this file is
* this key's answer.
*
* WHAT THE RENDERER READS, measured at `ObjectForm.tsx`'s `SimpleObjectForm`
* (the `const fieldsToShow = schema.fields || Object.keys(objectSchema.fields)`
* loop) and at `flatFields.ts`'s `buildFlatFields`, which the drawer and modal
* presentations share:
*
* a member is a BARE FIELD NAME, looked up in the object schema.
*
* The pin is that sentence made falsifiable, plus the ONE distinction that
* makes this key worth a pin of its own rather than a note: `object-form` has a
* SECOND authoring surface spelled `fields` — `sections[].fields` — and the two
* do NOT share a member vocabulary. A section field may be the spec's
* `FormFieldSchema` object, whose identity key is `field` (see
* `sectionFields.ts`, and `sectionFields.spec-parity.test.ts` for its key set).
* That same object as a member of the TOP-LEVEL `fields` resolves to no name
* and is dropped in silence — no throw, no console warning, no empty-state.
*
* ⛔ NOT a restatement of the declaration: the declaration says `array` and
* stops. Every row below is a render whose outcome would change if the read
* site changed — the ablation objectui#8071 ran on this file removed the
* `typeof fieldName === 'string'` name resolution and reddened rows 1, 3 and 5.
*
* The `{ name }` spelling in row 5 is recorded as the renderer's tolerance, not
* as a second contract (AGENTS.md #0.1): it is what `buildFlatFields` documents
* ("field names or objects carrying one") and what the loop's
* `(fieldName as any).name` fallback does. Pinned so that a future edit removing
* it is a decision rather than an accident, and so the contrast with the
* REJECTED `{ field }` spelling above it cannot quietly invert.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { registerAllFields } from '@object-ui/fields';
import { ObjectForm } from '../ObjectForm';

registerAllFields();

const OBJECT_SCHEMA = {
name: 'invoice',
fields: {
status: { type: 'text', label: 'Status' },
sent_at: { type: 'text', label: 'Sent at' },
note: { type: 'text', label: 'Note' },
},
};

const makeDataSource = () =>
({
getObjectSchema: vi.fn().mockResolvedValue(OBJECT_SCHEMA),
getRecord: vi.fn(),
create: vi.fn(),
update: vi.fn(),
}) as any;

/** Render `object-form` and hand back the field labels in DOM order. */
async function renderedFieldLabels(schema: Record<string, unknown>): Promise<string[]> {
const { container } = render(
<ObjectForm
schema={{ type: 'object-form', objectName: 'invoice', mode: 'create', ...schema } as any}
dataSource={makeDataSource()}
/>,
);
// The form resolves the object schema in an effect; wait for the first field
// to land rather than for a fixed set, so a row asserting an EMPTY result
// still waits for the same settle point as the others.
await waitFor(() => expect(container.querySelector('form')).toBeTruthy());
await waitFor(() =>
expect(container.querySelector('[data-loading], .animate-pulse')).toBeFalsy(),
);
return Array.from(container.querySelectorAll('label')).map((l) =>
(l.textContent ?? '').replace(/\s*\*\s*$/, '').trim(),
);
}

describe('object-form `fields` members are BARE FIELD NAMES (objectui#8071)', () => {
it('1. resolves each member against the object schema, in AUTHORED order', async () => {
expect(await renderedFieldLabels({ fields: ['note', 'status'] })).toEqual(['Note', 'Status']);
});

it('2. control: with no `fields` every declared field renders, in schema order', async () => {
// Without this the row above could pass on a renderer that ignored the key
// entirely and happened to agree — it does not: the orders differ.
expect(await renderedFieldLabels({})).toEqual(['Status', 'Sent at', 'Note']);
});

it('3. drops a member the object schema does not declare — no untyped stub', async () => {
expect(await renderedFieldLabels({ fields: ['status', 'not_a_field'] })).toEqual(['Status']);
});

it('4. the spec SECTION-field object is NOT a member of this key, and is dropped silently', async () => {
// `{ field }` is the spec `FormFieldSchema` identity key — legal in
// `sections[].fields`, meaningless here. The renderer reads `.name` off a
// non-string member, finds nothing, and skips it without a word.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
expect(await renderedFieldLabels({ fields: [{ field: 'note' }] })).toEqual([]);
expect(warn).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
} finally {
warn.mockRestore();
error.mockRestore();
}
});

it('5. the SAME entry inside `sections[].fields` DOES resolve — two vocabularies, one word', async () => {
// The live control for row 4. Without it "dropped" could mean "this object
// schema never renders `note`", which is the failure mode a one-sided
// negative cannot tell apart from the claim.
//
// The section SELECTS from the fields the top-level key already resolved,
// so `status` dropping out is the second half of the reading: the section's
// `{ field: 'note' }` really was resolved to the name `note`. A section
// whose members resolved to nothing renders no field at all (the loop
// `return`s on an empty `sectionFields`), which is the outcome row 4 sees.
// The section heading is a `section-divider` pseudo-field, not a `<label>`,
// so it is deliberately absent from this list.
expect(
await renderedFieldLabels({
fields: ['note', 'status'],
sections: [{ label: 'Detail', fields: [{ field: 'note' }] }],
}),
).toEqual(['Note']);
});

it('6. tolerates the `{ name }` spelling — recorded as drift, not a second contract', async () => {
expect(await renderedFieldLabels({ fields: [{ name: 'note' }] })).toEqual(['Note']);
});
});
Loading
Loading