Skip to content

Commit 10725fb

Browse files
test(examples): the catalog mirror declares the select options the host declares (#6539)
The gallery fixture's `role` / `status` options landed on the host in 4b0b126 and the catalog-test mirror never followed, so two declarations of one fixture disagreed for 330 commits with every case in the file green throughout. Declare them on the mirror to match the host, and add the pins that would have caught the drift: every `select` field must declare options covering its own rows exactly, and the mirror's whole `users` field surface is compared to the host's. Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 11f23e9 commit 10725fb

1 file changed

Lines changed: 172 additions & 2 deletions

File tree

‎examples/schema-catalog/test/catalog-gallery-render.test.tsx‎

Lines changed: 172 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,13 @@ const AUTHORED_TEXT_EXEMPT: Record<string, string> = {};
304304
* mirror reproduces is the host's SURFACE and its rows; the query semantics
305305
* ($search / $orderby / windowing) are the host's, and the parity case pins the
306306
* method names rather than re-deriving them here.
307+
*
308+
* objectui#6317 widened that guard where it had to be widened. Pinning method
309+
* NAMES left the field declarations unwatched, and they drifted: the host
310+
* declared `options` on `role` / `status` in `4b0b12630` and this schema did
311+
* not follow for 330 commits, with every case in this file green throughout.
312+
* The `USERS_SCHEMA` below is now compared to the host's WHOLE, options
313+
* included — see the #6317 cases at the end of this file.
307314
*/
308315
const USERS_ROWS = [
309316
{ id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'admin', department: 'Engineering', status: 'active', created_at: '2024-01-14' },
@@ -319,9 +326,25 @@ const USERS_SCHEMA = {
319326
fields: {
320327
name: { label: 'Name', type: 'text' },
321328
email: { label: 'Email', type: 'email' },
322-
role: { label: 'Role', type: 'select' },
329+
role: {
330+
label: 'Role',
331+
type: 'select',
332+
options: [
333+
{ label: 'Admin', value: 'admin' },
334+
{ label: 'Member', value: 'member' },
335+
{ label: 'Viewer', value: 'viewer' },
336+
],
337+
},
323338
department: { label: 'Department', type: 'text' },
324-
status: { label: 'Status', type: 'select' },
339+
status: {
340+
label: 'Status',
341+
type: 'select',
342+
options: [
343+
{ label: 'Active', value: 'active' },
344+
{ label: 'Invited', value: 'invited' },
345+
{ label: 'Suspended', value: 'suspended' },
346+
],
347+
},
325348
created_at: { label: 'Created', type: 'date' },
326349
},
327350
};
@@ -1232,3 +1255,150 @@ describe('objectui#5113 — the docs-site hosts supply the same fixture', () =>
12321255
},
12331256
);
12341257
});
1258+
1259+
/**
1260+
* objectui#6317 — every `select` field in the fixture declares the options its
1261+
* own rows use, and this mirror declares exactly what the host declares.
1262+
*
1263+
* ## The defect
1264+
*
1265+
* `ObjectForm` copies a field's options through verbatim — `formField.options =
1266+
* field.options || []` (`packages/plugin-form/src/ObjectForm.tsx`) — so a
1267+
* `select` field with no `options` renders the "No options available" empty
1268+
* state. Measured through this file's own render path, an `object-form` over
1269+
* `users` with no `fields` restriction:
1270+
*
1271+
* before: "NameEmailRoleNo options availableDepartmentStatusNo options
1272+
* availableCancelUpdate"
1273+
* after: "NameEmailRoleAdminAdminMemberViewerDepartmentStatusActiveActive
1274+
* InvitedSuspendedCancelUpdate"
1275+
*
1276+
* and the record's own `role` / `status` join the form's control values
1277+
* (`["Alice Johnson","alice@example.com","Engineering"]` → `["Alice Johnson",
1278+
* "alice@example.com","admin","Engineering","active"]`), so the two fields stop
1279+
* being dropped on the way in.
1280+
*
1281+
* ## Why the grid and view tiles do NOT move with it
1282+
*
1283+
* Worth recording, because the expectation going in was that they would.
1284+
* `ObjectGrid` SYNTHESISES options for an option-less select from the distinct
1285+
* values in the loaded rows (`packages/plugin-grid/src/ObjectGrid.tsx` —
1286+
* `fieldMeta.options = uniqueValues.map(v => ({ value: v, label:
1287+
* humanizeLabel(String(v)) }))`), so `admin` already printed as "Admin".
1288+
* Measured: the tile text of all seven `users`-bound entries is byte-identical
1289+
* before and after this declaration. The grid has a fallback for the missing
1290+
* declaration; the FORM path has none. That asymmetry is the whole card.
1291+
*
1292+
* ## Two directions, because a one-sided pin cannot see this drift
1293+
*
1294+
* The host declared these options in `4b0b12630` and this mirror did not
1295+
* follow for 330 commits — with every case in this file green throughout. The
1296+
* `select`-coverage case below catches a fixture that declares neither; the
1297+
* parity case catches the two files declaring different things.
1298+
*/
1299+
1300+
interface UsersFieldDecl {
1301+
label?: string;
1302+
type?: string;
1303+
options?: Array<{ label: string; value: string }>;
1304+
}
1305+
1306+
/** Distinct values the mirror's rows carry for one field — walked, not grepped. */
1307+
function distinctRowValues(field: string): Set<string> {
1308+
const seen = new Set<string>();
1309+
for (const row of USERS_ROWS) {
1310+
const value = (row as Record<string, unknown>)[field];
1311+
if (value !== undefined && value !== null) seen.add(String(value));
1312+
}
1313+
return seen;
1314+
}
1315+
1316+
/**
1317+
* The host's `USERS_SCHEMA`, read off its source. `apps/**` is outside every
1318+
* root Vitest project, which is the same constraint that makes this file a
1319+
* mirror in the first place. Brace-matched rather than pattern-matched and then
1320+
* JSON-ified, so an extraction that stops working fails LOUDLY here rather than
1321+
* quietly comparing less than it claims to.
1322+
*/
1323+
function readHostUsersFields(source: string): Record<string, UsersFieldDecl> {
1324+
const start = source.indexOf('const USERS_SCHEMA = {');
1325+
expect(start, 'the host fixture no longer declares `const USERS_SCHEMA = {`').toBeGreaterThan(-1);
1326+
const open = source.indexOf('{', start);
1327+
let depth = 0;
1328+
let end = -1;
1329+
for (let i = open; i < source.length; i++) {
1330+
if (source[i] === '{') depth += 1;
1331+
else if (source[i] === '}') {
1332+
depth -= 1;
1333+
if (depth === 0) {
1334+
end = i;
1335+
break;
1336+
}
1337+
}
1338+
}
1339+
expect(end, 'the host `USERS_SCHEMA` literal has unbalanced braces').toBeGreaterThan(-1);
1340+
const json = source
1341+
.slice(open, end + 1)
1342+
.replace(/'/g, '"')
1343+
.replace(/([{,[]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/g, '$1"$2":')
1344+
.replace(/,(\s*[}\]])/g, '$1');
1345+
let parsed: { fields?: Record<string, UsersFieldDecl> };
1346+
try {
1347+
parsed = JSON.parse(json) as { fields?: Record<string, UsersFieldDecl> };
1348+
} catch (error) {
1349+
throw new Error(
1350+
'the host `USERS_SCHEMA` is no longer a plain single-quoted literal, so this ' +
1351+
'parity case can no longer read it — a comment inside the literal, or an ' +
1352+
'apostrophe inside a string, would each do it. Fix the reader, not the pin.',
1353+
{ cause: error },
1354+
);
1355+
}
1356+
expect(parsed.fields, 'the host `USERS_SCHEMA` declares no `fields`').toBeTruthy();
1357+
return parsed.fields as Record<string, UsersFieldDecl>;
1358+
}
1359+
1360+
describe('objectui#6317 — a `select` field declares the options its rows use', () => {
1361+
const mirrorFields = USERS_SCHEMA.fields as Record<string, UsersFieldDecl>;
1362+
const selectFields = Object.entries(mirrorFields)
1363+
.filter(([, field]) => field.type === 'select')
1364+
.map(([name]) => name);
1365+
1366+
it('the fixture declares select fields at all — otherwise the cases below are vacuous', () => {
1367+
expect(selectFields).not.toEqual([]);
1368+
});
1369+
1370+
it.each(selectFields)('`%s` declares options, and they cover its rows exactly', (name) => {
1371+
const declared = mirrorFields[name].options;
1372+
expect(
1373+
declared,
1374+
`\`${name}\` is declared \`type: 'select'\` with no \`options\`. ObjectForm copies ` +
1375+
'a field\'s options through verbatim, so a form bound to it renders the "No ' +
1376+
'options available" empty state — on a docs page whose whole purpose is to ' +
1377+
'show the component working.',
1378+
).toBeTruthy();
1379+
const optionValues = new Set((declared ?? []).map((option) => String(option.value)));
1380+
const rowValues = distinctRowValues(name);
1381+
expect(
1382+
[...rowValues].filter((value) => !optionValues.has(value)),
1383+
`rows carry these \`${name}\` values that no option declares — they would render as a blank cell`,
1384+
).toEqual([]);
1385+
expect(
1386+
[...optionValues].filter((value) => !rowValues.has(value)),
1387+
`these \`${name}\` options match no row, so nothing in the gallery demonstrates them`,
1388+
).toEqual([]);
1389+
});
1390+
1391+
it('the host fixture declares the SAME field surface, options included', () => {
1392+
const hostSource = fs.readFileSync(
1393+
path.join(process.cwd(), 'apps/site/app/components/galleryDataSource.ts'),
1394+
'utf8',
1395+
);
1396+
expect(
1397+
readHostUsersFields(hostSource),
1398+
'the host fixture and this mirror declare different `users` field surfaces. They ' +
1399+
'are ONE fixture in two files and have to move together: the host gained its ' +
1400+
'`role` / `status` options in 4b0b12630 and this mirror did not follow for 330 ' +
1401+
'commits, with every case in this file green the whole time.',
1402+
).toEqual(mirrorFields);
1403+
});
1404+
});

0 commit comments

Comments
 (0)