|
2 | 2 |
|
3 | 3 | /** |
4 | 4 | * Pin for the frontmatter `title` / `navTitle` of generated reference pages |
5 | | - * (#15403). |
| 5 | + * (#15403), and for the one page fact the title reads besides names: whether |
| 6 | + * the page renders a `### Properties` table. |
6 | 7 | * |
7 | 8 | * `check:docs` compares the regenerated tree with the committed one, so it |
8 | 9 | * holds the OUTPUT still but says nothing about the rule: a regression that |
|
15 | 16 | import { describe, expect, it } from 'vitest'; |
16 | 17 |
|
17 | 18 | import { CATEGORY_TITLES } from './lib/category-title'; |
| 19 | +import { declaresProperties, renderSchemaSection, rendersPropertiesTable } from './lib/schema-section'; |
18 | 20 | import { |
19 | 21 | RENDERED_TITLE_MAX, |
20 | 22 | ROOT_INDEX_NAV_TITLE, |
@@ -57,69 +59,153 @@ describe('the band', () => { |
57 | 59 | }); |
58 | 60 |
|
59 | 61 | /** |
60 | | - * Real `(module name, category title)` pairs from `content/docs/references/**` |
61 | | - * at `862b6ce8`, the name being the page's title before this rule — two per |
62 | | - * rung of the module ladder, including the tree's shortest name (`Mcp`, 3) and |
63 | | - * its two longest pairs (`Expression Bindable Text Keys` in UI, name 29; |
64 | | - * `Schemaless Node Config` in Automation, name + category 41). |
| 62 | + * Real `(module name, category title, renders a Properties table)` triples from |
| 63 | + * `content/docs/references/**`, the name being the page's title before this |
| 64 | + * rule — two per rung of the module ladder, including the tree's shortest name |
| 65 | + * (`Mcp`, 3), its two longest pairs (`Expression Bindable Text Keys` in UI, name |
| 66 | + * 29; `Schemaless Node Config` in Automation, name + category 41), and |
| 67 | + * `data/feed`, the one page the first rung reached without a property table. |
65 | 68 | */ |
66 | | -const MODULE_PAGES: Array<[name: string, categoryTitle: string, title: string]> = [ |
67 | | - ['Mcp', 'AI Protocol', 'Mcp schema — AI Protocol property reference'], |
68 | | - ['Agent', 'AI Protocol', 'Agent schema — AI Protocol property reference'], |
69 | | - ['Object', 'Data Protocol', 'Object schema — Data Protocol reference'], |
70 | | - ['Flow', 'Automation Protocol', 'Flow schema — Automation Protocol reference'], |
71 | | - ['Plugin Registry', 'Kernel Protocol', 'Plugin Registry — Kernel Protocol reference'], |
72 | | - ['Package Api Assembled', 'API Protocol', 'Package Api Assembled — API Protocol reference'], |
73 | | - ['Metadata Protection', 'Kernel Protocol', 'Metadata Protection — Kernel Protocol'], |
74 | | - ['Expression Bindable Text Keys', 'UI Protocol', 'Expression Bindable Text Keys — UI Protocol'], |
75 | | - ['Schemaless Node Config', 'Automation Protocol', 'Schemaless Node Config — Automation Protocol'], |
| 69 | +const MODULE_PAGES: Array<[name: string, categoryTitle: string, documentsProperties: boolean, title: string]> = [ |
| 70 | + ['Mcp', 'AI Protocol', true, 'Mcp schema — AI Protocol property reference'], |
| 71 | + ['Agent', 'AI Protocol', true, 'Agent schema — AI Protocol property reference'], |
| 72 | + ['Feed', 'Data Protocol', false, 'Feed schema — Data Protocol reference'], |
| 73 | + ['Object', 'Data Protocol', true, 'Object schema — Data Protocol reference'], |
| 74 | + ['Flow', 'Automation Protocol', true, 'Flow schema — Automation Protocol reference'], |
| 75 | + ['Plugin Registry', 'Kernel Protocol', true, 'Plugin Registry — Kernel Protocol reference'], |
| 76 | + ['Package Api Assembled', 'API Protocol', true, 'Package Api Assembled — API Protocol reference'], |
| 77 | + ['Metadata Protection', 'Kernel Protocol', false, 'Metadata Protection — Kernel Protocol'], |
| 78 | + ['Expression Bindable Text Keys', 'UI Protocol', false, 'Expression Bindable Text Keys — UI Protocol'], |
| 79 | + ['Schemaless Node Config', 'Automation Protocol', true, 'Schemaless Node Config — Automation Protocol'], |
76 | 80 | ]; |
77 | 81 |
|
78 | 82 | describe('modulePageTitle', () => { |
79 | | - it.each(MODULE_PAGES)('%s (%s) → %s', (name, categoryTitle, title) => { |
80 | | - const out = modulePageTitle({ name, categoryTitle }, `references/x/${name}.mdx`); |
| 83 | + it.each(MODULE_PAGES)('%s (%s, properties: %s) → %s', (name, categoryTitle, documentsProperties, title) => { |
| 84 | + const out = modulePageTitle({ name, categoryTitle, documentsProperties }, `references/x/${name}.mdx`); |
81 | 85 | expect(out).toEqual({ title, navTitle: name }); |
82 | 86 | expectRuleShaped(out.title); |
83 | 87 | }); |
84 | 88 |
|
85 | 89 | it('takes the LONGEST rung inside the band', () => { |
86 | 90 | // `Agent` fits the first rung (45) and the second (36): the first wins. |
87 | | - const candidates = modulePageTitleCandidates({ name: 'Agent', categoryTitle: 'AI Protocol' }); |
| 91 | + const agent = { name: 'Agent', categoryTitle: 'AI Protocol', documentsProperties: true }; |
| 92 | + const candidates = modulePageTitleCandidates(agent); |
88 | 93 | expect(candidates.filter(titleInBand)).toHaveLength(2); |
89 | | - expect(modulePageTitle({ name: 'Agent', categoryTitle: 'AI Protocol' }, 'p').title).toBe(candidates[0]); |
| 94 | + expect(modulePageTitle(agent, 'p').title).toBe(candidates[0]); |
| 95 | + }); |
| 96 | + |
| 97 | + it('offers `property reference` only to a page that renders a Properties table', () => { |
| 98 | + // The same name and category, the one fact flipped: `data/feed` renders two |
| 99 | + // enums (`### Allowed Values`) and no property, so it starts at the second rung. |
| 100 | + const feed = { name: 'Feed', categoryTitle: 'Data Protocol' }; |
| 101 | + const withTable = modulePageTitleCandidates({ ...feed, documentsProperties: true }); |
| 102 | + const withoutTable = modulePageTitleCandidates({ ...feed, documentsProperties: false }); |
| 103 | + expect(withTable[0]).toBe('Feed schema — Data Protocol property reference'); |
| 104 | + expect(withoutTable.some(c => c.includes('property'))).toBe(false); |
| 105 | + expect(withoutTable).toEqual(withTable.slice(1)); |
| 106 | + expect(modulePageTitle({ ...feed, documentsProperties: false }, 'p').title).toBe('Feed schema — Data Protocol reference'); |
90 | 107 | }); |
91 | 108 |
|
92 | 109 | it('keeps the rungs longest first, so the first fit is the longest fit', () => { |
93 | | - const lengths = modulePageTitleCandidates({ name: 'N', categoryTitle: 'C' }).map(c => c.length); |
94 | | - expect([...lengths].sort((a, b) => b - a)).toEqual(lengths); |
| 110 | + for (const documentsProperties of [true, false]) { |
| 111 | + const lengths = modulePageTitleCandidates({ name: 'N', categoryTitle: 'C', documentsProperties }).map(c => c.length); |
| 112 | + expect([...lengths].sort((a, b) => b - a)).toEqual(lengths); |
| 113 | + } |
95 | 114 | }); |
96 | 115 |
|
97 | 116 | it('is total for every declared category and every name that can fit beside it', () => { |
98 | | - // The ladder's rungs overlap end to end: every name + category length from |
99 | | - // 7 to 43 lands in the band. Swept over the real category titles, with |
100 | | - // every name length up to the limit that category leaves. |
| 117 | + // With a Properties table the rungs overlap end to end from name + category |
| 118 | + // 7 to 43; without one, from 16 to 43. Swept over the real category titles, |
| 119 | + // with every name length up to the limit that category leaves. |
101 | 120 | for (const categoryTitle of Object.values(CATEGORY_TITLES)) { |
102 | 121 | for (let n = 1; categoryTitle.length + n <= 43; n++) { |
103 | | - expectRuleShaped(modulePageTitle({ name: 'N'.repeat(n), categoryTitle }, 'p').title); |
| 122 | + expectRuleShaped(modulePageTitle({ name: 'N'.repeat(n), categoryTitle, documentsProperties: true }, 'p').title); |
| 123 | + if (categoryTitle.length + n >= 16) { |
| 124 | + expectRuleShaped(modulePageTitle({ name: 'N'.repeat(n), categoryTitle, documentsProperties: false }, 'p').title); |
| 125 | + } |
104 | 126 | } |
105 | 127 | } |
106 | 128 | }); |
107 | 129 |
|
| 130 | + it('refuses a property-less page too short for the second rung, naming it', () => { |
| 131 | + // name + category 15: the second rung is 35, one under the band. |
| 132 | + expect(() => |
| 133 | + modulePageTitle({ name: 'Abcd', categoryTitle: 'AI Protocol', documentsProperties: false }, 'content/docs/references/ai/abcd.mdx'), |
| 134 | + ).toThrow(/content\/docs\/references\/ai\/abcd\.mdx/); |
| 135 | + }); |
| 136 | + |
108 | 137 | it('keeps two same-named modules in different categories apart', () => { |
109 | | - const kernel = modulePageTitle({ name: 'Plugin', categoryTitle: 'Kernel Protocol' }, 'p').title; |
110 | | - const studio = modulePageTitle({ name: 'Plugin', categoryTitle: 'Studio Protocol' }, 'p').title; |
| 138 | + const kernel = modulePageTitle({ name: 'Plugin', categoryTitle: 'Kernel Protocol', documentsProperties: true }, 'p').title; |
| 139 | + const studio = modulePageTitle({ name: 'Plugin', categoryTitle: 'Studio Protocol', documentsProperties: true }, 'p').title; |
111 | 140 | expect(kernel).not.toBe(studio); |
112 | 141 | }); |
113 | 142 |
|
114 | 143 | it('refuses, naming the page, when no rung fits — never a truncated title', () => { |
115 | | - const tooLong = { name: 'An Exceedingly Long Module Display Name', categoryTitle: 'Integration Protocol' }; |
| 144 | + const tooLong = { name: 'An Exceedingly Long Module Display Name', categoryTitle: 'Integration Protocol', documentsProperties: true }; |
116 | 145 | expect(modulePageTitleCandidates(tooLong).some(titleInBand)).toBe(false); |
117 | 146 | expect(() => modulePageTitle(tooLong, 'content/docs/references/integration/long.mdx')).toThrow( |
118 | 147 | /content\/docs\/references\/integration\/long\.mdx/, |
119 | 148 | ); |
120 | 149 | }); |
121 | 150 | }); |
122 | 151 |
|
| 152 | +/** |
| 153 | + * The fact the first module rung reads — whether a schema renders a |
| 154 | + * `### Properties` table — held equal to what the renderer really emits, one |
| 155 | + * JSON Schema shape per branch of `renderSchemaSection`. If a branch changes |
| 156 | + * what it renders and the predicate does not follow, this goes red instead of |
| 157 | + * a title quietly claiming a table its page lacks. |
| 158 | + */ |
| 159 | +const SECTION_SHAPES: Array<[shape: string, name: string, schema: any, rendersTable: boolean]> = [ |
| 160 | + ['object root with properties', 'Agent', { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, true], |
| 161 | + ['string enum (data/feed FeedFilterMode)', 'FeedFilterMode', { type: 'string', enum: ['all', 'comments_only', 'changes_only', 'tasks_only'] }, false], |
| 162 | + ['string enum (data/feed FeedItemType)', 'FeedItemType', { type: 'string', enum: ['comment', 'field_change', 'task'] }, false], |
| 163 | + [ |
| 164 | + 'union with an object arm', |
| 165 | + 'Trigger', |
| 166 | + { anyOf: [{ type: 'object', properties: { type: { type: 'string', const: 'cron' }, expr: { type: 'string' } } }, { type: 'string' }] }, |
| 167 | + true, |
| 168 | + ], |
| 169 | + ['oneOf with an object arm', 'Source', { oneOf: [{ type: 'object', properties: { url: { type: 'string' } } }, { type: 'number' }] }, true], |
| 170 | + ['union of an enum and a scalar', 'Mode', { anyOf: [{ type: 'string', enum: ['a', 'b'] }, { type: 'number' }] }, false], |
| 171 | + [ |
| 172 | + 'string enum that also carries a union (the enum branch wins)', |
| 173 | + 'Kind', |
| 174 | + { type: 'string', enum: ['a'], anyOf: [{ type: 'object', properties: { x: { type: 'string' } } }] }, |
| 175 | + false, |
| 176 | + ], |
| 177 | + ['bare scalar', 'ObjectName', { type: 'string', description: 'Machine name' }, false], |
| 178 | + ['record map (additionalProperties, no properties)', 'Labels', { type: 'object', additionalProperties: { type: 'string' } }, false], |
| 179 | + ['array of objects', 'Rows', { type: 'array', items: { type: 'object', properties: { id: { type: 'string' } } } }, false], |
| 180 | + [ |
| 181 | + 'definitions entry under its own name', |
| 182 | + 'Wrapped', |
| 183 | + { $ref: '#/definitions/Wrapped', definitions: { Wrapped: { type: 'object', properties: { a: { type: 'number' } } } } }, |
| 184 | + true, |
| 185 | + ], |
| 186 | +]; |
| 187 | + |
| 188 | +describe('rendersPropertiesTable', () => { |
| 189 | + it.each(SECTION_SHAPES)('%s → %s', (_shape, name, schema, rendersTable) => { |
| 190 | + expect(rendersPropertiesTable(name, schema)).toBe(rendersTable); |
| 191 | + // The renderer's own output agrees, whatever the expectation above says. |
| 192 | + expect(/^### Properties$/m.test(renderSchemaSection(name, schema))).toBe(rendersTable); |
| 193 | + }); |
| 194 | + |
| 195 | + it('answers the data/feed page as having no property table', () => { |
| 196 | + const feed = SECTION_SHAPES.filter(([shape]) => shape.includes('data/feed')); |
| 197 | + expect(feed).toHaveLength(2); |
| 198 | + expect(feed.some(([, name, schema]) => rendersPropertiesTable(name, schema))).toBe(false); |
| 199 | + }); |
| 200 | + |
| 201 | + it('declaresProperties is the object-with-properties test and nothing wider', () => { |
| 202 | + expect(declaresProperties({ type: 'object', properties: { a: { type: 'string' } } })).toBe(true); |
| 203 | + expect(declaresProperties({ type: 'object' })).toBe(false); |
| 204 | + expect(declaresProperties({ properties: { a: {} } })).toBe(false); |
| 205 | + expect(declaresProperties(undefined)).toBe(false); |
| 206 | + }); |
| 207 | +}); |
| 208 | + |
123 | 209 | describe('categoryIndexTitle', () => { |
124 | 210 | it.each([ |
125 | 211 | ['AI Protocol', 'AI Protocol — complete schema reference'], |
|
0 commit comments