diff --git a/component/component.go b/component/component.go index 1c87cdc..296753e 100644 --- a/component/component.go +++ b/component/component.go @@ -1,10 +1,25 @@ // Package component defines the typed union of A2UI components that a2tea -// understands. Each concrete type corresponds to one component "kind" in the -// A2UI JSON schema (see https://a2ui.org). +// understands. Each concrete type corresponds to one component type in the +// A2UI v0.9 basic catalog. // -// The interface is deliberately small: a Component knows its Kind so that -// dispatch tables in sibling packages (render, event) can pick the right -// implementation without type switches sprinkled across the codebase. +// Schema reference: +// - https://a2ui.org/specification/v0.9-a2ui/ +// - https://a2ui.org/reference/components/ +// +// Wire shape (v0.9, flat-string discriminator): +// +// { +// "id": "submit_button", +// "component": "Button", +// "child": "submit_button_label", +// "variant": "primary" +// } +// +// A2UI delivers components as a flat adjacency list keyed by ID; container +// components reference their children by ID via "child" (single) or +// "children" (array). a2tea's component package decodes individual component +// objects — assembling the adjacency list into a renderable tree is the +// renderer's job. package component import ( @@ -13,181 +28,505 @@ import ( "fmt" ) -// Component is the closed union of all A2UI components a2tea can render. +// Component is the closed union of A2UI components a2tea understands. // -// New component kinds MUST be added here AND in the Unmarshal switch below, -// otherwise they will be silently rejected. +// Implementations correspond one-to-one with entries in the A2UI v0.9 basic +// catalog. New component types MUST be added here AND to the dispatch switch +// in Unmarshal, otherwise they will be rejected with ErrUnknownComponent. type Component interface { - // Kind returns the A2UI component kind string ("card", "form", ...). - // It is the dispatch key used by render.For and event routing. + // Kind returns the PascalCase A2UI component type name + // ("Card", "Button", "Text", ...). It matches the JSON "component" + // discriminator and is the dispatch key used by renderers and event + // routers. Kind() string } -// Common kind constants. Keep these as a single source of truth so renderers -// and tests can reference them without stringly-typed drift. +// Component type constants. Single source of truth so renderers, event +// routers, and tests can reference component types without stringly-typed +// drift. const ( - KindCard = "card" - KindForm = "form" - KindInput = "input" - KindChoice = "choice" - KindProgress = "progress" - KindMarkdown = "markdown" - KindStream = "stream" + KindCard = "Card" + KindButton = "Button" + KindText = "Text" + KindRow = "Row" + KindColumn = "Column" + KindTextField = "TextField" + KindModal = "Modal" + KindTabs = "Tabs" + KindList = "List" ) -// Card is an A2UI card: a titled container with body content and optional -// action buttons. +// Accessibility carries A2UI accessibility attributes. The v0.9 spec defines +// this as an open object; we preserve it as raw JSON so we don't lose fields +// the renderer doesn't yet understand. +type Accessibility = json.RawMessage + +// Value is the A2UI "value" / data-binding type. A value may be a JSON +// literal (string, number, bool), a path binding ({"path": "..."}), or a +// function call ({"call": "...", "args": {...}}). We capture both the raw +// JSON (so renderers can re-serialize losslessly) and the decoded common +// forms. // -// TODO(a2tea): flesh out fields once the A2UI schema for cards is pinned -// down (title styling, body markdown vs plain, button variants, etc). +// Per the v0.9 spec, "value" on TextField etc. may be a literal or a binding; +// renderers should check IsPath / IsLiteral before reaching into Raw. +type Value struct { + // Raw is the original JSON, preserved verbatim. Always populated when + // the Value was decoded from JSON; renderers that need full fidelity + // (e.g. for echoing back in updateDataModel) should use this. + Raw json.RawMessage + + // Literal is the decoded JSON literal when the value is not an object + // (string, number, bool, null). For object values (path/call bindings) + // this is nil. + Literal any + + // Path is the binding path when the value is {"path": "..."}. Empty + // otherwise. + Path string + + // Call is the function name when the value is {"call": "...", "args": + // ...}. Empty otherwise. + Call string + + // CallArgs is the raw args object when Call is non-empty. + CallArgs json.RawMessage +} + +// IsPath reports whether this value is a path binding. +func (v Value) IsPath() bool { return v.Path != "" } + +// IsCall reports whether this value is a function-call binding. +func (v Value) IsCall() bool { return v.Call != "" } + +// IsLiteral reports whether this value is a JSON literal (string, number, +// bool, null) rather than a binding object. +func (v Value) IsLiteral() bool { return !v.IsPath() && !v.IsCall() } + +// UnmarshalJSON decodes a Value, distinguishing literal forms from binding +// objects. +func (v *Value) UnmarshalJSON(raw []byte) error { + v.Raw = append(v.Raw[:0], raw...) + + // Try as an object first. + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err == nil && obj != nil { + if p, ok := obj["path"]; ok { + if err := json.Unmarshal(p, &v.Path); err != nil { + return fmt.Errorf("a2tea/component: decode value.path: %w", err) + } + return nil + } + if c, ok := obj["call"]; ok { + if err := json.Unmarshal(c, &v.Call); err != nil { + return fmt.Errorf("a2tea/component: decode value.call: %w", err) + } + if a, ok := obj["args"]; ok { + v.CallArgs = append(v.CallArgs[:0], a...) + } + return nil + } + // Object with neither path nor call — store as literal-any. + var lit any + if err := json.Unmarshal(raw, &lit); err != nil { + return fmt.Errorf("a2tea/component: decode value object: %w", err) + } + v.Literal = lit + return nil + } + + // Fall back to literal. + var lit any + if err := json.Unmarshal(raw, &lit); err != nil { + return fmt.Errorf("a2tea/component: decode value literal: %w", err) + } + v.Literal = lit + return nil +} + +// MarshalJSON re-emits the value, preferring Raw when present so round-trips +// are lossless. +func (v Value) MarshalJSON() ([]byte, error) { + if len(v.Raw) > 0 { + return v.Raw, nil + } + if v.IsPath() { + return json.Marshal(struct { + Path string `json:"path"` + }{v.Path}) + } + if v.IsCall() { + return json.Marshal(struct { + Call string `json:"call"` + Args json.RawMessage `json:"args,omitempty"` + }{v.Call, v.CallArgs}) + } + return json.Marshal(v.Literal) +} + +// ChildrenSpec models the "children" property of List/Row/Column. In static +// form it is an array of component IDs; in template form it is an object +// {"path": "...", "componentId": "..."} that expands at render time against +// the data model. +type ChildrenSpec struct { + // Raw is the original JSON, preserved verbatim. + Raw json.RawMessage + + // IDs is populated when children is a static array of component IDs. + IDs []string + + // TemplatePath is populated when children is a template object. + TemplatePath string + + // TemplateComponentID is populated when children is a template object. + TemplateComponentID string +} + +// IsTemplate reports whether this children spec is a template expansion +// rather than a static list of IDs. +func (c ChildrenSpec) IsTemplate() bool { return c.TemplatePath != "" || c.TemplateComponentID != "" } + +// UnmarshalJSON decodes a ChildrenSpec, accepting either an array of strings +// or a {"path": ..., "componentId": ...} object. +func (c *ChildrenSpec) UnmarshalJSON(raw []byte) error { + c.Raw = append(c.Raw[:0], raw...) + + // Try array form first. + var ids []string + if err := json.Unmarshal(raw, &ids); err == nil { + c.IDs = ids + return nil + } + + // Try template-object form. + var tmpl struct { + Path string `json:"path"` + ComponentID string `json:"componentId"` + } + if err := json.Unmarshal(raw, &tmpl); err != nil { + return fmt.Errorf("a2tea/component: decode children: %w", err) + } + if tmpl.Path == "" && tmpl.ComponentID == "" { + return errors.New("a2tea/component: children must be an array of IDs or a {path, componentId} template object") + } + c.TemplatePath = tmpl.Path + c.TemplateComponentID = tmpl.ComponentID + return nil +} + +// MarshalJSON re-emits the children spec, preferring Raw for lossless +// round-trips. +func (c ChildrenSpec) MarshalJSON() ([]byte, error) { + if len(c.Raw) > 0 { + return c.Raw, nil + } + if c.IsTemplate() { + return json.Marshal(struct { + Path string `json:"path"` + ComponentID string `json:"componentId"` + }{c.TemplatePath, c.TemplateComponentID}) + } + if c.IDs == nil { + return []byte("[]"), nil + } + return json.Marshal(c.IDs) +} + +// Action models the action attached to a Button. The v0.9 spec defines this +// as an open object (typically {"event": {"name": "...", "context": {...}}}); +// we capture it as raw JSON because renderers/event routers handle the +// interpretation and we don't want to constrain the shape prematurely. +type Action = json.RawMessage + +// Card is a container with elevation/border and padding that wraps a single +// child component referenced by ID. type Card struct { - ID string `json:"id,omitempty"` - Title string `json:"title,omitempty"` - Body string `json:"body,omitempty"` - Buttons []Button `json:"buttons,omitempty"` + ID string `json:"id"` + Child string `json:"child"` + Accessibility Accessibility `json:"accessibility,omitempty"` + Weight *float64 `json:"weight,omitempty"` } // Kind implements Component. func (Card) Kind() string { return KindCard } -// Button is a clickable action embedded in a Card or Form. +// MarshalJSON adds the "component" discriminator. +func (c Card) MarshalJSON() ([]byte, error) { + type alias Card + return json.Marshal(struct { + Component string `json:"component"` + alias + }{KindCard, alias(c)}) +} + +// Button is a clickable button that triggers an Action. Its visible content +// is whichever component is referenced by Child. type Button struct { - ID string `json:"id"` - Label string `json:"label"` + ID string `json:"id"` + Child string `json:"child"` + Variant string `json:"variant,omitempty"` + Action Action `json:"action,omitempty"` + Accessibility Accessibility `json:"accessibility,omitempty"` + Weight *float64 `json:"weight,omitempty"` } -// Form is an A2UI form: an ordered collection of input fields plus a submit -// action. -// -// TODO(a2tea): decide whether Fields should be a typed slice of Input/Choice -// or a heterogenous []Component. The current shape assumes homogeneous -// inputs, which is wrong for real forms. -type Form struct { - ID string `json:"id,omitempty"` - Title string `json:"title,omitempty"` - Fields []Input `json:"fields,omitempty"` - Submit Button `json:"submit,omitempty"` +// Kind implements Component. +func (Button) Kind() string { return KindButton } + +// MarshalJSON adds the "component" discriminator. +func (b Button) MarshalJSON() ([]byte, error) { + type alias Button + return json.Marshal(struct { + Component string `json:"component"` + alias + }{KindButton, alias(b)}) +} + +// Text displays text content with an optional style variant. The text field +// is a Value because it may be a literal string or a data binding. +type Text struct { + ID string `json:"id"` + Text Value `json:"text"` + Variant string `json:"variant,omitempty"` + Accessibility Accessibility `json:"accessibility,omitempty"` + Weight *float64 `json:"weight,omitempty"` } // Kind implements Component. -func (Form) Kind() string { return KindForm } +func (Text) Kind() string { return KindText } + +// MarshalJSON adds the "component" discriminator. +func (t Text) MarshalJSON() ([]byte, error) { + type alias Text + return json.Marshal(struct { + Component string `json:"component"` + alias + }{KindText, alias(t)}) +} -// Input is a single-line text input. -type Input struct { - ID string `json:"id"` - Label string `json:"label,omitempty"` - Placeholder string `json:"placeholder,omitempty"` - Value string `json:"value,omitempty"` +// Row is a horizontal layout container. Its children are referenced by ID +// via Children (which may be a static list or a template). +type Row struct { + ID string `json:"id"` + Children ChildrenSpec `json:"children"` + Justify string `json:"justify,omitempty"` + Align string `json:"align,omitempty"` + Accessibility Accessibility `json:"accessibility,omitempty"` + Weight *float64 `json:"weight,omitempty"` } // Kind implements Component. -func (Input) Kind() string { return KindInput } +func (Row) Kind() string { return KindRow } -// Choice is a single-select picker over a list of options. -type Choice struct { - ID string `json:"id"` - Label string `json:"label,omitempty"` - Options []ChoiceOption `json:"options,omitempty"` +// MarshalJSON adds the "component" discriminator. +func (r Row) MarshalJSON() ([]byte, error) { + type alias Row + return json.Marshal(struct { + Component string `json:"component"` + alias + }{KindRow, alias(r)}) +} + +// Column is a vertical layout container. Same children semantics as Row. +type Column struct { + ID string `json:"id"` + Children ChildrenSpec `json:"children"` + Justify string `json:"justify,omitempty"` + Align string `json:"align,omitempty"` + Accessibility Accessibility `json:"accessibility,omitempty"` + Weight *float64 `json:"weight,omitempty"` } // Kind implements Component. -func (Choice) Kind() string { return KindChoice } +func (Column) Kind() string { return KindColumn } -// ChoiceOption is one option in a Choice. -type ChoiceOption struct { - Value string `json:"value"` - Label string `json:"label,omitempty"` +// MarshalJSON adds the "component" discriminator. +func (c Column) MarshalJSON() ([]byte, error) { + type alias Column + return json.Marshal(struct { + Component string `json:"component"` + alias + }{KindColumn, alias(c)}) } -// Progress is a determinate or indeterminate progress indicator. Percent is -// in [0.0, 1.0] when Indeterminate is false. -type Progress struct { - ID string `json:"id,omitempty"` - Label string `json:"label,omitempty"` - Percent float64 `json:"percent,omitempty"` - Indeterminate bool `json:"indeterminate,omitempty"` +// TextField is a text input with optional validation. Value is a Value +// because it is typically a data-model binding. +type TextField struct { + ID string `json:"id"` + Label string `json:"label"` + Value Value `json:"value"` + TextFieldType string `json:"textFieldType,omitempty"` + ValidationRegexp string `json:"validationRegexp,omitempty"` + Accessibility Accessibility `json:"accessibility,omitempty"` + Weight *float64 `json:"weight,omitempty"` } // Kind implements Component. -func (Progress) Kind() string { return KindProgress } +func (TextField) Kind() string { return KindTextField } + +// MarshalJSON adds the "component" discriminator. +func (t TextField) MarshalJSON() ([]byte, error) { + type alias TextField + return json.Marshal(struct { + Component string `json:"component"` + alias + }{KindTextField, alias(t)}) +} -// Markdown is a block of rendered markdown content. -type Markdown struct { - ID string `json:"id,omitempty"` - Source string `json:"source"` +// Modal is an overlay dialog. EntryPointChild is the component that triggers +// the modal (e.g. a Button); ContentChild is the dialog body. +type Modal struct { + ID string `json:"id"` + EntryPointChild string `json:"entryPointChild"` + ContentChild string `json:"contentChild"` + Accessibility Accessibility `json:"accessibility,omitempty"` + Weight *float64 `json:"weight,omitempty"` } // Kind implements Component. -func (Markdown) Kind() string { return KindMarkdown } +func (Modal) Kind() string { return KindModal } -// Stream represents an append-only stream of text chunks, the way an agent -// would emit a streaming response. -// -// TODO(a2tea): add a channel or callback for live chunk delivery; the static -// Chunks slice here only covers the "replay a finished stream" case. -type Stream struct { - ID string `json:"id,omitempty"` - Chunks []string `json:"chunks,omitempty"` +// MarshalJSON adds the "component" discriminator. +func (m Modal) MarshalJSON() ([]byte, error) { + type alias Modal + return json.Marshal(struct { + Component string `json:"component"` + alias + }{KindModal, alias(m)}) +} + +// TabItem is one entry in a Tabs component. +type TabItem struct { + Title string `json:"title"` + Child string `json:"child"` +} + +// Tabs is a tabbed interface that switches between named child panels. +type Tabs struct { + ID string `json:"id"` + TabItems []TabItem `json:"tabItems"` + Accessibility Accessibility `json:"accessibility,omitempty"` + Weight *float64 `json:"weight,omitempty"` } // Kind implements Component. -func (Stream) Kind() string { return KindStream } +func (Tabs) Kind() string { return KindTabs } -// ErrUnknownKind is returned by Unmarshal when the "kind" discriminator on -// the incoming JSON does not match any registered component type. -var ErrUnknownKind = errors.New("a2tea/component: unknown kind") +// MarshalJSON adds the "component" discriminator. +func (t Tabs) MarshalJSON() ([]byte, error) { + type alias Tabs + return json.Marshal(struct { + Component string `json:"component"` + alias + }{KindTabs, alias(t)}) +} -// Unmarshal decodes a raw A2UI JSON document into a concrete Component. -// -// TODO(a2tea): this is a stub. The real implementation needs to: -// - peek at the "kind" discriminator, -// - dispatch to the correct concrete type's UnmarshalJSON, -// - validate required fields per the A2UI schema, -// - and probably support a nested "children" array for container kinds. +// List is a scrollable list that supports both static children and template +// expansion against the data model. +type List struct { + ID string `json:"id"` + Children ChildrenSpec `json:"children"` + Direction string `json:"direction,omitempty"` + Align string `json:"align,omitempty"` + Accessibility Accessibility `json:"accessibility,omitempty"` + Weight *float64 `json:"weight,omitempty"` +} + +// Kind implements Component. +func (List) Kind() string { return KindList } + +// MarshalJSON adds the "component" discriminator. +func (l List) MarshalJSON() ([]byte, error) { + type alias List + return json.Marshal(struct { + Component string `json:"component"` + alias + }{KindList, alias(l)}) +} + +// ErrUnknownComponent is returned by Unmarshal when the "component" +// discriminator does not match any catalog entry a2tea supports. +var ErrUnknownComponent = errors.New("a2tea/component: unknown component type") + +// ErrMissingDiscriminator is returned by Unmarshal when the input has no +// "component" field. +var ErrMissingDiscriminator = errors.New("a2tea/component: missing \"component\" discriminator") + +// Unmarshal decodes a single A2UI v0.9 component object into the matching +// concrete Component type. The input is one entry from the +// updateComponents.components flat list — Unmarshal does NOT decode the +// surface-level envelope. // -// For now it recognizes the discriminator and returns a zero-valued struct -// of the right kind, which is enough to wire the rest of the pipeline. +// Errors are never swallowed: a malformed discriminator, an unknown +// component type, or a malformed payload all return a non-nil error. func Unmarshal(raw json.RawMessage) (Component, error) { if len(raw) == 0 { - return nil, nil + return nil, errors.New("a2tea/component: empty input") } var head struct { - Kind string `json:"kind"` + Component string `json:"component"` } if err := json.Unmarshal(raw, &head); err != nil { return nil, fmt.Errorf("a2tea/component: decode discriminator: %w", err) } - switch head.Kind { + if head.Component == "" { + return nil, ErrMissingDiscriminator + } + switch head.Component { case KindCard: var c Card - // TODO(a2tea): actually decode the rest of the document into c. - _ = json.Unmarshal(raw, &c) + if err := json.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("a2tea/component: decode Card: %w", err) + } + return c, nil + case KindButton: + var c Button + if err := json.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("a2tea/component: decode Button: %w", err) + } + return c, nil + case KindText: + var c Text + if err := json.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("a2tea/component: decode Text: %w", err) + } return c, nil - case KindForm: - var c Form - _ = json.Unmarshal(raw, &c) + case KindRow: + var c Row + if err := json.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("a2tea/component: decode Row: %w", err) + } return c, nil - case KindInput: - var c Input - _ = json.Unmarshal(raw, &c) + case KindColumn: + var c Column + if err := json.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("a2tea/component: decode Column: %w", err) + } return c, nil - case KindChoice: - var c Choice - _ = json.Unmarshal(raw, &c) + case KindTextField: + var c TextField + if err := json.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("a2tea/component: decode TextField: %w", err) + } return c, nil - case KindProgress: - var c Progress - _ = json.Unmarshal(raw, &c) + case KindModal: + var c Modal + if err := json.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("a2tea/component: decode Modal: %w", err) + } return c, nil - case KindMarkdown: - var c Markdown - _ = json.Unmarshal(raw, &c) + case KindTabs: + var c Tabs + if err := json.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("a2tea/component: decode Tabs: %w", err) + } return c, nil - case KindStream: - var c Stream - _ = json.Unmarshal(raw, &c) + case KindList: + var c List + if err := json.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("a2tea/component: decode List: %w", err) + } return c, nil default: - return nil, fmt.Errorf("%w: %q", ErrUnknownKind, head.Kind) + return nil, fmt.Errorf("%w: %q", ErrUnknownComponent, head.Component) } } diff --git a/component/component_test.go b/component/component_test.go new file mode 100644 index 0000000..0f21d47 --- /dev/null +++ b/component/component_test.go @@ -0,0 +1,461 @@ +package component_test + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/joestump/a2tea/component" +) + +// JSON snippets in this file are sourced from the A2UI v0.9 specification +// and component reference: +// +// - https://a2ui.org/specification/v0.9-a2ui/ (envelope + flow examples) +// - https://a2ui.org/reference/components/ (per-component property tables) +// +// Where the reference page shows a property fragment rather than a full +// object, we expand it into the smallest complete object that exercises the +// required fields. Snippets that mirror full examples from the spec are +// flagged with a comment. + +func TestUnmarshal_Catalog(t *testing.T) { + cases := []struct { + name string + json string + // check is given the decoded Component and should assert per-type + // fields. The Kind() assertion is done by the framework. + wantKind string + check func(t *testing.T, c component.Component) + }{ + { + // Card example (a2ui.org/reference/components/, Card row). + name: "Card", + wantKind: component.KindCard, + json: `{ + "id": "info-card", + "component": "Card", + "child": "card-content" + }`, + check: func(t *testing.T, c component.Component) { + card, ok := c.(component.Card) + if !ok { + t.Fatalf("want Card, got %T", c) + } + if card.ID != "info-card" { + t.Errorf("ID = %q, want %q", card.ID, "info-card") + } + if card.Child != "card-content" { + t.Errorf("Child = %q, want %q", card.Child, "card-content") + } + }, + }, + { + // Button example mirrors the spec's submit_button (v0.9 spec, + // "Component Definition" section). + name: "Button", + wantKind: component.KindButton, + json: `{ + "id": "submit_button", + "component": "Button", + "child": "submit_button_label", + "variant": "primary", + "action": { + "event": { + "name": "submitContactForm", + "context": {"formId": "contact_form_1"} + } + } + }`, + check: func(t *testing.T, c component.Component) { + btn, ok := c.(component.Button) + if !ok { + t.Fatalf("want Button, got %T", c) + } + if btn.ID != "submit_button" { + t.Errorf("ID = %q", btn.ID) + } + if btn.Variant != "primary" { + t.Errorf("Variant = %q", btn.Variant) + } + if len(btn.Action) == 0 { + t.Errorf("Action should be preserved as raw JSON") + } + }, + }, + { + // Text example (spec: user_name Text with literal text value). + name: "Text/literal", + wantKind: component.KindText, + json: `{ + "id": "user_name", + "component": "Text", + "text": "John Doe" + }`, + check: func(t *testing.T, c component.Component) { + txt, ok := c.(component.Text) + if !ok { + t.Fatalf("want Text, got %T", c) + } + if !txt.Text.IsLiteral() { + t.Errorf("Text.Text should be a literal, got path=%q call=%q", txt.Text.Path, txt.Text.Call) + } + if s, _ := txt.Text.Literal.(string); s != "John Doe" { + t.Errorf("Text literal = %v, want %q", txt.Text.Literal, "John Doe") + } + }, + }, + { + // Text example with a data-binding path (v0.9 spec illustrates + // path bindings throughout; this is the minimal form). + name: "Text/path-binding", + wantKind: component.KindText, + json: `{ + "id": "user_name", + "component": "Text", + "text": {"path": "/contact/firstName"}, + "variant": "h1" + }`, + check: func(t *testing.T, c component.Component) { + txt := c.(component.Text) + if !txt.Text.IsPath() { + t.Errorf("expected path binding") + } + if txt.Text.Path != "/contact/firstName" { + t.Errorf("path = %q", txt.Text.Path) + } + if txt.Variant != "h1" { + t.Errorf("variant = %q", txt.Variant) + } + }, + }, + { + // Row example (reference: toolbar Row with justify/align). + name: "Row", + wantKind: component.KindRow, + json: `{ + "id": "toolbar", + "component": "Row", + "children": ["btn1", "btn2"], + "justify": "spaceBetween", + "align": "center" + }`, + check: func(t *testing.T, c component.Component) { + row := c.(component.Row) + if row.Children.IsTemplate() { + t.Errorf("Row.Children should be static IDs, not template") + } + if got := row.Children.IDs; len(got) != 2 || got[0] != "btn1" || got[1] != "btn2" { + t.Errorf("Children.IDs = %v", got) + } + if row.Justify != "spaceBetween" || row.Align != "center" { + t.Errorf("justify=%q align=%q", row.Justify, row.Align) + } + }, + }, + { + // Column example (spec: form_container Column with three + // children). + name: "Column", + wantKind: component.KindColumn, + json: `{ + "id": "form_container", + "component": "Column", + "children": ["header_row", "name_row", "submit_row"] + }`, + check: func(t *testing.T, c component.Component) { + col := c.(component.Column) + if len(col.Children.IDs) != 3 { + t.Errorf("len(Children.IDs) = %d, want 3", len(col.Children.IDs)) + } + }, + }, + { + // TextField example (spec: first_name_field with path-bound + // value). + name: "TextField", + wantKind: component.KindTextField, + json: `{ + "id": "first_name_field", + "component": "TextField", + "label": "First Name", + "value": {"path": "/contact/firstName"}, + "textFieldType": "shortText" + }`, + check: func(t *testing.T, c component.Component) { + tf := c.(component.TextField) + if tf.Label != "First Name" { + t.Errorf("label = %q", tf.Label) + } + if !tf.Value.IsPath() || tf.Value.Path != "/contact/firstName" { + t.Errorf("value path = %q (isPath=%v)", tf.Value.Path, tf.Value.IsPath()) + } + if tf.TextFieldType != "shortText" { + t.Errorf("textFieldType = %q", tf.TextFieldType) + } + }, + }, + { + // Modal example (reference: confirmation-modal with entry-point + // and content child IDs). + name: "Modal", + wantKind: component.KindModal, + json: `{ + "id": "confirmation-modal", + "component": "Modal", + "entryPointChild": "open-modal-btn", + "contentChild": "modal-body" + }`, + check: func(t *testing.T, c component.Component) { + m := c.(component.Modal) + if m.EntryPointChild != "open-modal-btn" || m.ContentChild != "modal-body" { + t.Errorf("entry=%q content=%q", m.EntryPointChild, m.ContentChild) + } + }, + }, + { + // Tabs example (reference: settings-tabs with two tabItems). + name: "Tabs", + wantKind: component.KindTabs, + json: `{ + "id": "settings-tabs", + "component": "Tabs", + "tabItems": [ + {"title": "General", "child": "general-tab"}, + {"title": "Advanced", "child": "advanced-tab"} + ] + }`, + check: func(t *testing.T, c component.Component) { + tabs := c.(component.Tabs) + if len(tabs.TabItems) != 2 { + t.Fatalf("len(TabItems) = %d, want 2", len(tabs.TabItems)) + } + if tabs.TabItems[0].Title != "General" || tabs.TabItems[0].Child != "general-tab" { + t.Errorf("TabItems[0] = %+v", tabs.TabItems[0]) + } + if tabs.TabItems[1].Title != "Advanced" || tabs.TabItems[1].Child != "advanced-tab" { + t.Errorf("TabItems[1] = %+v", tabs.TabItems[1]) + } + }, + }, + { + // List/static example (reference: message-list with direction + // vertical and static child IDs). + name: "List/static", + wantKind: component.KindList, + json: `{ + "id": "message-list", + "component": "List", + "children": ["msg-1", "msg-2", "msg-3"], + "direction": "vertical" + }`, + check: func(t *testing.T, c component.Component) { + l := c.(component.List) + if l.Direction != "vertical" { + t.Errorf("direction = %q", l.Direction) + } + if l.Children.IsTemplate() { + t.Errorf("should be static") + } + if len(l.Children.IDs) != 3 { + t.Errorf("len(Children.IDs) = %d", len(l.Children.IDs)) + } + }, + }, + { + // List/template example (spec: employee_list with template + // children that expand against /employees). + name: "List/template", + wantKind: component.KindList, + json: `{ + "id": "employee_list", + "component": "List", + "children": { + "path": "/employees", + "componentId": "employee_card_template" + } + }`, + check: func(t *testing.T, c component.Component) { + l := c.(component.List) + if !l.Children.IsTemplate() { + t.Errorf("should be template") + } + if l.Children.TemplatePath != "/employees" { + t.Errorf("template path = %q", l.Children.TemplatePath) + } + if l.Children.TemplateComponentID != "employee_card_template" { + t.Errorf("template componentId = %q", l.Children.TemplateComponentID) + } + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, err := component.Unmarshal(json.RawMessage(tc.json)) + if err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if c.Kind() != tc.wantKind { + t.Errorf("Kind() = %q, want %q", c.Kind(), tc.wantKind) + } + if tc.check != nil { + tc.check(t, c) + } + }) + } +} + +func TestUnmarshal_UnknownComponent(t *testing.T) { + raw := json.RawMessage(`{"id": "x", "component": "Hologram"}`) + _, err := component.Unmarshal(raw) + if err == nil { + t.Fatal("expected error for unknown component type, got nil") + } + if !errors.Is(err, component.ErrUnknownComponent) { + t.Errorf("want errors.Is ErrUnknownComponent, got %v", err) + } + if !strings.Contains(err.Error(), "Hologram") { + t.Errorf("error should mention the unknown type, got %q", err.Error()) + } +} + +func TestUnmarshal_MissingDiscriminator(t *testing.T) { + raw := json.RawMessage(`{"id": "x", "child": "y"}`) + _, err := component.Unmarshal(raw) + if err == nil { + t.Fatal("expected error for missing discriminator") + } + if !errors.Is(err, component.ErrMissingDiscriminator) { + t.Errorf("want errors.Is ErrMissingDiscriminator, got %v", err) + } +} + +func TestUnmarshal_MalformedJSON(t *testing.T) { + cases := []struct { + name string + raw string + }{ + {"empty", ``}, + {"trailing-garbage", `{"component": "Card", "id": "x"} oops`}, + {"unclosed-object", `{"component": "Card"`}, + {"discriminator-not-string", `{"component": 42}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := component.Unmarshal(json.RawMessage(tc.raw)) + if err == nil { + t.Fatalf("expected error for malformed input %q", tc.raw) + } + }) + } +} + +func TestUnmarshal_MalformedComponentPayload(t *testing.T) { + // "component" matches a known type but the rest of the body is wrong: + // children must be either an array of strings or a template object. + raw := json.RawMessage(`{"id": "x", "component": "Row", "children": 42}`) + _, err := component.Unmarshal(raw) + if err == nil { + t.Fatal("expected error for malformed Row.children") + } + if !strings.Contains(err.Error(), "Row") { + t.Errorf("error should mention component type, got %q", err.Error()) + } +} + +// TestNested verifies that the flat adjacency-list shape works end-to-end: +// a Card refers to a Column by ID, the Column refers to two Buttons by ID, +// and each Button refers to a Text label by ID. We decode all five entries +// out of the same components array and assert the IDs line up. +func TestNested_CardColumnButtons(t *testing.T) { + // Modeled on the v0.9 spec's adjacency-list updateComponents payload. + // Source: https://a2ui.org/specification/v0.9-a2ui/ + doc := `[ + {"id": "root", "component": "Card", "child": "form_container"}, + {"id": "form_container", "component": "Column", "children": ["ok_button", "cancel_button"]}, + {"id": "ok_button", "component": "Button", "child": "ok_label", "variant": "primary"}, + {"id": "cancel_button", "component": "Button", "child": "cancel_label"}, + {"id": "ok_label", "component": "Text", "text": "OK"}, + {"id": "cancel_label", "component": "Text", "text": "Cancel"} + ]` + + var entries []json.RawMessage + if err := json.Unmarshal([]byte(doc), &entries); err != nil { + t.Fatalf("decode adjacency list: %v", err) + } + + byID := make(map[string]component.Component, len(entries)) + for _, raw := range entries { + c, err := component.Unmarshal(raw) + if err != nil { + t.Fatalf("Unmarshal entry %s: %v", string(raw), err) + } + // Each concrete struct has an ID field; pull it out via a tiny + // inner decode rather than a type switch. + var head struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &head); err != nil { + t.Fatalf("decode id: %v", err) + } + byID[head.ID] = c + } + + root, ok := byID["root"].(component.Card) + if !ok { + t.Fatalf("root is not a Card: %T", byID["root"]) + } + if root.Child != "form_container" { + t.Fatalf("root.Child = %q", root.Child) + } + + col, ok := byID[root.Child].(component.Column) + if !ok { + t.Fatalf("form_container is not a Column: %T", byID[root.Child]) + } + if got := col.Children.IDs; len(got) != 2 || got[0] != "ok_button" || got[1] != "cancel_button" { + t.Fatalf("column.children = %v", got) + } + + for _, btnID := range col.Children.IDs { + btn, ok := byID[btnID].(component.Button) + if !ok { + t.Fatalf("%s is not a Button: %T", btnID, byID[btnID]) + } + label, ok := byID[btn.Child].(component.Text) + if !ok { + t.Fatalf("%s.child %q is not a Text: %T", btnID, btn.Child, byID[btn.Child]) + } + if !label.Text.IsLiteral() { + t.Errorf("label %q should be a literal", btn.Child) + } + } + + if got, want := byID["ok_button"].(component.Button).Variant, "primary"; got != want { + t.Errorf("ok_button variant = %q, want %q", got, want) + } +} + +// TestKindConstants is a smoke test that every Kind constant matches the +// PascalCase string the v0.9 spec defines. If anyone renames a constant or +// drifts away from the spec values, this catches it. +func TestKindConstants(t *testing.T) { + cases := map[string]string{ + component.KindCard: "Card", + component.KindButton: "Button", + component.KindText: "Text", + component.KindRow: "Row", + component.KindColumn: "Column", + component.KindTextField: "TextField", + component.KindModal: "Modal", + component.KindTabs: "Tabs", + component.KindList: "List", + } + for got, want := range cases { + if got != want { + t.Errorf("Kind constant %q != spec value %q", got, want) + } + } +} diff --git a/examples/hello/sample.json b/examples/hello/sample.json index 5050c0b..44e6fd2 100644 --- a/examples/hello/sample.json +++ b/examples/hello/sample.json @@ -1,12 +1,17 @@ { - "kind": "card", - "id": "hello-card", - "title": "Hello from A2UI", - "body": "This card is rendered by a2tea. Press any key to quit.", - "buttons": [ - { - "id": "ok", - "label": "OK" - } - ] + "version": "v0.9", + "updateComponents": { + "surfaceId": "hello_surface", + "components": [ + {"id": "root", "component": "Card", "child": "body"}, + {"id": "body", "component": "Column", "children": ["greeting", "subtitle", "actions"]}, + {"id": "greeting", "component": "Text", "text": "Hello from A2UI", "variant": "h1"}, + {"id": "subtitle", "component": "Text", "text": "This surface is rendered by a2tea.", "variant": "body"}, + {"id": "actions", "component": "Row", "children": ["ok_button", "cancel_button"], "justify": "spaceBetween"}, + {"id": "ok_button", "component": "Button", "child": "ok_label", "variant": "primary", "action": {"event": {"name": "dismiss"}}}, + {"id": "cancel_button", "component": "Button", "child": "cancel_label"}, + {"id": "ok_label", "component": "Text", "text": "OK"}, + {"id": "cancel_label", "component": "Text", "text": "Cancel"} + ] + } }