import { describe, expect, it } from "vitest"; import { getToolcraftComponentContract } from "./component-contracts"; import { TOOLCRAFT_SEGMENTED_STATIC_FIT } from "./segmented-control-fit"; describe("Toolcraft template component contracts: choices and actions", () => { it("documents select as the fallback for segmented controls that exceed compact limits", () => { const segmented = getToolcraftComponentContract("segmented").decisionCatalog; const select = getToolcraftComponentContract("select").decisionCatalog; expect(segmented?.strictness).toBe("best-fit"); expect(segmented?.acceptableAlternatives?.join(" ")).toMatch(/Select/i); expect(segmented?.layoutConstraints).toContain( "Segmented controls are full-width controls and must not be placed in two-column inline or half-width layout groups.", ); expect(getToolcraftComponentContract("segmented").aiUsageRules).toContain( "Do not place Segmented beside Switch, Color, Select, or another control in an inline row; use Select when a finite choice must occupy a half-width column.", ); expect(select?.useWhen.join(" ")).toMatch(/long labels|many options/i); expect(select?.useWhen).toContain( "Use Select when a dropdown choice is more readable than a row of segmented cells.", ); expect(select?.layoutConstraints).toContain( "Standalone Select controls render stacked full-width with the label above the dropdown; do not use the compact side-label row with label left and dropdown right.", ); expect(select?.layoutConstraints).toContain( "Prefer compact two-column inline layout only for related short Select pairs that tune one workflow or entity.", ); expect(select?.layoutConstraints).toContain( "Use vertical one-select-per-row layout for single Select controls and as the fallback when a Select pair label, selected value, or option text would clip, truncate, or lose internal padding in the compact row.", ); expect(select?.layoutConstraints).toContain( "If a compact Select pair falls back to vertical layout, record the fit reason in the spec or worklog.", ); }); it("documents context-only labels for binary controls", () => { const switchContract = getToolcraftComponentContract("switch"); const checkboxContract = getToolcraftComponentContract("checkbox"); expect(switchContract.aiUsageRules).toContain( 'Switch labels name the setting context only; do not prefix labels with "Enable" or "Disable" because the switch already communicates on/off behavior.', ); expect(switchContract.aiUsageRules).toContain( 'Use labels such as "CRT", "Background", "Glow", or "Loop" instead of "Enable CRT" or "Disable background".', ); expect(switchContract.aiUsageRules).toContain( "Two adjacent Switch controls for the same product entity must share one inline row when every visible label fits without truncation. Keep paired labels to short one- or two-word names; the runtime auto-pairs safe adjacent switches by target entity, and generated schemas should stack switches only when any label would truncate.", ); expect(switchContract.aiUsageRules).toContain( "When the nearest section title already names the switch context, do not duplicate that title as the visible switch label. Use label false for a visual-only toggle and keep the meaning in target/description.", ); expect(switchContract.aiUsageRules).toContain( 'A Switch may share an inline row with one related parameter control when the visible switch label is short enough to fit. That row uses equal-width columns and the same horizontal column gap as paired Select controls; never shrink the switch column to intrinsic width. The non-switch parameter uses label false in that row; if its label is needed, stack the controls instead. In section-owned rows, use a short visible switch label such as "Include" instead of repeating the section title, such as "Include background" inside Background.', ); expect(checkboxContract.aiUsageRules).toContain( 'Checkbox labels name the setting context only; do not prefix labels with "Enable" or "Disable" because the checkbox already communicates enabled/selected state.', ); expect(checkboxContract.aiUsageRules).toContain( 'Use labels such as "Transparent background", "Guides", or "Loop" instead of "Enable transparent background".', ); expect(checkboxContract.aiUsageRules).toContain( "When the nearest section title already names the checkbox context, do not duplicate that title as the visible checkbox label. Use label false for a visual-only checkbox and keep the meaning in target/description.", ); expect(checkboxContract.aiUsageRules).toContain( "Two adjacent Checkbox controls for the same product entity must share one inline row when every visible label fits without truncation. Keep paired labels to short one- or two-word names; the runtime auto-pairs safe adjacent checkboxes by target entity, and generated schemas should stack checkboxes only when any label would truncate.", ); expect(checkboxContract.aiUsageRules).toContain( "A Checkbox may share an inline row with one related parameter control when the visible checkbox label is short enough to fit. That row uses equal-width columns and the same horizontal column gap as paired Select controls; never shrink the checkbox column to intrinsic width. The non-checkbox parameter uses label false in that row; if its label is needed, stack the controls instead. Hide the checkbox label when the section title provides the visible context.", ); }); it("documents Actions as local section command groups", () => { const contract = getToolcraftComponentContract("actions"); expect(contract.stateMode).toBe("command-only"); expect(contract.decisionCatalog?.ownsValueModel).toContain( "entity-scoped command group", ); expect(contract.decisionCatalog?.useWhen).toContain( "Use Actions for section-scoped commands such as Randomize palette, Normalize weights, Sort glyphs, Clear selection, Duplicate item, or Reset current entity.", ); expect(contract.aiUsageRules).toContain( "Use Actions for local commands inside the current section when the command affects only the nearby entity or workflow step.", ); expect(contract.aiUsageRules).toContain( "Good Actions examples: Randomize palette, Normalize weights, Sort glyphs, Clear selection, Duplicate item, Reset current layer, Reset current stop, or Shuffle shades.", ); expect(contract.aiUsageRules).toContain( "Do not use Actions for final product delivery actions; use sticky panelActions for Export, Copy, Download, Generate, or Apply.", ); expect(contract.aiUsageRules).toContain( "Do not use Actions for animation transport; Play, Pause, Resume, Restart, and Scrub belong to the top timeline when timeline behavior exists.", ); expect(contract.decisionCatalog?.layoutConstraints).toContain( "Do not set an Actions control label to the exact same visible text as its only button; use a short one- or two-word context label such as Ink wash, Palette action, or Current layer while the button keeps the command verb.", ); expect(contract.decisionCatalog?.layoutConstraints).toContain( "Actions never use a side-label layout. If a visible label exists, it sits above the buttons.", ); expect(contract.decisionCatalog?.layoutConstraints).toContain( "Actions buttons render as a two-column grid. One visible button occupies the left half of the section; two buttons occupy one half each; more than two buttons wrap into additional 50% cells.", ); expect(contract.decisionCatalog?.layoutConstraints).toContain( "Do not center or right-align a partial final Actions row; an odd trailing button stays in the left 50% cell.", ); expect(contract.aiUsageRules).toContain( "For a single visible Actions button, the control label and button label must not be identical; make the control label a concise context and the button label the command.", ); expect(contract.aiUsageRules).toContain( "Render the Actions label above the buttons; do not put the label on the left with buttons on the right.", ); expect(contract.aiUsageRules).toContain( "Render Actions buttons in 50% cells: one button uses the left half, two buttons fill one row, and larger groups continue in two columns.", ); expect(contract.aiUsageRules).toContain( "Do not stretch an odd trailing Actions button full-width.", ); expect(contract.aiUsageRules).toContain( 'For local reset-like actions, use product-specific values such as "reset-current-layer" or "reset-palette" and handle them through ToolcraftApp onPanelAction; do not use a bare "reset" value unless the action intentionally runs controls.reset.', ); }); it("documents CollectionActions as canvas-backed add/remove controls", () => { const contract = getToolcraftComponentContract("collectionActions"); expect(contract.stateMode).toBe("controlled"); expect(contract.decisionCatalog?.strictness).toBe("exact-owner"); expect(contract.decisionCatalog?.ownsValueModel).toContain( "repeatable product entity collection", ); expect(contract.decisionCatalog?.useWhen).toContain( "Use CollectionActions instead of a count Slider when the user edits the actual set of items rather than only a numeric amount.", ); expect(contract.decisionCatalog?.doNotReplaceWith).toContain( "Do not use Slider to add or remove real collection items.", ); expect(contract.decisionCatalog?.layoutConstraints).toContain( "recommendedMaxItems is an agent/layout/performance hint, not a hard add limit; hardMaxItems is allowed only for real algorithm, format, API, export, or proven performance limits.", ); expect(contract.decisionCatalog?.requiredAcceptance.join(" ")).toMatch( /canvas preview and export/i, ); expect(contract.aiUsageRules).toContain( "Adding or removing collection items must update the runtime target array consumed by the renderer and export; do not add panel-only items.", ); expect(contract.aiUsageRules).toContain( "recommendedMaxItems is advisory only and must not disable the plus button. Use hardMaxItems only when a real product, algorithm, API, export, or measured performance limit requires it.", ); expect(contract.aiUsageRules.join(" ")).toMatch(/TextInput/); expect(contract.aiUsageRules.join(" ")).toMatch(/FontPicker/); expect(contract.aiUsageRules).toContain( "Use FontPicker as the collection item control when each repeated item is a typography/text-style entity; do not split its font, color, opacity, size, case, letter-spacing, or line-height into sibling collection fields.", ); expect(contract.aiUsageRules).toContain( "Use itemControl when each repeated entity is one built-in value. Use itemControls only when two or more built-in fields describe one logical repeated product entity and are added or removed atomically.", ); expect(contract.decisionCatalog?.layoutConstraints).toContain( "Compound itemControls records render one line only between adjacent logical items, with no generated Item N headings; standalone color itemControl collections keep the compact two-column grid without item dividers.", ); expect(contract.decisionCatalog?.requiredAcceptance).toContain( "For itemControls, prove plus creates every declared default field in one runtime record, editing preserves sibling fields, and minus removes the whole final record from preview and export.", ); }); it("documents SourceCollection as source-cardinality built-in item controls", () => { const contract = getToolcraftComponentContract("sourceCollection"); expect(contract.stateMode).toBe("controlled"); expect(contract.visualComponent).toBe("ControlsPanelCollectionItems"); expect(contract.decisionCatalog?.strictness).toBe("exact-owner"); expect(contract.decisionCatalog?.ownsValueModel).toContain( "source-owned collection cardinality", ); expect(contract.decisionCatalog?.useWhen.join(" ")).toMatch( /loaded|derived|detected/i, ); expect(contract.decisionCatalog?.doNotReplaceWith?.join(" ")).toMatch( /custom control|copied|recreated/i, ); expect(contract.decisionCatalog?.requiredAcceptance.join(" ")).toMatch( /source item count.*canvas preview.*export/i, ); expect(contract.aiUsageRules.join(" ")).toMatch( /built-in itemControl.*must not render add or remove/i, ); }); it("documents segmented controls as compact selector-only choices", () => { const segmented = getToolcraftComponentContract("segmented"); const select = getToolcraftComponentContract("select"); const { maxOptionLabelLength, maxOptions, maxTotalLabelLength } = TOOLCRAFT_SEGMENTED_STATIC_FIT; const numericRule = `Generated schemas should keep text segmented controls to at most ${maxOptions} options, no option label longer than ${maxOptionLabelLength} characters, and no more than ${maxTotalLabelLength} total option-label characters.`; expect(select.decisionCatalog?.acceptableAlternatives).toContain( `Use Segmented for two to ${maxOptions} short closely related options that fit without clipping.`, ); expect(segmented.decisionCatalog?.ownsValueModel).toContain( `two to ${maxOptions} short related options`, ); expect(segmented.decisionCatalog?.layoutConstraints).toContain( `Keep text segmented controls to at most ${maxOptions} options and compact labels.`, ); expect(segmented.aiUsageRules).toContain( "Use Segmented only for compact mode choices where every cell keeps its internal padding.", ); expect(segmented.aiUsageRules).toContain( "If a segmented control is too wide, first shorten option labels; if the compact labels still exceed the width budget, use Select because it has the same selection mechanics without broken cells.", ); expect(segmented.aiUsageRules).toContain( "defineToolcraft rejects text Segmented schemas above the static fit budget before render; shorten labels or use Select instead of relying on clipping or runtime conversion.", ); expect(segmented.aiUsageRules).toContain(numericRule); }); it("documents tabs as label-free responsive content-view choices", () => { const tabs = getToolcraftComponentContract("tabs"); expect(tabs.visualComponent).toBe("TabsControl"); expect(tabs.labelPolicy).toBe("hidden"); expect(tabs.decisionCatalog?.ownsValueModel).toContain( "finite content-view selection", ); expect(tabs.decisionCatalog?.useWhen).toContain( "Use Tabs when the selected option replaces the content or workflow view shown below the control.", ); expect(tabs.decisionCatalog?.doNotReplaceWith).toContain( "Do not use Tabs for a compact setting that leaves the surrounding content unchanged; use Segmented.", ); expect(tabs.decisionCatalog?.layoutConstraints).toContain( "Tabs are full-width controls and must not be placed in two-column inline or half-width layout groups.", ); expect(tabs.decisionCatalog?.layoutConstraints).toContain( "When tab cells do not preserve text and internal padding on one row, Tabs automatically renders the same options through Select.", ); expect(tabs.aiUsageRules).toContain( "Tabs never render a separate visible field label; use the control label as its accessible name and rely on the nearest section context.", ); }); it("keeps panel actions documented as sticky footer command controls", () => { const contract = getToolcraftComponentContract("panelActions"); expect(contract.stateMode).toBe("command-only"); expect(contract.commands).toEqual(["controls.apply"]); expect(contract.aiUsageRules).toContain( "Do not use panelActions for resetting controls; the controls panel header owns Reset controls.", ); expect(contract.aiUsageRules).toContain( "Handle product-specific non-export panelActions through ToolcraftApp onPanelAction. Typed export-image/export-video actions consume ToolcraftAppComposition.exportRenderer; typed export-svg consumes svgExportRenderer. All typed export actions are runtime-owned.", ); expect(contract.aiUsageRules).toContain( "Async non-export product actions such as Download, Copy, Generate, or Apply must return the real Promise from onPanelAction and report progress through the onPanelAction reportProgress callback.", ); expect(contract.aiUsageRules).toContain( "The sticky footer top accent indicator is determinate when reportProgress receives 0..1 values and falls back to pending state only when progress is unavailable.", ); expect(contract.aiUsageRules).toContain( "defineToolcraft hoists panelActions into the controls panel sticky footer automatically.", ); expect(contract.aiUsageRules).toContain( "Every product must declare productReadiness.exportIntent before authoring export panelActions or settings sections.", ); expect(contract.aiUsageRules).toContain( 'Export-labeled panelActions use icon "upload-simple", matching the Setup "Export Settings" action; do not use "download", "download-simple", or "export" icons for Export PNG, Export SVG, or Export Video.', ); expect(contract.aiUsageRules).toContain( 'Image export is the Toolcraft product default. Keep Export PNG unless productReadiness.exportIntent.image is "user-removed" with non-empty explicit user-removal evidence.', ); expect(contract.aiUsageRules).toContain( 'Every app with Export PNG must expose a separate "Image Export" controls section.', ); expect(contract.aiUsageRules).toContain( 'The Image Export section must include "export.image.format" as a Select control with PNG and JPG choices, defaulting to "png".', ); expect(contract.aiUsageRules).toContain( 'The Image Export section must include "export.image.resolution" as a Select control with 2K, 4K, and 8K choices, defaulting to "4k".', ); expect(contract.aiUsageRules).toContain( "Image Export format and resolution render as one compact two-column inline Select pair, matching the Video Export settings structure.", ); expect(contract.aiUsageRules).toContain( "Image Export resolution controls the actual exported image long edge: 2K = 2048px, 4K = 4096px, 8K = 8192px. Runtime resolves the selected value and protected browser acceptance proves decoded image width and height.", ); expect(contract.aiUsageRules).toContain( 'Add Export SVG only when productReadiness.exportIntent.svg is "user-requested" with non-empty explicit user-request evidence.', ); expect(contract.aiUsageRules).toContain( "Export SVG means self-contained editable vector geometry/text. Do not wrap raster bytes in image, foreignObject, or data URLs and do not silently trace raster/WebGL/model output.", ); expect(contract.aiUsageRules).toContain( "SVG export has no settings section. Product code appends namespace-aware vector nodes through svgExportRenderer; runtime owns frame/viewBox, background, XML validation, serialization, download, progress, and typed failures.", ); expect(contract.aiUsageRules).toContain( "Protected SVG browser proof must inspect the exact downloaded bytes with strict XML parsing, namespace/vector-only policy, native decode, content hashing, and exact product vector expectations.", ); expect(contract.aiUsageRules).toContain( 'Add Export Video only when productReadiness.exportIntent.video is "user-requested" with non-empty explicit user-request evidence.', ); expect(contract.aiUsageRules).toContain( 'Animation, playback, keyframes, and timeline presence never add Export Video when productReadiness.exportIntent.video is "not-requested".', ); expect(contract.aiUsageRules).toContain( "Any app with Export Video must enable the top Toolcraft timeline; video duration, loop, and rendered timestamps come from runtime timeline state.", ); expect(contract.aiUsageRules).toContain( 'Apps with Export Video must expose a separate "Video Export" controls section.', ); expect(contract.aiUsageRules).toContain( 'Apps with both Export PNG and Export Video must expose both "Image Export" and "Video Export"; Image Export sits immediately before Video Export.', ); expect(contract.aiUsageRules).toContain( 'Image-only apps place "Image Export" directly above sticky footer panelActions.', ); expect(contract.aiUsageRules).toContain( 'Video-only apps require explicit image user-removal evidence and place "Video Export" directly above sticky footer panelActions.', ); expect(contract.aiUsageRules).toContain( 'Explicit no-export apps require image user-removal evidence with SVG/video "not-requested" and omit export actions and settings sections.', ); expect(contract.aiUsageRules).toContain( 'The Video Export section must include format and resolution controls such as targets "export.video.format" and "export.video.resolution".', ); expect(contract.aiUsageRules).toContain( "Use Select controls for Video Export format and resolution; do not use Segmented unless the product has a deliberately tiny fixed output menu and browser tests prove every cell keeps padding.", ); expect(contract.aiUsageRules).toContain( "Place the Video Export section as the final controls section directly above sticky footer panelActions.", ); expect(contract.aiUsageRules).toContain( 'Video Export format defaults to "mp4"; keep "webm" available as the baseline alternate unless the prompt/reference requires another default.', ); expect(contract.aiUsageRules).toContain( 'Video Export resolution defaults to "current"; keep "4k" available as the high-resolution alternate.', ); expect(contract.aiUsageRules).toContain( "Video Export format and resolution are a compact semantic pair and should use a two-column inline layout by default; use stacked rows only when labels or selected values do not fit without clipping.", ); expect(contract.aiUsageRules).toContain( 'Baseline browser video formats are "mp4" and "webm"; MOV or ProRes require an explicit custom encoder/transcoder and dedicated acceptance plus performance coverage.', ); expect(contract.aiUsageRules).toContain( "Runtime video export chooses the actual MIME, container, and codec through the timestamped Mediabunny encoder capability check, then returns the real supported format or a typed visible failure.", ); expect(contract.aiUsageRules).toContain( "Video export must use getToolcraftVideoExportSize for current and 4K dimensions. Current video export uses the current canvas/output size with even encoder-safe rounding; 4K video fits inside an encoder-safe 3840x2160 box, preserves canvas aspect ratio, and uses even pixel dimensions. Do not hand-roll 4096px long-edge video sizing.", ); expect(contract.aiUsageRules).toContain( "Runtime video export allocates the selected recording canvas dimensions before timestamped encoder setup and rejects renderer, encoder, muxer, and output-limit errors instead of returning corrupt blobs.", ); expect(contract.aiUsageRules).toContain( "Offline video export duration is encoded from the fixed runtime 30 FPS timeline schedule. Product code must not use canvas.captureStream, MediaRecorder, or wall-clock time as a fallback.", ); expect(contract.aiUsageRules).toContain( 'Video resolution must control exported dimensions. Use "current" output size by default; "4K" is an export resolution target, not a hardcoded 3840x2160 canvas lock.', ); expect(contract.aiUsageRules).toContain( "Video export browser coverage must load the exported blob metadata and prove video.duration matches the edited runtime timeline duration; blobSize/blobType checks alone are not enough.", ); expect(contract.aiUsageRules).toContain( "Video export must report frame-based progress through reportProgress during render/encode steps. PNG export should report phase progress for render, blob, and handoff when those phases are asynchronous.", ); expect(contract.aiUsageRules).toContain( 'Product-output apps declare the standard background pair in an authored "Background" source section. Runtime removes that visible section and places Background plus Infinity canvas, then Background color, in Setup; Timeline is the final Setup control.', ); expect(contract.aiUsageRules).toContain( "Product-output apps call shouldIncludeToolcraftPreviewBackground(state) for bounded live preview background. Runtime artifact export owns Background semantics: PNG may be transparent, JPEG and video remain opaque, Infinity uses the full-viewport color, and Background off restores finite mode and disables Infinity.", ); expect(contract.aiUsageRules).toContain( "ToolcraftAppComposition.exportRenderer draws one deterministic product frame in scene coordinates for both image and video. Runtime owns canvas allocation, background, runtime media/model compositing, selected dimensions, encoding, download, progress, and typed failures.", ); expect(contract.aiUsageRules).toContain( "ToolcraftAppComposition.svgExportRenderer appends one deterministic namespace-aware vector frame. Runtime owns SVG root/frame/background assembly, vector-only validation, strict XML serialization, download, progress, and typed failures.", ); expect(contract.aiUsageRules).toContain( "Product modules must not encode or download export canvases directly and must not instantiate MediaRecorder or VideoEncoder; the generated-source boundary rejects those duplicate export paths.", ); expect(contract.aiUsageRules).toContain( "Runtime video export keeps the selected background and uses getToolcraftVideoExportSize for current/4K output dimensions.", ); expect(contract.aiUsageRules).toContain( "Copy PNG can be a secondary action when clipboard output is useful, but copy does not replace export.", ); expect(contract.aiUsageRules).toContain( "Add Copy PNG as a secondary action only when the prompt/reference includes clipboard output or the product clearly benefits from paste/share workflows.", ); expect(contract.aiUsageRules).toContain( "Footer actions must be one compact horizontal group; do not split them into stacked full-width sections.", ); expect(contract.aiUsageRules).toContain( "If two footer actions are needed, render secondary/outline on the left and primary on the right.", ); expect(contract.aiUsageRules).toContain( "When an odd number of footer actions renders in two columns, the final unpaired action spans the full row width.", ); }); });