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
28 changes: 28 additions & 0 deletions .changeset/type-check-tests-plugin-form-4040.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
---

Releases nothing on purpose: `@object-ui/plugin-form` now type-checks its 40 test files
(`tsconfig.test.json` chained from `type-check`), and its `TEST_DEBT` entry is gone. Only
test sources changed; no published behaviour, and no public type, moved.

Thirteen code-tier errors, ten of them one collision. `describe.each` over the four
sectioned containers hands JSX a UNION of `ModalForm | DrawerForm | TabbedForm |
SplitForm`, and JSX resolves a union of components by INTERSECTING their props — so
`ModalFormSchema & DrawerFormSchema & …` reduced `formType` to `never` and every schema
was rejected, including the right one. The parametrised suites now name the surface they
actually exercise (`schema` plus `dataSource`) once, which is the only shape all four are
assignable to; the `formType` values they pass are pinned to a literal union instead of
`string`, so the discriminants stay checked.

The other three were each a stub or fixture that told the compiler less than the code:

- `occSave.test.tsx` stubbed `dataSource.update` with three parameters while the contract
is `(resource, id, data, opts?: { ifMatch? })` — and the assertion under test reads the
FOURTH argument. vitest records real arguments whatever the stub declares, so
`mock.calls[0][3]` passed at runtime against a declared 3-tuple. Now declared, so the
case checks the option bag it is about.
- `deriveMasterDetail.test.ts` built a field map by `.map().concat()`, where inference
narrowed the nine plain fields to `{ type: string }` and the tenth (a relation carrying
`reference`) was then rejected. The entry type is written out.
- `LineItemsPanel.test.tsx` had an unread `o` parameter on an `update` double (`TS6133`,
from the repo's `noUnusedParameters`), renamed `_o`.
2 changes: 1 addition & 1 deletion packages/plugin-form/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"scripts": {
"build": "vite build",
"test": "vitest run",
"type-check": "tsc --noEmit && tsc -p tsconfig.typetests.json",
"type-check": "tsc --noEmit && tsc -p tsconfig.typetests.json && tsc -p tsconfig.test.json",
"lint": "eslint ."
},
"dependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-form/src/LineItemsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function makeDataSource(overrides: any = {}) {
getObjectSchema: vi.fn().mockResolvedValue(null),
find: vi.fn().mockResolvedValue({ data: [{ id: 'l1', amount: 10 }] }),
create: vi.fn(async (_o: string, d: any) => ({ id: 'newline', ...d })),
update: vi.fn(async (o: string, id: string, d: any) => ({ id, ...d })),
update: vi.fn(async (_o: string, id: string, d: any) => ({ id, ...d })),
delete: vi.fn(async () => true),
...overrides,
} as any;
Expand Down
9 changes: 8 additions & 1 deletion packages/plugin-form/src/deriveMasterDetail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,9 +320,16 @@ describe('deriveDetail hydrates explicit-but-untyped override columns', () => {
describe('resolveInlineMode (grid vs form)', () => {
const thin = { fields: { name: { type: 'text' }, amount: { type: 'currency' }, parent: { type: 'master_detail', reference: 'p' } } };
const rich = { fields: { name: { type: 'text' }, notes: { type: 'textarea' }, parent: { type: 'master_detail', reference: 'p' } } };
// The entry type is written out because the nine plain fields and the tenth
// relation field do NOT have the same shape — only the relation carries
// `reference`. Left to inference the `.map()` produces `{ type: string }`
// entries and `.concat()` then rejects the relation for the extra key, which
// reads as "the fixture is wrong" when the fixture is exactly right.
type FieldEntry = [string, Record<string, string>];
const wide = {
fields: Object.fromEntries(
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'].map((n) => [n, { type: 'text' }])
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']
.map((n): FieldEntry => [n, { type: 'text' }])
.concat([['parent', { type: 'master_detail', reference: 'p' }]]),
),
};
Expand Down
27 changes: 25 additions & 2 deletions packages/plugin-form/src/discardGuard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import React from 'react';
import { registerAllFields } from '@object-ui/fields';
import type { DataSource } from '@object-ui/types';
import { ModalForm } from './ModalForm';
import { DrawerForm } from './DrawerForm';

Expand Down Expand Up @@ -216,10 +217,32 @@ function fireBeforeUnload(): Event {
return evt;
}

describe.each([
/**
* The two overlay containers, as one type.
*
* `ModalFormProps.schema` is a `ModalFormSchema` and `DrawerFormProps.schema` a
* `DrawerFormSchema`, each pinned to its own `formType` literal, so a
* `describe.each` tuple hands JSX a UNION of the two components — and JSX
* resolves a union of components by INTERSECTING their props, reducing
* `formType` to `never` and rejecting every schema including the correct one
* (`Type 'any' is not assignable to type 'never'`). Naming the surface the
* parametrisation actually uses is what makes the suite expressible; `schema`
* can only be `any` here, because a shared schema type would have to be
* assignable to both variants at once. See the longer note in
* `recordSwapLoading.test.tsx`, where the same collision accounts for most of
* this package's #4040 error count.
*/
type OverlayFormContainer = React.ComponentType<{
schema: any;
dataSource?: DataSource;
}>;

const OVERLAY_FORMS: ReadonlyArray<readonly [string, OverlayFormContainer]> = [
['ModalForm', ModalForm],
['DrawerForm', DrawerForm],
] as const)('%s beforeunload guard', (_name, Form) => {
];

describe.each(OVERLAY_FORMS)('%s beforeunload guard', (_name, Form) => {
it('does not block unload while the form is pristine', async () => {
render(
<Form
Expand Down
14 changes: 13 additions & 1 deletion packages/plugin-form/src/occSave.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,19 @@ const conflictError = () =>
currentRecord: { id: 'r1', name: 'Theirs', updated_at: NEWER_VERSION },
});

const makeDS = (update = vi.fn(async (_o: string, _id: string, d: any) => ({ id: 'r1', ...d }))) => ({
// The stub declares the FOURTH parameter because that is the one under test:
// `DataSource.update` is `(resource, id, data, opts?: { ifMatch?: string })`
// (packages/types/src/data.ts), and the whole point of these cases is which
// `ifMatch` the save carries. A stub with three parameters still records the
// real fourth argument at runtime — vitest does — so `mock.calls[0][3]` passed
// while the compiler was told the call tuple has length 3, and the assertion
// that the unguarded write sends NO options was reading past the end of a
// declared tuple rather than checking the declared option bag (#4040).
const makeDS = (
update = vi.fn(
async (_o: string, _id: string, d: any, _opts?: { ifMatch?: string }) => ({ id: 'r1', ...d }),
),
) => ({
getObjectSchema: vi.fn().mockResolvedValue(objectSchema),
findOne: vi.fn().mockResolvedValue({ id: 'r1', name: 'Mine', updated_at: VERSION }),
create: vi.fn(),
Expand Down
55 changes: 44 additions & 11 deletions packages/plugin-form/src/recordSwapLoading.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { registerAllFields } from '@object-ui/fields';
import type { DataSource } from '@object-ui/types';
import { ModalForm } from './ModalForm';
import { DrawerForm } from './DrawerForm';
import { TabbedForm } from './TabbedForm';
Expand Down Expand Up @@ -80,7 +81,47 @@ function deferredDataSource() {
const titleInput = () =>
document.body.querySelector<HTMLInputElement>('[data-field="title"] input');

const schemaFor = (formType: string, recordId: string) =>
/**
* The four containers under test, as one type.
*
* They are genuinely four different components — `ModalFormProps.schema` is a
* `ModalFormSchema`, `DrawerFormProps.schema` a `DrawerFormSchema`, and so on,
* each pinned to its own `formType` literal. A `describe.each` tuple therefore
* hands JSX a UNION of four component types, and JSX resolves a union of
* components by INTERSECTING their props: `ModalFormSchema & DrawerFormSchema &
* …` reduces `formType` to `never`, so every schema is rejected — including the
* right one for the case actually running. Ten of this package's thirteen
* unchecked-test errors were that one collision, reported as
* `Type 'any' is not assignable to type 'never'`.
*
* Naming the surface the parametrisation really exercises — the same two props
* against each container — is what makes the suite expressible. `schema` is
* deliberately `any` here and cannot be anything else: assigning
* `React.FC< ModalFormProps >` to a shared component type requires the shared
* schema type to be assignable to `ModalFormSchema` AND `DrawerFormSchema` AND
* the other two at once, which nothing but `any` satisfies. That is a fact
* about these four having no common props base, not a cast hiding a mismatch:
* the `formType` each case passes is checked below instead.
*/
type SectionedFormContainer = React.ComponentType<{
schema: any;
dataSource?: DataSource;
}>;

/** The `formType` discriminants these containers are pinned to, spelled once. */
type SectionedFormType = 'modal' | 'drawer' | 'tabbed' | 'split';

const CONTAINERS: ReadonlyArray<readonly [string, SectionedFormContainer, SectionedFormType]> = [
['ModalForm', ModalForm, 'modal'],
['DrawerForm', DrawerForm, 'drawer'],
['TabbedForm', TabbedForm, 'tabbed'],
['SplitForm', SplitForm, 'split'],
];

/** The two overlay containers — the only ones with an unsaved-input guard. */
const OVERLAY_CONTAINERS = CONTAINERS.filter(([, , t]) => t === 'modal' || t === 'drawer');

const schemaFor = (formType: SectionedFormType, recordId: string) =>
({
type: 'object-form',
formType,
Expand All @@ -96,12 +137,7 @@ const schemaFor = (formType: string, recordId: string) =>
beforeEach(() => vi.clearAllMocks());
afterEach(() => cleanup());

describe.each([
['ModalForm', ModalForm, 'modal'],
['DrawerForm', DrawerForm, 'drawer'],
['TabbedForm', TabbedForm, 'tabbed'],
['SplitForm', SplitForm, 'split'],
] as const)('%s — recordId swap', (_name, Form, formType) => {
describe.each(CONTAINERS)('%s — recordId swap', (_name, Form, formType) => {
it('does not keep showing the previous record while the new one loads', async () => {
const { ds, land } = deferredDataSource();
const { rerender } = render(
Expand Down Expand Up @@ -151,10 +187,7 @@ describe.each([
* (#2998) would stay armed for input belonging to a record no longer on screen:
* a plain refresh would prompt, and closing would ask to discard nothing.
*/
describe.each([
['ModalForm', ModalForm, 'modal'],
['DrawerForm', DrawerForm, 'drawer'],
] as const)('%s — swapping records clears the unsaved-input guard', (_name, Form, formType) => {
describe.each(OVERLAY_CONTAINERS)('%s — swapping records clears the unsaved-input guard', (_name, Form, formType) => {
it('stops blocking unload once the previous record is gone', async () => {
const { ds, land } = deferredDataSource();
const { rerender } = render(
Expand Down
41 changes: 41 additions & 0 deletions packages/plugin-form/tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
// Type-checks this package's TESTS, which `tsconfig.json` excludes.
// See `packages/types/tsconfig.test.json` for why that exclusion was a hole:
// the build correctly keeps tests out of `dist`, but nothing else compiled
// them, so a test could assert a contract the compiler never checked.
//
// `tsconfig.typetests.json` next door is the narrow rescue this supersedes for
// this package: it compiles `spec-symbol-batch7.test.tsx` alone, because that
// one file's whole value is compile-time assertions and it could not wait for
// the rest of the tree to compile (objectui#3181). It stays chained — the file
// is now read by both projects, which costs one extra compile and keeps the
// narrow project's reasoning where it was written.
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
// The package build emits `dist`; this project emits nothing, so it must
// not inherit `composite` / `declaration` from the build config.
"composite": false,
"jsx": "react-jsx",
"lib": ["ES2020", "DOM"],
// Two global augmentations, neither of them an import:
// - `node` for `spec-symbol-batch7.test.tsx`, kept in step with the narrow
// project next door which compiles the same file with `types: ["node"]`.
// - `@testing-library/jest-dom` for the `toBeInTheDocument` /
// `toHaveTextContent` matchers across the `.tsx` suites; it does not
// live under `@types/`, so it is never picked up automatically.
// Naming `types` at all switches off automatic `@types/*` inclusion, so
// both have to be listed here.
"types": ["node", "@testing-library/jest-dom"],
// Drop the root tsconfig's source-tree `paths` so `@object-ui/*` and
// `@objectstack/spec` resolve through the workspace dependency's built
// `.d.ts` instead of pulling sibling sources in as program inputs (TS6059).
"paths": {}
},
// No `src/**/*.d.ts` entry, unlike plugin-map's project: this package keeps no
// ambient declaration files, so there is nothing for such a pattern to name.
// Add it here the moment one appears — an ambient declaration is a program
// input only when a pattern NAMES it, and the build gets that for free from
// its `"include": ["src"]` while this project would not.
"include": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}
1 change: 0 additions & 1 deletion scripts/check-type-check-coverage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,6 @@ export const TEST_DEBT = {
"@object-ui/components": { errors: 31, issue: 4118, note: "TS7006x12, TS7031x12 — untyped test callback params" },
"@object-ui/react": { errors: 27, issue: 4118, note: "TS2769x9 — overload mismatch on render helpers" },
"@object-ui/i18n": { errors: 13, issue: 4118, note: "TS2769x9" },
"@object-ui/plugin-form": { errors: 12, issue: 4118, note: "TS2322x10" },
"@object-ui/plugin-dashboard": { errors: 6, issue: 4118 },
"@object-ui/plugin-list": { errors: 6, issue: 4118, note: "TS2353x3 — dialect keys" },
"@object-ui/permissions": { errors: 5, issue: 4118, note: "TS2741x5" },
Expand Down
Loading