xtablo-source/go-backend/internal/web/ui/ui_test.go
Arthur Belleville 9a92f358e8
Add task management features with database schema and handlers
Create a new tasks module with full CRUD operations, supporting both
regular tasks and etapes (phases). Implements task hierarchy with
parent-child relationships, assignees, and due dates. Includes database
schema with validation triggers, SQLC query generation, in-memory
repository implementation, HTTP handlers, view templates, and
comprehensive test coverage.
2026-05-10 21:58:48 +02:00

482 lines
10 KiB
Go

package ui
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/a-h/templ"
)
func TestButtonRendersDefaultSolidMediumMarkup(t *testing.T) {
component := Button(ButtonProps{
Label: "Nouveau projet",
Variant: ButtonVariantDefault,
Size: SizeMD,
Type: "button",
})
html := renderToString(t, component)
for _, want := range []string{
`type="button"`,
`Nouveau projet`,
`ui-button`,
`ui-button-solid`,
`ui-button-default`,
`ui-button-md`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestIconButtonRendersBorderlessDestructiveMarkup(t *testing.T) {
component := IconButton(IconButtonProps{
Label: "Supprimer le projet",
Icon: "trash",
Variant: IconButtonVariantDanger,
Tone: IconButtonToneGhost,
Type: "button",
})
html := renderToString(t, component)
for _, want := range []string{
`type="button"`,
`aria-label="Supprimer le projet"`,
`borderless-icon-button`,
`ui-icon-button-ghost`,
`ui-icon-button-danger`,
`lucide-trash2`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestIconButtonRendersBorderlessNeutralMarkup(t *testing.T) {
component := IconButton(IconButtonProps{
Label: "Modifier le projet",
Icon: "pencil",
Variant: IconButtonVariantNeutral,
Tone: IconButtonToneGhost,
Type: "button",
})
html := renderToString(t, component)
for _, want := range []string{
`type="button"`,
`aria-label="Modifier le projet"`,
`borderless-icon-button`,
`ui-icon-button-ghost`,
`ui-icon-button-neutral`,
`M12 20h9`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestBadgeRendersSemanticStatusVariant(t *testing.T) {
component := Badge(BadgeProps{
Label: "En cours",
Variant: BadgeVariantWarning,
})
html := renderToString(t, component)
for _, want := range []string{
`ui-badge`,
`ui-badge-warning`,
`En cours`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestSelectRendersSingleSelectMarkup(t *testing.T) {
component := Select(SelectProps{
ID: "project-status",
Name: "status",
Placeholder: "Select a status",
Value: "in-progress",
Options: []SelectOption{
{Value: "todo", Label: "To do"},
{Value: "in-progress", Label: "In progress"},
{Value: "done", Label: "Done"},
},
})
html := renderToString(t, component)
for _, want := range []string{
`class="ui-select"`,
`id="project-status"`,
`name="status"`,
`class="ui-select-native"`,
`class="ui-select-control"`,
`class="ui-select-value-wrapper"`,
`class="ui-select-arrow-zone"`,
`class="ui-select-arrow-icon"`,
`value="in-progress" selected`,
`data-ui-select-root`,
`data-ui-select-label`,
`Select a status`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestSelectRendersMultiSelectMarkup(t *testing.T) {
component := Select(SelectProps{
Name: "assignee_ids",
Placeholder: "Select multiple values",
Multiple: true,
Values: []string{"u_1", "u_2"},
Options: []SelectOption{
{Value: "u_1", Label: "Alice"},
{Value: "u_2", Label: "Bob"},
{Value: "u_3", Label: "Charlie"},
},
})
html := renderToString(t, component)
for _, want := range []string{
`multiple`,
`data-ui-select-multiple="true"`,
`data-placeholder="Select multiple values"`,
`data-selected-label="Alice, Bob"`,
`value="u_1" selected`,
`value="u_2" selected`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestModalRendersShellStructure(t *testing.T) {
component := Modal(ModalProps{
Title: "Nouveau projet",
Body: textComponent("Body copy"),
Actions: textComponent("Actions"),
})
html := renderToString(t, component)
for _, want := range []string{
`ui-modal-backdrop`,
`ui-modal-panel`,
`Nouveau projet`,
`Body copy`,
`Actions`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestSpaceXRendersDefaultMediumMarkup(t *testing.T) {
component := SpaceX(SpaceProps{})
html := renderToString(t, component)
for _, want := range []string{
`aria-hidden="true"`,
`ui-space-x`,
`ui-space-x-md`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestSpaceYRendersExplicitExtraLargeMarkup(t *testing.T) {
component := SpaceY(SpaceProps{Size: SpacingStepXL})
html := renderToString(t, component)
for _, want := range []string{
`aria-hidden="true"`,
`ui-space-y`,
`ui-space-y-xl`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestButtonUsesSharedTokenBackedClasses(t *testing.T) {
component := Button(ButtonProps{
Label: "Create",
Variant: ButtonVariantDefault,
Size: SizeSM,
Type: "button",
})
html := renderToString(t, component)
for _, want := range []string{
`ui-button`,
`ui-button-solid`,
`ui-button-default`,
`ui-button-sm`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestSharedSemanticClassesExistInStylesheet(t *testing.T) {
cssPath := filepath.Join("..", "..", "..", "static", "styles.css")
body, err := os.ReadFile(cssPath)
if err != nil {
t.Fatalf("read stylesheet: %v", err)
}
css := string(body)
for _, want := range []string{
`Code generated by cmd/buildstyles`,
`--color-text-primary`,
`--color-surface-default`,
`--color-status-warning-soft-bg`,
`--shadow-surface-md`,
`--overlay-backdrop-default`,
`.ui-button-solid.ui-button-default`,
`.ui-badge-warning`,
`.ui-card`,
`.catalog-page`,
`.ui-button-sm`,
`.ui-modal-panel`,
`.ui-space-x-md`,
`.ui-space-y-md`,
`.borderless-icon-button`,
`.ui-select-control`,
`.ui-select-native`,
`.ui-select-chip`,
`.ui-icon-button-solid.ui-icon-button-neutral`,
`.ui-icon-button-ghost.ui-icon-button-neutral`,
`.ui-icon-button-ghost.ui-icon-button-danger`,
`.ui-button-soft.ui-button-danger`,
} {
if !strings.Contains(css, want) {
t.Fatalf("expected stylesheet to contain %q", want)
}
}
}
func TestButtonRendersDangerLargeMarkup(t *testing.T) {
component := Button(ButtonProps{
Label: "Supprimer",
Variant: ButtonVariantDanger,
Tone: ButtonToneSoft,
Size: SizeLG,
Type: "submit",
})
html := renderToString(t, component)
for _, want := range []string{
`type="submit"`,
`ui-button-soft`,
`ui-button-danger`,
`ui-button-lg`,
`Supprimer`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestButtonRendersSoftWarningMarkup(t *testing.T) {
component := Button(ButtonProps{
Label: "Relancer",
Variant: ButtonVariantWarning,
Tone: ButtonToneSoft,
Size: SizeMD,
Type: "button",
})
html := renderToString(t, component)
for _, want := range []string{
`type="button"`,
`ui-button-soft`,
`ui-button-warning`,
`ui-button-md`,
`Relancer`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestInputRendersSharedControlMarkup(t *testing.T) {
component := Input(InputProps{
Name: "name",
Value: "My project",
Placeholder: "Nom du projet",
Type: "text",
})
html := renderToString(t, component)
for _, want := range []string{
`name="name"`,
`value="My project"`,
`placeholder="Nom du projet"`,
`class="ui-input"`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestTextareaRendersSharedControlMarkup(t *testing.T) {
component := Textarea(TextareaProps{
Name: "description",
Value: "Longer copy",
Placeholder: "Description",
Rows: 4,
})
html := renderToString(t, component)
for _, want := range []string{
`name="description"`,
`placeholder="Description"`,
`rows="4"`,
`class="ui-textarea"`,
`Longer copy`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestFormFieldRendersLabelAndError(t *testing.T) {
component := FormField(FormFieldProps{
Label: "Nom",
For: "tablo-name",
Field: Input(InputProps{Name: "name", Type: "text"}),
Error: "Le nom est requis",
})
html := renderToString(t, component)
for _, want := range []string{
`ui-form-field`,
`for="tablo-name"`,
`Nom`,
`ui-form-error`,
`Le nom est requis`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestCardRendersSharedRegions(t *testing.T) {
component := Card(CardProps{
Header: textComponent("Header"),
Body: textComponent("Body"),
Footer: textComponent("Footer"),
})
html := renderToString(t, component)
for _, want := range []string{
`ui-card`,
`ui-card-header`,
`ui-card-body`,
`ui-card-footer`,
`Body`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestTableRendersSharedShell(t *testing.T) {
component := Table(TableProps{
Head: textComponent("<tr><th>Projet</th></tr>"),
Body: textComponent("<tr><td>Hello</td></tr>"),
})
html := renderToString(t, component)
for _, want := range []string{
`ui-table-shell`,
`class="ui-table"`,
`<thead>`,
`<tbody>`,
`Projet`,
`Hello`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func TestEmptyStateRendersTitleDescriptionAndAction(t *testing.T) {
component := EmptyState(EmptyStateProps{
Title: "Aucun projet",
Description: "Créez votre premier projet.",
Action: textComponent("Créer"),
})
html := renderToString(t, component)
for _, want := range []string{
`ui-empty-state`,
`Aucun projet`,
`Créez votre premier projet.`,
`Créer`,
} {
if !strings.Contains(html, want) {
t.Fatalf("expected %q in %q", want, html)
}
}
}
func renderToString(t *testing.T, component templ.Component) string {
t.Helper()
var buf bytes.Buffer
if err := component.Render(context.Background(), &buf); err != nil {
t.Fatalf("render component: %v", err)
}
return buf.String()
}
func textComponent(text string) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
_, err := w.Write([]byte(text))
return err
})
}