Initial Commit
This commit is contained in:
117
openspec/specs/control-surface-schema/spec.md
Normal file
117
openspec/specs/control-surface-schema/spec.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# control-surface-schema spec delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: ControlSurface root document
|
||||
|
||||
`@suaveplan/control-surface-schema` SHALL export a `ControlSurface` type with: `id` (uuid v7), `name`, `version` (semver matching `CURRENT_SCHEMA_VERSION`), `createdAt`, `updatedAt`, `skin`, `root: ControlNode`. Every field is validated by the Zod schema.
|
||||
|
||||
#### Scenario: Empty surface validates
|
||||
|
||||
- **WHEN** `validateSurface({ id, name: "x", version: CURRENT_SCHEMA_VERSION, createdAt: 0, updatedAt: 0, skin: { id: "default" }, root: { type: "group", id: "root", layout: { kind: "absolute" }, transform: { position: { x:0, y:0 } }, children: [] } })` is called
|
||||
- **THEN** it returns `{ surface }` with no error
|
||||
|
||||
#### Scenario: Missing fields rejected
|
||||
|
||||
- **WHEN** `validateSurface({})` is called
|
||||
- **THEN** it returns `{ error }` with code `CONTROL_SURFACE_SCHEMA.INVALID_DOCUMENT` and Zod issues attached
|
||||
|
||||
**Verified by:** `src/validate/validate.test.ts`
|
||||
|
||||
### Requirement: ControlNode discriminated union
|
||||
|
||||
`ControlNode = Group | Control`. Validators distinguish by `type` field; cross-validation rejects controls inside controls or groups with mixed-type children that don't match the layout kind.
|
||||
|
||||
#### Scenario: Nested groups validate
|
||||
|
||||
- **WHEN** a surface contains a Group containing two Groups each containing three Controls
|
||||
- **THEN** validation succeeds and `walkSurface(surface, visitor)` visits 9 nodes in DFS order
|
||||
|
||||
**Verified by:** `src/walk/walk.test.ts`
|
||||
|
||||
### Requirement: 18 ControlKind variants
|
||||
|
||||
`ControlKind` SHALL be a closed union of 18 values: `knob`, `slider-v`, `slider-h`, `button-momentary`, `button-latching`, `switch-2`, `switch-3`, `led`, `digital-display`, `meter-vu`, `meter-peak`, `meter-spectral`, `xy-pad`, `jog-wheel`, `piano-key`, `drum-pad`, `text-label`, `image`. The validator rejects unknown kinds.
|
||||
|
||||
#### Scenario: Unknown kind rejected
|
||||
|
||||
- **WHEN** a Control has `kind: "not-a-thing"`
|
||||
- **THEN** `validateSurface` rejects with `INVALID_DOCUMENT`
|
||||
|
||||
**Verified by:** `src/validate/validate.test.ts::"unknown ControlKind"`
|
||||
|
||||
### Requirement: Six Binding variants
|
||||
|
||||
`Binding` SHALL cover `pluginParam`, `transport`, `midiOut`, `midiIn`, `dmx`, `compound`. The `compound` variant wraps an array of other bindings (no nested compounds — flat-only) so one knob can drive a plugin parameter, a MIDI CC, and a DMX channel simultaneously.
|
||||
|
||||
#### Scenario: pluginParam binding round-trips through validate + JSON
|
||||
|
||||
- **WHEN** a Control has `binding: { kind: "pluginParam", pluginId: "p1", paramPath: "channels/0/volume", curve: "log" }`
|
||||
- **THEN** `JSON.parse(JSON.stringify(validateSurface(surface).surface))` deep-equals the original
|
||||
|
||||
#### Scenario: Nested compound rejected
|
||||
|
||||
- **WHEN** a `compound` binding contains another `compound` in its `bindings` array
|
||||
- **THEN** validation rejects with `INVALID_DOCUMENT`
|
||||
|
||||
**Verified by:** `src/validate/validate.test.ts::"binding variants"`
|
||||
|
||||
### Requirement: GroupLayout — 4 variants
|
||||
|
||||
`GroupLayout` SHALL cover `grid`, `absolute`, `stack`, `free3d`. `free3d` is the only layout that admits z-axis transforms; the validator rejects nonzero `transform.position.z` under any other layout.
|
||||
|
||||
#### Scenario: 2D layout rejects z-transform
|
||||
|
||||
- **WHEN** a Group has `layout: { kind: "grid", columns: 4, rows: 4, gap: 8 }` and a child with `transform: { position: { x:0, y:0, z:1 } }`
|
||||
- **THEN** validation rejects with `INVALID_DOCUMENT`
|
||||
|
||||
**Verified by:** `src/validate/validate.test.ts::"layout z-axis discipline"`
|
||||
|
||||
### Requirement: Schema versioning + forward migration
|
||||
|
||||
`upgradeSurface(unknown)` SHALL accept any prior version registered in the migration chain and migrate forward to `CURRENT_SCHEMA_VERSION`. Unknown versions reject with `UNSUPPORTED_VERSION`.
|
||||
|
||||
#### Scenario: Upgrade 0.1.0 → 0.2.0 (illustrative)
|
||||
|
||||
- **WHEN** a v0.1.0 document is passed to `upgradeSurface` and a registered `0.1.0 → 0.2.0` migration exists
|
||||
- **THEN** the returned document validates against the current schema
|
||||
|
||||
#### Scenario: Unknown version rejects
|
||||
|
||||
- **WHEN** `upgradeSurface({ version: "99.0.0", ... })` is called
|
||||
- **THEN** it throws `SchemaError("CONTROL_SURFACE_SCHEMA.UNSUPPORTED_VERSION")`
|
||||
|
||||
**Verified by:** `src/upgrade/upgrade.test.ts`
|
||||
|
||||
### Requirement: Duplicate-id detection
|
||||
|
||||
`validateSurface` SHALL detect duplicate `id` values across the entire node tree and reject with `DUPLICATE_ID` carrying the offending ids on `context.ids`.
|
||||
|
||||
#### Scenario: Two controls with the same id rejected
|
||||
|
||||
- **WHEN** a surface has two `Control` nodes with `id: "x"`
|
||||
- **THEN** validation rejects with `DUPLICATE_ID` listing `["x"]`
|
||||
|
||||
**Verified by:** `src/identity/identity.test.ts`
|
||||
|
||||
### Requirement: Binding-reference cross-check
|
||||
|
||||
When a binding references a `pluginId` that is not present in the surface's optional `pluginCatalog` (passed alongside), validation SHALL reject with `BINDING_REFERENCE_MISSING` carrying the missing reference on context.
|
||||
|
||||
#### Scenario: Reference missing plugin
|
||||
|
||||
- **WHEN** `validateSurface(surface, { pluginCatalog: new Set(["p1"]) })` is called and a control references `pluginId: "p99"`
|
||||
- **THEN** validation rejects with `BINDING_REFERENCE_MISSING` carrying `{ missing: "p99" }`
|
||||
|
||||
**Verified by:** `src/identity/identity.test.ts::"binding ref"`
|
||||
|
||||
### Requirement: walkSurface DFS traversal
|
||||
|
||||
`walkSurface(surface, visitor)` SHALL visit every node depth-first, passing the node + its parent stack (root-first). Mutation during traversal is undefined behaviour (visitor is read-only by contract).
|
||||
|
||||
#### Scenario: DFS order
|
||||
|
||||
- **WHEN** a surface has root → A → [B, C → [D]] → E
|
||||
- **THEN** visitor is called with node ids in order `["root", "A", "B", "C", "D", "E"]`
|
||||
|
||||
**Verified by:** `src/walk/walk.test.ts`
|
||||
Reference in New Issue
Block a user