Files
tia-love/internal/app/layout_test.go
T
oxpoe 2d07935f83
CI / test (push) Successful in 39s
feat: layar Pengaturan (PRD §5.1.5) + tombol Setting beranda + toggle suara
- Layar Pengaturan (',' atau klik chip Pengaturan beranda): bahasa, mode tema,
  efek suara, volume, animasi, timestamp — semua berlaku langsung & tersimpan
  ke config (config.Save), plus path config + catatan.
- Audio live: Engine.SetEnabled/SetVolume/Enabled/Volume; init idempoten
  (readyOnce/started) sehingga engine yang lahir mati bisa dinyalakan tanpa
  restart — komentar 'shortcut' F-024 lama dihapus.
- Beranda: kolom tengah kini CTA + hint '[m] suara: …' (klik = toggle) +
  chip Pengaturan — draf dashboard lengkap.
- Tombol lama 'T'/'a'/'S' kini lewat applySetting yang sama (DRY + persist).
- TIA_LANG dihapus (tak perlu lagi): e2e memakai XDG_CONFIG_HOME temp
  (config bersih per tes) → tes tidak tergantung config pengguna.
- Toast: klik di luar kotak toast tidak lagi menutupnya (hanya klik pada toast)
  — tombol lain tetap bisa diklik saat notifikasi tampil.
- Tes: unit settings (apply/persist/jepit volume, baris plain==styled tanpa
  ANSI) + e2e TestSettingsScreen (klik chip, toggle, bahasa→Inggris,
  verifikasi config.yaml).
2026-10-16 04:07:33 +07:00

169 lines
6.1 KiB
Go

package app
import (
"strings"
"testing"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n"
)
// TestDetailWidths — kolom aksi tab tampil hanya bila layar cukup lebar
// (tab bar detail tetap utuh di layar sempit).
func TestDetailWidths(t *testing.T) {
m := Model{width: 120, section: sectionContainers}
wDetail, wAct := m.detailWidths()
if wAct == 0 {
t.Fatalf("layar 120: kolom aksi harus tampil (wDetail=%d)", wDetail)
}
if wDetail+wAct != 120-m.leftWidth() {
t.Fatalf("layar 120: lebar tidak menjumlah (wDetail=%d wAct=%d)", wDetail, wAct)
}
m.width = 80
if _, wAct = m.detailWidths(); wAct != 0 {
t.Fatalf("layar 80: kolom aksi harus disembunyikan, dapat %d", wAct)
}
}
// TestWorkspaceBar — bar workspace memuat subjudul seksi + tombol stack,
// menampilkan query saat mode cari, dan tidak melebihi lebar dalam panel.
func TestWorkspaceBar(t *testing.T) {
for _, width := range []int{40, 80, 120} {
m := Model{width: width, view: viewList}
subLine, searchLine := m.workspaceBarPlain()
if strings.ContainsRune(subLine, 0x1b) || strings.ContainsRune(searchLine, 0x1b) {
t.Fatalf("w=%d: baris plain tidak boleh memuat ANSI", width)
}
if !strings.Contains(subLine, i18n.T("toolbar.addStack")) || !strings.Contains(subLine, i18n.T("toolbar.convert")) {
t.Fatalf("w=%d: tombol stack/konversi tidak ada: %q", width, subLine)
}
if !strings.Contains(searchLine, "[ / "+i18n.T("toolbar.search")+" ]") {
t.Fatalf("w=%d: pill cari tidak ada: %q", width, searchLine)
}
inner := width - 2 // bar surface: padding 1 sel tiap sisi
if w := lipgloss.Width(subLine); w > inner {
t.Fatalf("w=%d: subjudul+tombol lebar %d > %d", width, w, inner)
}
if w := lipgloss.Width(searchLine); w > inner {
t.Fatalf("w=%d: pill cari lebar %d > %d", width, w, inner)
}
m.searching, m.query = true, "abc"
if _, searchLine = m.workspaceBarPlain(); !strings.Contains(searchLine, "/ abc") {
t.Fatalf("w=%d: query tidak tampil di pill: %q", width, searchLine)
}
}
// Subjudul = daftar seksi (mock): "stack, kontainer, image, volume, jaringan".
m := Model{width: 120, view: viewList}
subLine, _ := m.workspaceBarPlain()
want := strings.ToLower(i18n.T("section.stacks")) + ", " +
strings.ToLower(i18n.T("section.containers")) + ", " +
strings.ToLower(i18n.T("section.images")) + ", " +
strings.ToLower(i18n.T("section.volumes")) + ", " +
strings.ToLower(i18n.T("section.networks"))
if !strings.HasPrefix(subLine, want) {
t.Fatalf("subjudul bar salah: %q (mau %q)", subLine, want)
}
// Tautan seksi: tiap span menunjuk nama seksi yang benar & bisa diklik.
keys := []string{"stacks", "containers", "images", "volumes", "networks"}
sub, spans := m.workspaceSubsections()
runes := []rune(sub)
if len(spans) != len(keys) {
t.Fatalf("jumlah span = %d, mau %d", len(spans), len(keys))
}
for i, sp := range spans {
got, wantName := string(runes[sp[0]:sp[1]]), strings.ToLower(i18n.T("section."+keys[i]))
if got != wantName {
t.Fatalf("span %d = %q, mau %q", i, got, wantName)
}
if s, ok := m.workspaceSectionAt(sp[0]); !ok || int(s) != i {
t.Fatalf("klik kolom %d harus → seksi %d (ok=%v s=%d)", sp[0], i, ok, s)
}
}
if _, ok := m.workspaceSectionAt(len(runes) + 5); ok {
t.Fatal("kolom di luar subjudul tidak boleh memetakan seksi")
}
// Layar sempit: span tetap di dalam subjudul yang tampak.
narrow := Model{width: 40, view: viewList}
subNarrow, spansNarrow, _, _, _ := narrow.workspaceBarParts()
limit := len([]rune(subNarrow))
for _, sp := range spansNarrow {
if sp[0] < 0 || sp[1] > limit {
t.Fatalf("span %v di luar subjudul tampak (%d kolom)", sp, limit)
}
}
}
// TestPanelTitleFits — judul/meta panjang tidak boleh melebihi lebar panel:
// nama kontainer/stack panjang pernah mendorong kolom kanan keluar layar.
func TestPanelTitleFits(t *testing.T) {
long := strings.Repeat("GITEA-ACTIONS-TASK-", 8)
for _, w := range []int{6, 10, 40, 100} {
for _, meta := range []string{"", "running", long} {
out := panel(long, meta, []string{"isi"}, w, 4, false)
for _, line := range strings.Split(out, "\n") {
if got := lipgloss.Width(line); got > w {
t.Fatalf("w=%d meta=%q: baris lebar %d > %d: %q", w, meta, got, w, line)
}
}
}
}
}
// TestFooterHintTiers — strip hint footer menyusut bertier: hint dekoratif
// dibuang lebih dulu (dengan ellipsis), status dipangkas ke sisa ruang; teks
// tampil (styled) harus identik dengan teks hit-test (plain).
func TestFooterHintTiers(t *testing.T) {
m := Model{width: 200, view: viewList, section: sectionContainers}
plain, styled := m.footerLayout()
if got := ansi.Strip(styled); got != plain {
t.Fatalf("plain != styled:\n%q\n%q", plain, got)
}
if !strings.Contains(plain, "[q] keluar") || !strings.Contains(plain, "audio:") {
t.Fatalf("lebar 200 harus memuat semua hint + status: %q", plain)
}
for _, w := range []int{100, 60, 40} {
m.width = w
plain, styled = m.footerLayout()
if got := ansi.Strip(styled); got != plain {
t.Fatalf("w=%d: plain != styled:\n%q\n%q", w, plain, got)
}
if got := lipgloss.Width(plain); got > w {
t.Fatalf("w=%d: baris footer %d sel", w, got)
}
if w >= 60 && !strings.Contains(plain, "[q] keluar") {
t.Fatalf("w=%d: hint esensial [q] keluar ikut terbuang: %q", w, plain)
}
if w < 40 && !strings.Contains(plain, "…") {
t.Fatalf("w=%d: hint yang dibuang harus ditandai ellipsis: %q", w, plain)
}
}
}
// TestSearchJump — pencarian melompat ke item pertama yang cocok (daftar tetap
// utuh), dan nama item mengikuti seksi aktif.
func TestSearchJump(t *testing.T) {
m := Model{
section: sectionContainers,
containers: []docker.Container{{Name: "alfa"}, {Name: "beta"}, {Name: "betawi"}},
}
m.query = "beta"
m.searchJumpCmd()
if got := m.cur(); got != 1 {
t.Fatalf("kursor = %d, mau 1 (kecocokan pertama)", got)
}
if m.itemName(2) != "betawi" {
t.Fatalf("itemName(2) = %q", m.itemName(2))
}
m.query = "tidak-ada"
m.searchJumpCmd()
if got := m.cur(); got != 1 {
t.Fatalf("tanpa kecocokan kursor harus tetap: %d", got)
}
}