feat(config): internal/config (load/save atomik + validasi) — wiring bahasa/suara/animasi + doctor baca config
CI / test (push) Successful in 13m3s
CI / test (push) Successful in 13m3s
This commit is contained in:
1 parent
36bc33f904
commit
c65d2cb75d
8 files changed
+328
-15
No files matched your search
+62
-3
@@ -58,7 +58,31 @@ type tui struct {
|
||||
waited bool
|
||||
}
|
||||
|
||||
func startTUI(t *testing.T) *tui { return startTUIEnv(t) }
|
||||
// startTUI memakai TIA_LANG=id eksplisit — deterministik walau mesin
|
||||
// pengembang/testing punya config.yaml sendiri.
|
||||
func startTUI(t *testing.T) *tui { return startTUIEnv(t, "TIA_LANG=id") }
|
||||
|
||||
// envWith — gabung env proses + override tanpa duplikat key
|
||||
// (Linux getenv memakai kemunculan pertama, jadi duplikat berbahaya).
|
||||
func envWith(env []string) []string {
|
||||
if len(env) == 0 {
|
||||
return nil
|
||||
}
|
||||
overridden := map[string]bool{}
|
||||
for _, kv := range env {
|
||||
k, _, _ := strings.Cut(kv, "=")
|
||||
overridden[k+"="] = true
|
||||
}
|
||||
out := make([]string, 0, len(os.Environ())+len(env))
|
||||
for _, kv := range os.Environ() {
|
||||
k, _, _ := strings.Cut(kv, "=")
|
||||
if overridden[k+"="] {
|
||||
continue
|
||||
}
|
||||
out = append(out, kv)
|
||||
}
|
||||
return append(out, env...)
|
||||
}
|
||||
|
||||
func startTUIEnv(t *testing.T, env ...string) *tui {
|
||||
t.Helper()
|
||||
@@ -66,8 +90,8 @@ func startTUIEnv(t *testing.T, env ...string) *tui {
|
||||
t.Skip("e2e pty tidak didukung di Windows")
|
||||
}
|
||||
cmd := exec.Command(binPath)
|
||||
if len(env) > 0 {
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
if e := envWith(env); len(e) > 0 {
|
||||
cmd.Env = e
|
||||
}
|
||||
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: rows, Cols: cols})
|
||||
if err != nil {
|
||||
@@ -216,6 +240,41 @@ func TestDoctorCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFileRespected(t *testing.T) {
|
||||
xdg := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(xdg, "tia"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(xdg, "tia", "config.yaml"),
|
||||
[]byte("version: 1\nui:\n language: en\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := startTUIEnv(t, "XDG_CONFIG_HOME="+xdg)
|
||||
u.waitContains("· home", 5*time.Second)
|
||||
u.send("q")
|
||||
if code := u.waitExit(5 * time.Second); code != 0 {
|
||||
t.Fatalf("exit code %d, mau 0", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorUsesConfigStacksDir(t *testing.T) {
|
||||
xdg := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(xdg, "tia"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stacks := filepath.Join(xdg, "stacks-custom")
|
||||
if err := os.WriteFile(filepath.Join(xdg, "tia", "config.yaml"),
|
||||
[]byte("version: 1\nstacks_dir: "+stacks+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command(binPath, "doctor")
|
||||
cmd.Env = envWith([]string{"XDG_CONFIG_HOME=" + xdg})
|
||||
out, _ := cmd.CombinedOutput()
|
||||
if !strings.Contains(string(out), "stacks-custom") {
|
||||
t.Fatalf("doctor tidak memakai stacks_dir dari config:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnglishLanguage(t *testing.T) {
|
||||
u := startTUIEnv(t, "TIA_LANG=en")
|
||||
u.waitContains("· home", 5*time.Second)
|
||||
|
||||
+25
-5
@@ -11,6 +11,7 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/config"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/sysinfo"
|
||||
@@ -64,12 +65,24 @@ type Model struct {
|
||||
}
|
||||
|
||||
func New() Model {
|
||||
cfg, cfgErr := config.Load()
|
||||
// shortcut: TIA_LANG = override khusus dev/tes; sumber resmi bahasa
|
||||
// tetap config (ui.language) — dihapus saat layar Pengaturan jadi.
|
||||
if l := os.Getenv("TIA_LANG"); l != "" {
|
||||
cfg.UI.Language = l
|
||||
}
|
||||
i18n.SetLang(cfg.UI.Language)
|
||||
|
||||
cl, err := docker.New()
|
||||
host, _ := os.Hostname()
|
||||
m := Model{
|
||||
sound: audio.New(audio.Options{Enabled: true, Volume: 70, MuteOnSSH: true}),
|
||||
sound: audio.New(audio.Options{
|
||||
Enabled: cfg.UI.SoundEnabled,
|
||||
Volume: cfg.UI.SoundVolume,
|
||||
MuteOnSSH: cfg.UI.SoundMuteOnSSH,
|
||||
}),
|
||||
tickID: animation.NewID(),
|
||||
animating: true,
|
||||
animating: cfg.UI.AnimationEnabled,
|
||||
toast: components.NewToast(),
|
||||
view: viewHome,
|
||||
hostname: host,
|
||||
@@ -77,6 +90,9 @@ func New() Model {
|
||||
cl: cl,
|
||||
loading: true,
|
||||
}
|
||||
if cfgErr != nil {
|
||||
m.toast.Show(i18n.T("toast.configError"), cfgErr.Error())
|
||||
}
|
||||
if err != nil {
|
||||
m.loadErr = err
|
||||
m.engineErr = err
|
||||
@@ -86,10 +102,14 @@ func New() Model {
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
if m.cl == nil {
|
||||
return animation.Next(m.tickID)
|
||||
var cmds []tea.Cmd
|
||||
if m.animating {
|
||||
cmds = append(cmds, animation.Next(m.tickID))
|
||||
}
|
||||
return tea.Batch(animation.Next(m.tickID), loadContainers(m.cl), loadInfo(m.cl))
|
||||
if m.cl != nil {
|
||||
cmds = append(cmds, loadContainers(m.cl), loadInfo(m.cl))
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func loadContainers(cl *docker.Client) tea.Cmd {
|
||||
|
||||
+16
-3
@@ -7,11 +7,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/config"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n"
|
||||
)
|
||||
@@ -51,8 +51,21 @@ func Doctor(w io.Writer) int {
|
||||
|
||||
fmt.Fprintf(w, "go : %s (%s/%s)\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
stacks := filepath.Join(home, ".tia", "stacks")
|
||||
// Konfigurasi & direktori stack.
|
||||
cfg, cfgErr := config.Load()
|
||||
cfgPath := config.Path()
|
||||
switch {
|
||||
case cfgErr != nil:
|
||||
fmt.Fprintf(w, "config : %s %v\n", i18n.T("error.warn"), cfgErr)
|
||||
default:
|
||||
if _, err := os.Stat(cfgPath); err != nil {
|
||||
fmt.Fprintf(w, "config : %s\n", i18n.Tf("doctor.configMissing", cfgPath))
|
||||
} else {
|
||||
fmt.Fprintf(w, "config : %s\n", cfgPath)
|
||||
}
|
||||
}
|
||||
|
||||
stacks := config.ExpandPath(cfg.StacksDir)
|
||||
if st, err := os.Stat(stacks); err == nil && st.IsDir() {
|
||||
fmt.Fprintf(w, "stacks : %s\n", stacks)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Package config — baca/tulis config tia (PRD §7.1).
|
||||
//
|
||||
// Sesuai prinsip ponytail: struct ini memuat subset schema PRD §7.2 yang
|
||||
// SUDAH dipakai; field lain ditambahkan saat fiturnya benar-benar masuk
|
||||
// (supaya config tidak pernah berisi opsi mati).
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Version int `yaml:"version"`
|
||||
StacksDir string `yaml:"stacks_dir"`
|
||||
UI UI `yaml:"ui"`
|
||||
}
|
||||
|
||||
type UI struct {
|
||||
Language string `yaml:"language"` // id | en
|
||||
SoundEnabled bool `yaml:"sound_enabled"`
|
||||
SoundVolume int `yaml:"sound_volume"` // 0-100
|
||||
SoundMuteOnSSH bool `yaml:"sound_mute_on_ssh"`
|
||||
AnimationEnabled bool `yaml:"animation_enabled"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
home, _ := os.UserHomeDir()
|
||||
return Config{
|
||||
Version: 1,
|
||||
StacksDir: filepath.Join(home, ".tia", "stacks"),
|
||||
UI: UI{
|
||||
Language: "id",
|
||||
SoundEnabled: true,
|
||||
SoundVolume: 70,
|
||||
SoundMuteOnSSH: true,
|
||||
AnimationEnabled: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Path — ~/.config/tia/config.yaml (mengikuti XDG_CONFIG_HOME).
|
||||
func Path() string {
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
dir = filepath.Join(os.Getenv("HOME"), ".config")
|
||||
}
|
||||
return filepath.Join(dir, "tia", "config.yaml")
|
||||
}
|
||||
|
||||
// Load membaca config; file belum ada BUKAN error (memakai default).
|
||||
func Load() (Config, error) { return LoadFrom(Path()) }
|
||||
|
||||
func LoadFrom(path string) (Config, error) {
|
||||
c := Default()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return c, nil
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
if err := yaml.Unmarshal(b, &c); err != nil {
|
||||
return Default(), fmt.Errorf("config: parse %s: %w", path, err)
|
||||
}
|
||||
c.normalize()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Save menulis config secara atomik (temp + rename) — tidak pernah setengah jadi.
|
||||
func Save(c Config) error { return SaveTo(Path(), c) }
|
||||
|
||||
func SaveTo(path string, c Config) error {
|
||||
c.normalize()
|
||||
b, err := yaml.Marshal(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: marshal: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalize — validasi nilai (file config adalah batas kepercayaan:
|
||||
// bisa diedit tangan; nilai rusak tidak boleh menjatuhkan aplikasi).
|
||||
func (c *Config) normalize() {
|
||||
switch c.UI.Language {
|
||||
case "id", "en":
|
||||
default:
|
||||
c.UI.Language = "id"
|
||||
}
|
||||
if c.UI.SoundVolume < 0 {
|
||||
c.UI.SoundVolume = 0
|
||||
}
|
||||
if c.UI.SoundVolume > 100 {
|
||||
c.UI.SoundVolume = 100
|
||||
}
|
||||
if c.StacksDir == "" {
|
||||
c.StacksDir = Default().StacksDir
|
||||
}
|
||||
if c.Version == 0 {
|
||||
c.Version = 1
|
||||
}
|
||||
}
|
||||
|
||||
// ExpandPath — ubah "~/..." jadi absolut (config menyimpan path gaya user).
|
||||
func ExpandPath(p string) string {
|
||||
if p == "~" || strings.HasPrefix(p, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
return filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(p, "~"), "/"))
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadMissingUsesDefaults(t *testing.T) {
|
||||
c, err := LoadFrom(filepath.Join(t.TempDir(), "tidak-ada.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("file tidak ada harusnya bukan error: %v", err)
|
||||
}
|
||||
d := Default()
|
||||
if c.UI.Language != d.UI.Language || c.UI.SoundVolume != d.UI.SoundVolume {
|
||||
t.Fatalf("default salah: %+v", c.UI)
|
||||
}
|
||||
if c.StacksDir == "" {
|
||||
t.Fatal("stacks_dir default kosong")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveLoadRoundtrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sub", "config.yaml")
|
||||
in := Default()
|
||||
in.UI.Language = "en"
|
||||
in.UI.SoundVolume = 42
|
||||
in.StacksDir = "/data/stacks"
|
||||
|
||||
if err := SaveTo(path, in); err != nil {
|
||||
t.Fatalf("SaveTo: %v", err)
|
||||
}
|
||||
out, err := LoadFrom(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFrom: %v", err)
|
||||
}
|
||||
if out.UI.Language != "en" || out.UI.SoundVolume != 42 || out.StacksDir != "/data/stacks" {
|
||||
t.Fatalf("roundtrip salah: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialYamlKeepsDefaults(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
os.WriteFile(path, []byte("ui:\n language: en\n"), 0o644)
|
||||
|
||||
c, err := LoadFrom(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFrom: %v", err)
|
||||
}
|
||||
if c.UI.Language != "en" {
|
||||
t.Fatalf("language: %s", c.UI.Language)
|
||||
}
|
||||
if c.UI.SoundVolume != 70 || !c.UI.SoundEnabled || !c.UI.AnimationEnabled {
|
||||
t.Fatalf("field lain harus default: %+v", c.UI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeInvalidValues(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
os.WriteFile(path, []byte("ui:\n language: klingon\n sound_volume: 999\n"), 0o644)
|
||||
|
||||
c, err := LoadFrom(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFrom: %v", err)
|
||||
}
|
||||
if c.UI.Language != "id" {
|
||||
t.Fatalf("language rusak harus fallback id: %s", c.UI.Language)
|
||||
}
|
||||
if c.UI.SoundVolume != 100 {
|
||||
t.Fatalf("volume harus diklamp 100: %d", c.UI.SoundVolume)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedYamlIsError(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
os.WriteFile(path, []byte("ui: [ini: rusak\n"), 0o644)
|
||||
if _, err := LoadFrom(path); err == nil {
|
||||
t.Fatal("yaml rusak harusnya error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPath(t *testing.T) {
|
||||
home, _ := os.UserHomeDir()
|
||||
if got := ExpandPath("~/x"); got != filepath.Join(home, "x") {
|
||||
t.Fatalf("~/x = %s", got)
|
||||
}
|
||||
if got := ExpandPath("~"); got != home {
|
||||
t.Fatalf("~ = %s", got)
|
||||
}
|
||||
if got := ExpandPath("/abs/path"); got != "/abs/path" {
|
||||
t.Fatalf("abs berubah: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ package i18n
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -34,9 +33,6 @@ func init() {
|
||||
catalogs[lang] = c
|
||||
}
|
||||
}
|
||||
if l := os.Getenv("TIA_LANG"); l != "" {
|
||||
active = l
|
||||
}
|
||||
}
|
||||
|
||||
// shortcut: bahasa hanya dari TIA_LANG + default id — akan dibaca dari
|
||||
|
||||
@@ -61,6 +61,7 @@ toast:
|
||||
demoTitle: "✔ [ SUCCESS ] Conversion Saved"
|
||||
demoBody: "Stack 'web-app' saved to ~/.tia/stacks/web-app/"
|
||||
dockerError: "✖ [ FAILED ] Docker"
|
||||
configError: "⚠ [ WARNING ] Config problem — using defaults"
|
||||
|
||||
activity:
|
||||
coming: "Docker events listener coming (Phase 2)."
|
||||
@@ -84,6 +85,7 @@ doctor:
|
||||
dockerDaemonUnreachable: "daemon not responding: %v"
|
||||
dockerOk: "engine %s (API %s) · %d/%d containers running · %d images"
|
||||
stacksMissing: "%s (missing — created during onboarding)"
|
||||
configMissing: "%s (missing — using defaults)"
|
||||
|
||||
cli:
|
||||
unknownCommand: "unknown command: %q"
|
||||
|
||||
@@ -61,6 +61,7 @@ toast:
|
||||
demoTitle: "✔ [ SUKSES ] Konversi Berhasil Disimpan"
|
||||
demoBody: "Stack 'web-app' tersimpan di ~/.tia/stacks/web-app/"
|
||||
dockerError: "✖ [ GAGAL ] Docker"
|
||||
configError: "⚠ [ PERINGATAN ] Config bermasalah — memakai default"
|
||||
|
||||
activity:
|
||||
coming: "Listener Docker events menyusul (Phase 2)."
|
||||
@@ -84,6 +85,7 @@ doctor:
|
||||
dockerDaemonUnreachable: "daemon tidak merespons: %v"
|
||||
dockerOk: "engine %s (API %s) · %d/%d kontainer berjalan · %d image"
|
||||
stacksMissing: "%s (belum ada — dibuat saat onboarding)"
|
||||
configMissing: "%s (belum ada — memakai default)"
|
||||
|
||||
cli:
|
||||
unknownCommand: "perintah tidak dikenal: %q"
|
||||
|
||||
Reference in new issue
Block a user