xtablo-source/backend/internal/web/ui/ui_test.go

466 lines
15 KiB
Go

package ui
import (
"bytes"
"context"
"io"
"strings"
"testing"
"github.com/a-h/templ"
)
// render is a tiny helper that renders a templ.Component to a string for
// substring assertions. Uses context.Background to match production calls.
func render(t *testing.T, ctx context.Context, c templ.Component) string {
t.Helper()
var buf bytes.Buffer
if err := c.Render(ctx, &buf); err != nil {
t.Fatalf("render: unexpected error: %v", err)
}
return buf.String()
}
func TestButton_DefaultSolidMD(t *testing.T) {
out := render(t, context.Background(), Button(ButtonProps{Label: "Fetch server time"}))
// Plan 02: multi-class assertions (one check per class token)
for _, wantClass := range []string{
`ui-button`,
`ui-button-solid`,
`ui-button-default`,
`ui-button-md`,
} {
if !strings.Contains(out, wantClass) {
t.Errorf("output missing class %q\ngot: %s", wantClass, out)
}
}
if !strings.Contains(out, "Fetch server time") {
t.Errorf("output missing label literal; got: %s", out)
}
if !strings.Contains(out, `type="button"`) {
t.Errorf("output missing default type=\"button\"; got: %s", out)
}
}
func TestButton_PassesThroughAttrs(t *testing.T) {
props := ButtonProps{
Label: "x",
Attrs: templ.Attributes{
"hx-get": "/demo/time",
"hx-target": "#demo-out",
},
}
out := render(t, context.Background(), Button(props))
if !strings.Contains(out, `hx-get="/demo/time"`) {
t.Errorf("output missing hx-get; got: %s", out)
}
if !strings.Contains(out, `hx-target="#demo-out"`) {
t.Errorf("output missing hx-target; got: %s", out)
}
}
func TestButton_ExplicitTypeSubmit(t *testing.T) {
out := render(t, context.Background(), Button(ButtonProps{Label: "Go", Type: "submit"}))
if !strings.Contains(out, `type="submit"`) {
t.Errorf("expected type=\"submit\"; got: %s", out)
}
}
func TestBadge_InfoVariant(t *testing.T) {
out := render(t, context.Background(), Badge(BadgeProps{Label: "OK", Variant: BadgeVariantInfo}))
wantClass := `class="ui-badge ui-badge-info"`
if !strings.Contains(out, wantClass) {
t.Errorf("output missing %q; got: %s", wantClass, out)
}
if !strings.Contains(out, "OK") {
t.Errorf("output missing label; got: %s", out)
}
}
func TestBadge_SuccessVariant(t *testing.T) {
out := render(t, context.Background(), Badge(BadgeProps{Label: "OK", Variant: BadgeVariantSuccess}))
if !strings.Contains(out, "ui-badge-success") {
t.Errorf("output missing ui-badge-success; got: %s", out)
}
}
func TestBadge_ZeroValueDefaultsToInfo(t *testing.T) {
out := render(t, context.Background(), Badge(BadgeProps{Label: "OK"}))
if !strings.Contains(out, "ui-badge-info") {
t.Errorf("zero-value Variant should normalize to info; got: %s", out)
}
}
func TestButtonClass_String(t *testing.T) {
got := ButtonClass(ButtonVariantDefault, ButtonToneSolid, SizeMD)
want := "ui-button ui-button-solid ui-button-default ui-button-md"
if got != want {
t.Errorf("ButtonClass = %q; want %q", got, want)
}
}
func TestBadgeClass_String(t *testing.T) {
got := BadgeClass(BadgeVariantInfo)
want := "ui-badge ui-badge-info"
if got != want {
t.Errorf("BadgeClass = %q; want %q", got, want)
}
}
func TestNormalizers_ZeroValueDefaults(t *testing.T) {
if got := NormalizedSize(""); got != SizeMD {
t.Errorf("NormalizedSize(\"\") = %q; want %q", got, SizeMD)
}
if got := NormalizedButtonVariant(""); got != ButtonVariantDefault {
t.Errorf("NormalizedButtonVariant(\"\") = %q; want %q", got, ButtonVariantDefault)
}
if got := NormalizedButtonTone(""); got != ButtonToneSolid {
t.Errorf("NormalizedButtonTone(\"\") = %q; want %q", got, ButtonToneSolid)
}
if got := NormalizedBadgeVariant(""); got != BadgeVariantInfo {
t.Errorf("NormalizedBadgeVariant(\"\") = %q; want %q", got, BadgeVariantInfo)
}
}
// Phase 13 Plan 01 — new enum and class function tests (TDD RED)
func TestButtonVariantGhost_Normalizer(t *testing.T) {
got := NormalizedButtonVariant(ButtonVariantGhost)
if got != ButtonVariantGhost {
t.Errorf("NormalizedButtonVariant(ButtonVariantGhost) = %q; want %q", got, ButtonVariantGhost)
}
}
func TestButtonClass_GhostVariant(t *testing.T) {
got := ButtonClass(ButtonVariantGhost, ButtonToneSolid, SizeMD)
// Plan 02: ghost variant omits the tone class and emits "ui-button-ghost" standalone.
want := "ui-button ui-button-ghost ui-button-md"
if got != want {
t.Errorf("ButtonClass(Ghost, Solid, MD) = %q; want %q", got, want)
}
}
func TestBadgeVariantPrimary_Normalizer(t *testing.T) {
got := NormalizedBadgeVariant(BadgeVariantPrimary)
if got != BadgeVariantPrimary {
t.Errorf("NormalizedBadgeVariant(BadgeVariantPrimary) = %q; want %q", got, BadgeVariantPrimary)
}
}
func TestBadgeClass_PrimaryVariant(t *testing.T) {
got := BadgeClass(BadgeVariantPrimary)
want := "ui-badge ui-badge-primary"
if got != want {
t.Errorf("BadgeClass(BadgeVariantPrimary) = %q; want %q", got, want)
}
}
func TestIconButtonClass_GhostNeutral(t *testing.T) {
got := IconButtonClass(IconButtonVariantNeutral, IconButtonToneGhost)
if !strings.Contains(got, "borderless-icon-button") {
t.Errorf("IconButtonClass(Neutral, Ghost) = %q; want to contain \"borderless-icon-button\"", got)
}
}
func TestIconButtonClass_SolidNeutral(t *testing.T) {
got := IconButtonClass(IconButtonVariantNeutral, IconButtonToneSolid)
if !strings.Contains(got, "ui-icon-button-solid") {
t.Errorf("IconButtonClass(Neutral, Solid) = %q; want to contain \"ui-icon-button-solid\"", got)
}
}
func TestSpaceXClass_MD(t *testing.T) {
got := SpaceXClass(SpacingStepMD)
want := "ui-space-x ui-space-x-md"
if got != want {
t.Errorf("SpaceXClass(SpacingStepMD) = %q; want %q", got, want)
}
}
func TestSpaceYClass_LG(t *testing.T) {
got := SpaceYClass(SpacingStepLG)
want := "ui-space-y ui-space-y-lg"
if got != want {
t.Errorf("SpaceYClass(SpacingStepLG) = %q; want %q", got, want)
}
}
// textComponent is a test helper that wraps a raw string as a templ.Component,
// used for asserting typed CardProps fields (Header/Body/Footer).
func textComponent(text string) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
_, err := w.Write([]byte(text))
return err
})
}
// Phase 13 Plan 02 — typed Card API and Badge primary variant tests (TDD RED)
func TestCard_RendersTypedRegions(t *testing.T) {
out := render(t, context.Background(), Card(CardProps{
Header: textComponent("header"),
Body: textComponent("body"),
}))
for _, want := range []string{
`ui-card-header`,
`header`,
`ui-card-body`,
`body`,
} {
if !strings.Contains(out, want) {
t.Fatalf("expected %q in %q", want, out)
}
}
// nil footer must not emit ui-card-footer
if strings.Contains(out, "ui-card-footer") {
t.Errorf("expected no ui-card-footer when Footer is nil; got: %s", out)
}
}
func TestBadge_PrimaryVariant(t *testing.T) {
out := render(t, context.Background(), Badge(BadgeProps{Label: "new", Variant: BadgeVariantPrimary}))
if !strings.Contains(out, "ui-badge-primary") {
t.Errorf("expected ui-badge-primary in output; got: %s", out)
}
}
// Phase 13 Plan 03 — Input and Textarea component tests (TDD RED)
func TestInput_DefaultType(t *testing.T) {
out := render(t, context.Background(), Input(InputProps{Name: "x"}))
if !strings.Contains(out, `type="text"`) {
t.Errorf("expected type=\"text\" for empty Type; got: %s", out)
}
if !strings.Contains(out, `class="ui-input"`) {
t.Errorf("expected class=\"ui-input\"; got: %s", out)
}
}
func TestInput_EmailType(t *testing.T) {
out := render(t, context.Background(), Input(InputProps{Name: "email", Type: "email"}))
if !strings.Contains(out, `type="email"`) {
t.Errorf("expected type=\"email\"; got: %s", out)
}
}
func TestInput_IDFallback(t *testing.T) {
out := render(t, context.Background(), Input(InputProps{Name: "email"}))
if !strings.Contains(out, `id="email"`) {
t.Errorf("expected id=\"email\" (fallback from name); got: %s", out)
}
}
func TestInput_ExplicitID(t *testing.T) {
out := render(t, context.Background(), Input(InputProps{ID: "my-id", Name: "x"}))
if !strings.Contains(out, `id="my-id"`) {
t.Errorf("expected id=\"my-id\"; got: %s", out)
}
}
func TestTextarea_RendersClass(t *testing.T) {
out := render(t, context.Background(), Textarea(TextareaProps{Name: "body"}))
if !strings.Contains(out, `class="ui-textarea"`) {
t.Errorf("expected class=\"ui-textarea\"; got: %s", out)
}
}
func TestTextarea_DefaultRows(t *testing.T) {
out := render(t, context.Background(), Textarea(TextareaProps{Name: "x", Rows: 0}))
if !strings.Contains(out, `rows="4"`) {
t.Errorf("expected rows=\"4\" for zero Rows; got: %s", out)
}
}
func TestTextarea_ExplicitRows(t *testing.T) {
out := render(t, context.Background(), Textarea(TextareaProps{Name: "x", Rows: 6}))
if !strings.Contains(out, `rows="6"`) {
t.Errorf("expected rows=\"6\"; got: %s", out)
}
}
// Phase 13 Plan 03 — Select and FormField component tests (TDD RED)
func TestSelect_RendersControl(t *testing.T) {
out := render(t, context.Background(), Select(SelectProps{
Name: "status",
Options: []SelectOption{{Value: "a", Label: "Alpha"}},
}))
if !strings.Contains(out, "ui-select-control") {
t.Errorf("expected ui-select-control in output; got: %s", out)
}
}
func TestSelect_HasInlineScript(t *testing.T) {
out := render(t, context.Background(), Select(SelectProps{
Name: "status",
Options: []SelectOption{{Value: "a", Label: "Alpha"}},
}))
if !strings.Contains(out, "__uiSelectInitAll") {
t.Errorf("expected __uiSelectInitAll in output (inline JS); got: %s", out)
}
}
func TestSelect_HasHtmxListener(t *testing.T) {
out := render(t, context.Background(), Select(SelectProps{
Name: "status",
Options: []SelectOption{{Value: "a", Label: "Alpha"}},
}))
if !strings.Contains(out, "htmx:afterSwap") {
t.Errorf("expected htmx:afterSwap in output (re-init listener); got: %s", out)
}
}
func TestFormField_RendersLabel(t *testing.T) {
out := render(t, context.Background(), FormField(FormFieldProps{
Label: "Name",
For: "name-input",
}))
for _, want := range []string{"ui-form-field", "ui-form-label"} {
if !strings.Contains(out, want) {
t.Errorf("expected %q in output; got: %s", want, out)
}
}
}
func TestFormField_RendersError(t *testing.T) {
out := render(t, context.Background(), FormField(FormFieldProps{
Label: "Name",
For: "name-input",
Error: "This field is required",
}))
if !strings.Contains(out, "ui-form-error") {
t.Errorf("expected ui-form-error in output when Error is set; got: %s", out)
}
}
func TestFormField_NoErrorWhenEmpty(t *testing.T) {
out := render(t, context.Background(), FormField(FormFieldProps{
Label: "Name",
For: "name-input",
Error: "",
}))
if strings.Contains(out, "ui-form-error") {
t.Errorf("expected NO ui-form-error in output when Error is empty; got: %s", out)
}
}
// Phase 13 Plan 04 — Modal, EmptyState, Table component tests (TDD RED)
func TestModal_RendersBackdropAndPanel(t *testing.T) {
out := render(t, context.Background(), Modal(ModalProps{Title: "Confirm action"}))
for _, want := range []string{"ui-modal-backdrop", "ui-modal-panel"} {
if !strings.Contains(out, want) {
t.Errorf("expected %q in output; got: %s", want, out)
}
}
}
func TestModal_RendersTitle(t *testing.T) {
out := render(t, context.Background(), Modal(ModalProps{Title: "Confirm action"}))
// Title must appear inside a heading element
if !strings.Contains(out, "<h2>") || !strings.Contains(out, "Confirm action") {
t.Errorf("expected <h2> with title text in output; got: %s", out)
}
}
func TestModal_NilBodyOmitted(t *testing.T) {
out := render(t, context.Background(), Modal(ModalProps{Title: "Confirm action"}))
if strings.Contains(out, "ui-modal-body") {
t.Errorf("expected NO ui-modal-body when Body is nil; got: %s", out)
}
}
func TestEmptyState_RendersTitle(t *testing.T) {
out := render(t, context.Background(), EmptyState(EmptyStateProps{Title: "Nothing here yet"}))
for _, want := range []string{"ui-empty-state", "Nothing here yet"} {
if !strings.Contains(out, want) {
t.Errorf("expected %q in output; got: %s", want, out)
}
}
}
func TestEmptyState_NilIconOmitted(t *testing.T) {
out := render(t, context.Background(), EmptyState(EmptyStateProps{Title: "Nothing here yet"}))
if strings.Contains(out, "ui-empty-state-icon") {
t.Errorf("expected NO ui-empty-state-icon when Icon is nil; got: %s", out)
}
}
func TestTable_RendersShell(t *testing.T) {
out := render(t, context.Background(), Table(TableProps{}))
for _, want := range []string{"ui-table-shell", "ui-table"} {
if !strings.Contains(out, want) {
t.Errorf("expected %q in output; got: %s", want, out)
}
}
}
// Phase 13 Plan 04 — IconButton, UIIcon, Space, and Button icon wiring tests (TDD RED)
func TestIconButton_GhostNeutral(t *testing.T) {
out := render(t, context.Background(), IconButton(IconButtonProps{
Icon: "plus",
Variant: IconButtonVariantNeutral,
Tone: IconButtonToneGhost,
Label: "Add",
}))
if !strings.Contains(out, "borderless-icon-button") {
t.Errorf("expected borderless-icon-button class; got: %s", out)
}
if !strings.Contains(out, `aria-label="Add"`) {
t.Errorf("expected aria-label=\"Add\"; got: %s", out)
}
}
func TestIconButton_SolidDanger(t *testing.T) {
out := render(t, context.Background(), IconButton(IconButtonProps{
Icon: "trash",
Variant: IconButtonVariantDanger,
Tone: IconButtonToneSolid,
Label: "Delete",
}))
if !strings.Contains(out, "ui-icon-button-solid") {
t.Errorf("expected ui-icon-button-solid class; got: %s", out)
}
if !strings.Contains(out, "ui-icon-button-danger") {
t.Errorf("expected ui-icon-button-danger class; got: %s", out)
}
}
func TestUIIcon_Plus(t *testing.T) {
out := render(t, context.Background(), UIIcon("plus"))
if !strings.Contains(out, "<svg") {
t.Errorf("expected <svg in output for 'plus' icon; got: %s", out)
}
}
func TestUIIcon_Fallback(t *testing.T) {
out := render(t, context.Background(), UIIcon("unknown-kind"))
if !strings.Contains(out, "<span") {
t.Errorf("expected <span fallback for unknown icon kind; got: %s", out)
}
}
func TestSpaceX_MD(t *testing.T) {
out := render(t, context.Background(), SpaceX(SpaceProps{Size: SpacingStepMD}))
if !strings.Contains(out, "ui-space-x-md") {
t.Errorf("expected ui-space-x-md in output; got: %s", out)
}
}
func TestSpaceY_LG(t *testing.T) {
out := render(t, context.Background(), SpaceY(SpaceProps{Size: SpacingStepLG}))
if !strings.Contains(out, "ui-space-y-lg") {
t.Errorf("expected ui-space-y-lg in output; got: %s", out)
}
}
func TestButton_IconRendered(t *testing.T) {
out := render(t, context.Background(), Button(ButtonProps{Label: "Add", Icon: "plus"}))
if !strings.Contains(out, "<svg") {
t.Errorf("expected <svg in output when Button.Icon is set; got: %s", out)
}
}