Skip to content

Commit 15b099e

Browse files
fix(app-shell): Studio CEL editors agree with the server verdict (builder null guard, stale RLS verdict) (#27677)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgYNRc1Hed3EUUXpeyeyrgCY)_ ## 维护者速读 - 改了什么:Studio 的无代码条件构建器对对象字段做「大于/小于/至少/至多」比较时,现在写出 `record.x != null && record.x > 40`(与「数字不能为负」预设同形),服务端发布时接受;RLS 子句字段只在文本仍是服务端评判过的那段时才显示服务端报错,改了文本就不再挂着上一版草稿的旧错误。 - 为什么改:之前构建器写 `record.estimated_hours > 40`,可空字段在 CEL 里没有 `>` 的 null 重载,构建器说「Valid CEL」,发布时却被拒并拖住整个包;RLS 字段在被客户端语法检查挡住保存时不会重读草稿,旧的服务端报错一直显示在新文本下面。 - 风险与回滚:只改 app-shell 两个源文件(`ConditionBuilder.tsx` 的比较编译路径、`CelPredicateField.tsx` 的显示条件);已存的条件打开时一个字节都不改写,未加守卫的旧比较保持原样直到作者编辑那一行。回滚即 revert 本 PR。 - 先看:`ConditionBuilder.tsx` 的 `compileRow` 守卫分支与 `update` 里的 `withGuard`;`parseGuardedCompare`;`CelPredicateField.tsx` 里 `serverErrors` 改读 `held`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.nullGuard-27513.test.tsx packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.serverVerdictHold-27482.test.tsx`,预期 13 条全绿。 - 需要你做的:无,席位落地。 Fixes #27513 Fixes #27452 Clause-②: no Metadata-protocol: none ## What changes - `ConditionBuilder.tsx` (the comparison compile path only): a row whose operator is an ordering comparison (`>` `<` `>=` `<=`) and whose subject names a catalog field compiles to `S != null && S op V`, a compound row, so it is parenthesised beside other rows. That is the shape the "Number can't be negative" preset already writes and the fix the server's own refusal prescribes ("Guard it with '!= null'"). `parseGuardedCompare` reads exactly that text back as the one row it compiles from, as a whole expression and as a parenthesised term. The guard is decided by the row, not by the field's nullability: the builder restates no publish rule, and `!= null` is well typed on a field that can never be null too. Equality rows, value-less rows and rows on a context subject (`record.id`, `user.*`) compile exactly as before. - A stored condition is never rewritten on open. A comparison saved without the guard parses as a row with no guard and re-emits its own bytes; the guard is applied to a row when the author builds or edits that row (`update` → `withGuard` on the rows the edit replaced), following the builder's existing rule for the author's own quote spelling. - `CelPredicateField.tsx`: the server's errors at `verdictPath` are now shown on the same terms on which they already held the save since #27612 (`useServerHoldAt`): only while the text on screen is the value the judged body holds at that path. "Valid CEL", `aria-invalid` and the destructive border follow the same set. `useServerErrorsAt` is no longer read here (the flow edge inspector still reads it). ## Why #27513: the release walkthrough built "Reject the save when… `record.estimated_hours` greater than `40`" on an optional Number field. The builder said "Valid CEL" and the rule saved, and publish refused it (`@objectstack/lint`'s #4763 null-guard gate: an ordering operator on a nullable declared field with no dominating `!= null`), holding every other draft in the package. Triage asked the builder to emit the preset's null-safe guard. #27452: an RLS clause showed the previous draft's server error under new text. The verdict comes from the last draft read; an edit the lint holds never saves, so nothing re-reads, and the field read the verdict by path alone. Triage asked the field to clear the server verdict on edit and show the new one on save. #27612 had already keyed the save hold on the judged text, not the display; this keys the display the same way, so one verdict decides both. ## Tests New `ConditionBuilder.nullGuard-27513.test.tsx` (7 cases) on the Validations tab's mount (`scope="record"`, the server host's subjects): the card's flow (Add condition → field → greater than → 40) emits the guarded text; that text is judged by the server's gate (`findUnguardedNullableOperands` from `@objectstack/lint`, test-only) as clean and evaluated by `@objectstack/formula` on a null row without a fault, against an instrument control in which the old emission is refused by the same gate and faults in the same engine; the guarded text reopens as one row; the OR join parenthesises and round-trips; a stored unguarded comparison keeps its bytes until that row is edited; equality needs no guard; a non-catalog subject is not guarded. Two new cases in `PermissionMatrixEditor.serverVerdictHold-27482.test.tsx` for #27452, through the real permission-set editor and a fake door: a stored refused USING shows the server message; editing it to a lint-held text removes the message while the hold and the client error stay, with no PUT and no draft re-read; typing the judged text back shows it again. `serverVerdictAtPath-26858.test.tsx` now provides the judged body its page provides (`DraftVerdictBodyContext`), as `PermissionMatrixEditor` does. <details><summary>Readings (head 4d13c9a)</summary> - Touched-file test set (`git grep` over the changed components, 115 files): 115 passed / 1043 tests, on `232f261a` and again on the merged head (lock VERDICT command-exit 0). - `pnpm --filter @object-ui/app-shell run type-check`: exit 0 on the merged head. - `dispatch-gates --commands`: 34 commands, each `:: exit 0` on the merged head (including `node scripts/check-sdui-manifest.mjs` and `pnpm --dir ui exec vitest run scripts/`: 169 files / 4901 tests passed); `dispatch-gates --ran`: 34 derived, 34 run, 0 NOT-MEASURED. - ui `check:new-line-citations`: exit 0, 0 new. - Ablations (restored, empty diff): #27452 display reverted to the path-only read turns 2 of the 6 cases in the hold file red; #27513 guard removed from `compileRow` turns 4 of the 7 null-guard cases red. </details> ## Acceptance notes - **#27482 is not in this PR** (seat ruling). Measured while wiring its Validations-tab arm: the server never addresses a validation rule's condition by path. `validate-expressions.ts` pushes validation-rule findings (`check()` and `checkNullGuards`, the #27513 refusal) with `where` only; `authoring-rules.ts` sets `path: i.path ?? i.where`; `withPublishVerdict` keeps a non-positional path as written. So the draft read's `_diagnostics` entry for a rule carries the prose locator (`object 'X' · validation rule 'NAME'`), never `validations.N.condition`; `protocol.runtime-authoring-gate.test.ts`'s #22032 block pins `issue.path` equal to that `where`. Only a top-level flow edge's condition carries a key path (#26858). No ui-only change can hold or show a server verdict at a rule's own path, so the seat returned #27482 to triage to file the producer-plus-consumer seam. - So the builder half of #27513 is fixed here and its "verdict half" (the server's verdict holding a Validations-tab save) rides that seam, not this PR. A rule built in the builder now writes what the server accepts; a hand-typed unguarded comparison in the raw editor still reads "Valid CEL" until the seam lands. - PR #27617 holds `DataPillar` regions of `StudioDesignSurface.tsx`; this PR no longer touches that file, so no merge ordering applies. - Branch `claude/project-thread-qx6k5b` is the card thread's designated push branch (the recorded platform exception to `claude/issue-N-slug`). The harness's after-PR assignee and review-request step is skipped by the dispatch order (the maintainer reviews after the seat accepts). ## Verification Local subset per the dispatch: the touched-file test set, `@object-ui/app-shell` type-check, the derived gate list reconciled with `dispatch-gates --ran`, two ablations (one per card). Repo-wide lint and the full ui suite are CI's. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZVdZdKe4J1nCNL23t)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fbb8bec commit 15b099e

7 files changed

Lines changed: 332 additions & 15 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): a CEL field shows the server's verdict only on the text it judged
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
In Studio's permission-set editor, an RLS `using` or `check` clause kept showing the previous draft's server error under text the author had since changed. An edit the CEL syntax check holds is never saved, so the draft is not read again and the server's verdict on the old text stayed on screen beside the new error: two different errors at once, one of them about text no longer in the field.
11+
12+
The clause now shows the server's errors on the same terms on which they hold the save: only while it still shows the text the server judged. Editing the text away hides them, and the next save's re-read brings the new verdict. Typing the judged text back shows the verdict again, because it describes that text.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): the condition builder writes a null-safe comparison on a field
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
In Studio's no-code condition builder, a row such as "`record.estimated_hours` greater than `40`" compiled to `record.estimated_hours > 40`. On a field that may be empty, CEL has no `>` for a missing value, so the server refused the rule at publish ("Guard it with '!= null'") and held back every other draft in the package, while the builder had said "Valid CEL".
11+
12+
A greater than, less than, at least or at most row on a field of the object now compiles behind a null test, `record.estimated_hours != null && record.estimated_hours > 40`: the shape the "Number can't be negative" preset already writes and the one the server's message prescribes. It is accepted at publish and evaluates to false on a record with no value. Equals and not equals are unchanged, as are rows on a context subject. A stored condition is never rewritten on open: a comparison saved without the guard reopens as the same row and keeps its text until the author edits that row.

‎ui/packages/app-shell/src/views/metadata-admin/CelPredicateField.tsx‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ import {
5454
type CelSuggestionKind,
5555
type CelValueType,
5656
} from './celAuthoring.js';
57-
import { useServerErrorsAt, useServerHoldAt, type ServerDiagnosticEntry } from './draftVerdict.js';
57+
import { useServerHoldAt, type ServerDiagnosticEntry } from './draftVerdict.js';
5858

5959
const LINT_DEBOUNCE_MS = 250;
6060

@@ -133,6 +133,12 @@ export interface CelPredicateFieldProps {
133133
* on what publish will refuse exactly as on a parse fault. Once the author
134134
* edits the text away the verdict no longer describes it and holds nothing:
135135
* the save goes out, and its re-read brings the next verdict.
136+
*
137+
* objectstack#27452 — the message is shown on the same terms as the hold:
138+
* only while the text on screen is the text the verdict judged. A save the
139+
* lint holds never re-reads, so a verdict on the previous text stays served;
140+
* it is not this text's, and is not shown under it. A page that provides no
141+
* judged body shows no server verdict here.
136142
*/
137143
verdictPath?: string;
138144
/**
@@ -404,12 +410,12 @@ export function CelPredicateField({
404410
const errors = issues.filter((i) => i.severity === 'error');
405411
const warnings = issues.filter((i) => i.severity === 'warning');
406412
// objectstack#26858 — the server's errors at this editor's path, each one
407-
// the lint does not already say in the same words.
408-
const atPath = useServerErrorsAt(verdictPath);
409-
const serverErrors = atPath.filter((e) => !issues.some((iss) => issueText(iss, t) === e.message));
413+
// the lint does not already say in the same words. objectstack#27452 — the
414+
// held ones: a verdict on text no longer on screen describes nothing here.
415+
const serverErrors = held.filter((e) => !issues.some((iss) => issueText(iss, t) === e.message));
410416
// "Valid CEL" only when nothing — the lint, the host's scope verdict
411417
// (objectui#11789) or the server's verdict at this path — says otherwise.
412-
const clean = linted && !!value.trim() && issues.length === 0 && !scopeIssue && atPath.length === 0;
418+
const clean = linted && !!value.trim() && issues.length === 0 && !scopeIssue && held.length === 0;
413419
// Result-type affordance (role="value"): shown once the expression parses,
414420
// even alongside warnings — the type is what dataset measure eligibility
415421
// keys off, so the author should see it whenever it is known.
@@ -437,7 +443,7 @@ export function CelPredicateField({
437443
aria-expanded={open}
438444
aria-autocomplete="list"
439445
aria-controls={open ? listId : undefined}
440-
aria-invalid={errors.length > 0 || atPath.length > 0 || undefined}
446+
aria-invalid={errors.length > 0 || held.length > 0 || undefined}
441447
onChange={(e) => {
442448
onChange(e.target.value);
443449
refreshAc();
@@ -450,7 +456,7 @@ export function CelPredicateField({
450456
}}
451457
className={cn(
452458
'font-mono text-xs',
453-
(errors.length > 0 || atPath.length > 0) && 'border-destructive focus-visible:ring-destructive/40',
459+
(errors.length > 0 || held.length > 0) && 'border-destructive focus-visible:ring-destructive/40',
454460
)}
455461
/>
456462
{open && suggestions.length > 0 && (

‎ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.serverVerdictHold-27482.test.tsx‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,43 @@ describe('a server error at a clause path holds the autosave (objectstack#27482)
172172
expect(server.sent).toHaveLength(0);
173173
});
174174
});
175+
176+
/**
177+
* objectstack#27452 — the verdict on the previous text is not shown under new
178+
* text. An edit the lint holds never saves, so nothing re-reads the draft and
179+
* the served verdict stays the one on the stored text; the clause shows only
180+
* the error that applies to what it holds.
181+
*/
182+
describe('a lint-held edit does not show the previous text\'s server verdict (objectstack#27452)', () => {
183+
/** A parse fault: the client lint holds it on its own. */
184+
const HELD_BY_LINT = 'f_total >';
185+
186+
it('the server message leaves with its text; the client error and the hold stay, no PUT', async () => {
187+
const server: Server = { draft: policy(REFUSED_USING), draftReads: 0, sent: [] };
188+
await openPackageDoor(server);
189+
await screen.findByText(REFUSAL);
190+
const reads = server.draftReads;
191+
192+
edit(USING_PLACEHOLDER, HELD_BY_LINT);
193+
194+
await waitFor(() => expect(screen.getByTestId('perm-autosave-held')).toBeInTheDocument(), AUTOSAVE);
195+
await waitFor(() => expect(screen.getByPlaceholderText(USING_PLACEHOLDER)).toHaveAttribute('aria-invalid', 'true'));
196+
expect(screen.queryByText(REFUSAL)).toBeNull();
197+
await pause(PAST_THE_PAUSE);
198+
expect(server.sent).toHaveLength(0);
199+
expect(server.draftReads).toBe(reads);
200+
expect(screen.queryByText(REFUSAL)).toBeNull();
201+
});
202+
203+
it('control: typed back to the judged text, the server message is shown again', async () => {
204+
const server: Server = { draft: policy(REFUSED_USING), draftReads: 0, sent: [] };
205+
await openPackageDoor(server);
206+
await screen.findByText(REFUSAL);
207+
208+
edit(USING_PLACEHOLDER, HELD_BY_LINT);
209+
await waitFor(() => expect(screen.queryByText(REFUSAL)).toBeNull());
210+
edit(USING_PLACEHOLDER, REFUSED_USING);
211+
212+
expect(await screen.findByText(REFUSAL)).toBeInTheDocument();
213+
});
214+
});
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27513 — the builder's ordering comparison on a field compiles
5+
* behind a null guard.
6+
*
7+
* The defect, as the release walkthrough measured it: on a Validations tab,
8+
* "Reject the save when… `record.estimated_hours` greater than `40`" on an
9+
* optional Number field saved as `record.estimated_hours > 40`. CEL has no `>`
10+
* overload for `null`, so the server refused the rule at publish and held the
11+
* whole package back. The "Number can't be negative" preset already writes the
12+
* guarded shape (`record.x != null && record.x < 0`); the builder now writes it
13+
* too.
14+
*
15+
* The verdicts here are read off the CEL the builder EMITS, judged by the
16+
* server's own null-guard rule (`@objectstack/lint`'s
17+
* `findUnguardedNullableOperands`, the gate the publish runs) and evaluated by
18+
* the engine the server evaluates a validation rule with. ⛔ Neither is called
19+
* from the builder: it decides nothing about nullability and guards every
20+
* ordering comparison on a catalog field.
21+
*/
22+
23+
import * as React from 'react';
24+
import { describe, it, expect, afterEach, beforeAll, vi } from 'vitest';
25+
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
26+
import userEvent from '@testing-library/user-event';
27+
import { ExpressionEngine } from '@objectstack/formula';
28+
import { findUnguardedNullableOperands } from '@objectstack/lint';
29+
30+
// objectui#4697 — ConditionBuilder calls useObjectFields(objectName)
31+
// unconditionally even when `fields` is supplied; keep that fetch off the network.
32+
const state = vi.hoisted(() => ({
33+
metadataClient: {
34+
get: vi.fn(async (): Promise<unknown> => undefined),
35+
list: vi.fn(async () => [] as unknown[]),
36+
withPreviewDrafts() { return this; },
37+
},
38+
}));
39+
vi.mock('../useMetadata', () => ({
40+
useMetadataClient: () => state.metadataClient,
41+
}));
42+
43+
import { ConditionBuilder, RECORD_CONDITION_SUBJECTS } from './ConditionBuilder';
44+
45+
afterEach(cleanup);
46+
47+
beforeAll(() => {
48+
// Radix Select probes pointer-capture APIs the test DOM lacks.
49+
for (const m of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture'] as const) {
50+
if (!Element.prototype[m]) {
51+
// @ts-expect-error test shim
52+
Element.prototype[m] = m === 'hasPointerCapture' ? () => false : () => {};
53+
}
54+
}
55+
});
56+
57+
/** The card's Work Order: an optional Number and a Picklist. */
58+
const FIELDS = [
59+
{ name: 'estimated_hours', label: 'Estimated Hours', type: 'number' },
60+
{ name: 'status', label: 'Status', type: 'select' },
61+
];
62+
/** What the server's index calls nullable on that object: neither is required or defaulted. */
63+
const NULLABLE = { nullableFields: new Set(['estimated_hours', 'status']) };
64+
65+
const GUARDED = 'record.estimated_hours != null && record.estimated_hours > 40';
66+
const UNGUARDED = 'record.estimated_hours > 40';
67+
68+
/** Evaluate `source` exactly as objectql's validation-rule evaluator does. */
69+
function evaluate(source: string, record: Record<string, unknown>) {
70+
const res = ExpressionEngine.evaluate<boolean>({ dialect: 'cel', source }, { record, previous: undefined });
71+
return res.ok ? { ok: true, value: res.value } : { ok: false };
72+
}
73+
74+
/** The Validations tab's mount: record scope, the server host's subjects. */
75+
function Harness({ initial }: { initial: string }) {
76+
const [v, setV] = React.useState(initial);
77+
return (
78+
<div>
79+
<ConditionBuilder
80+
value={v}
81+
onCommit={setV}
82+
fields={FIELDS}
83+
scope="record"
84+
subjects={{ context: RECORD_CONDITION_SUBJECTS }}
85+
/>
86+
<pre data-testid="committed">{v}</pre>
87+
</div>
88+
);
89+
}
90+
91+
const committed = () => screen.getByTestId('committed').textContent ?? '';
92+
const comboboxes = (c: HTMLElement) => Array.from(c.querySelectorAll('[role="combobox"]')) as HTMLElement[];
93+
/** The raw editor's textarea, present only when the builder fell back to it. */
94+
const rawEditor = (c: HTMLElement) => c.querySelector('textarea');
95+
96+
async function pick(trigger: HTMLElement, option: string) {
97+
await userEvent.click(trigger);
98+
await userEvent.click(await screen.findByRole('option', { name: option }));
99+
}
100+
101+
describe('an ordering comparison on a field compiles behind a null guard (objectstack#27513)', () => {
102+
it('the card flow — Add condition, the optional number, greater than, 40 — writes the guarded shape', async () => {
103+
const { container } = render(<Harness initial="" />);
104+
fireEvent.click(screen.getByRole('button', { name: /Add condition/ }));
105+
await pick(comboboxes(container)[0], 'record.estimated_hours');
106+
await pick(comboboxes(container)[1], 'greater than');
107+
fireEvent.change(screen.getByPlaceholderText('value'), { target: { value: '40' } });
108+
109+
expect(committed()).toBe(GUARDED);
110+
// The server's null-guard gate accepts it, and the engine evaluates it on
111+
// a row with no hours instead of faulting.
112+
expect(findUnguardedNullableOperands(committed(), NULLABLE)).toEqual([]);
113+
expect(evaluate(committed(), { estimated_hours: null, status: 'new' })).toEqual({ ok: true, value: false });
114+
expect(evaluate(committed(), { estimated_hours: 41, status: 'new' })).toEqual({ ok: true, value: true });
115+
expect(evaluate(committed(), { estimated_hours: 40, status: 'new' })).toEqual({ ok: true, value: false });
116+
});
117+
118+
it('INSTRUMENT CONTROL — the emission this replaces is refused by the same gate and faults in the same engine', () => {
119+
expect(findUnguardedNullableOperands(UNGUARDED, NULLABLE)).toMatchObject([
120+
{ operand: 'record.estimated_hours', operator: '>' },
121+
]);
122+
expect(evaluate(UNGUARDED, { estimated_hours: null, status: 'new' })).toEqual({ ok: false });
123+
});
124+
125+
it('the guarded text reopens as the one row it compiles from', () => {
126+
const { container } = render(<Harness initial={GUARDED} />);
127+
expect(rawEditor(container)).toBeNull();
128+
// One row: its subject and operator, and no join (two rows would read the
129+
// guard as an "is not empty" row of its own).
130+
expect(comboboxes(container)).toHaveLength(2);
131+
expect((screen.getByPlaceholderText('value') as HTMLInputElement).value).toBe('40');
132+
expect(committed()).toBe(GUARDED);
133+
});
134+
135+
it('beside another row the guarded row is parenthesised, and the OR join round-trips', async () => {
136+
const { container } = render(<Harness initial="record.status == 'new' || record.estimated_hours == 40" />);
137+
await pick(comboboxes(container).at(-1)!, 'greater than');
138+
const cel = committed();
139+
expect(cel).toBe(`record.status == 'new' || (${GUARDED})`);
140+
expect(findUnguardedNullableOperands(cel, NULLABLE)).toEqual([]);
141+
142+
cleanup();
143+
const again = render(<Harness initial={cel} />);
144+
expect(rawEditor(again.container)).toBeNull();
145+
expect(screen.getAllByPlaceholderText('value')).toHaveLength(2);
146+
});
147+
148+
it('a stored comparison written without the guard keeps its spelling until the author edits that row', () => {
149+
const { container } = render(<Harness initial={`${UNGUARDED} && record.status == 'new'`} />);
150+
// Adopted as rows, not rewritten: the builder writes nothing on mount.
151+
expect(rawEditor(container)).toBeNull();
152+
expect(committed()).toBe(`${UNGUARDED} && record.status == 'new'`);
153+
154+
// Editing the other row leaves this one's bytes alone.
155+
fireEvent.change(screen.getAllByPlaceholderText('value')[1], { target: { value: 'done' } });
156+
expect(committed()).toBe(`${UNGUARDED} && record.status == 'done'`);
157+
158+
// Editing this row writes it the way the builder writes.
159+
fireEvent.change(screen.getAllByPlaceholderText('value')[0], { target: { value: '50' } });
160+
expect(committed()).toBe(
161+
"(record.estimated_hours != null && record.estimated_hours > 50) && record.status == 'done'",
162+
);
163+
});
164+
165+
it('equality needs no guard: CEL compares against null without a fault', async () => {
166+
const { container } = render(<Harness initial="record.estimated_hours > 40" />);
167+
await pick(comboboxes(container).at(-1)!, 'equals');
168+
expect(committed()).toBe('record.estimated_hours == 40');
169+
expect(findUnguardedNullableOperands(committed(), NULLABLE)).toEqual([]);
170+
});
171+
172+
it('a subject that names no catalog field is not guarded', async () => {
173+
const { container } = render(<Harness initial="record.id == 5" />);
174+
await pick(comboboxes(container).at(-1)!, 'greater than');
175+
expect(committed()).toBe('record.id > 5');
176+
});
177+
});

0 commit comments

Comments
 (0)