Pengelola tugas menyimpan data antar eksekusi, sehingga isinya bukan lagi satu alur lurus melainkan beberapa kepentingan sekaligus: membaca perintah pengguna, menjaga aturan tugas, dan merawat berkas penyimpanan. Arsitektur yang baik memisahkan ketiganya sejak awal. Pada bagian ini kita memetakan lapisan-lapisan tersebut, menata berkasnya, dan menulis titik masuk yang menghubungkan semuanya.

Tiga Lapisan Tanggung Jawab #

Aplikasi dibagi menjadi tiga lapisan dengan arah tanggung jawab yang jelas:

LapisanBerkasMenyentuh
Antarmukamain.go, cli.goos.Args, io.Writer, os.Exit
Domaintask.goHanya nilai Task dan slice-nya
Persistensistorage.goos.ReadFile, os.CreateTemp, JSON

Aturan mainnya sederhana: lapisan domain dan persistensi tidak membaca argumen command line dan tidak menghentikan program. Kedua lapisan itu menerima nilai, mengembalikan nilai atau error, dan biarkan lapisan antarmuka yang memutuskan bagaimana error tersebut dilaporkan. Pisah semacam ini membuat tiga berkas test kita nanti bisa menguji domain tanpa terminal dan persistensi tanpa berkas sungguhan di direktori kerja.

Struktur Berkas dan Module #

Module diberi nama example.com/pengelola-tugas dan menyasar Go 1.27:

text
module example.com/pengelola-tugas

go 1.27

Struktur direktori proyek:

text
pengelola-tugas-cli/
├── go.mod
├── main.go        titik masuk: flag -data
├── cli.go         dispatch subperintah dan output
├── task.go        model Task dan aturan bisnis
├── storage.go     muat/simpan JSON
├── task_test.go        test domain
├── storage_test.go     test persistensi
├── cli_test.go         test alur CLI
└── README.md

Titik Masuk dan Suntikan Jam #

main.go sependek mungkin — seluruh pekerjaan beratnya dipercayakan ke Run pada cli.go yang kita bahas di Bagian 3:

go
package main

import (
	"flag"
	"os"
	"time"
)

// berkasDefault adalah lokasi penyimpanan JSON ketika flag -data tidak dipakai.
const berkasDefault = "tugas.json"

func main() {
	berkas := flag.String("data", berkasDefault, "berkas penyimpanan JSON")
	flag.Parse()

	penyimpanan := PenyimpananBaru(*berkas)

	// os.Exit dipanggil di luar Run agar defer di dalam Run tetap bekerja.
	os.Exit(Run(flag.Args(), penyimpanan, time.Now, os.Stdout, os.Stderr))
}

Mari kita telaah baris-baris pentingnya:

  1. Baris 13 (flag.String("data", ...)): flag global -data menentukan lokasi berkas penyimpanan. Nilai baku tugas.json berada di konstanta berkasDefault agar mudah diganti.
  2. Baris 14 (flag.Parse()): flag diparse sebelum subperintah; flag.Args() pada baris 19 lalu berisi sisa argumen non-flag.
  3. Baris 16 (PenyimpananBaru(*berkas)): objek penyimpanan dibangun sekali di titik masuk lalu mengalir ke seluruh aplikasi — lapisan bawah tidak pernah mencari berkasnya sendiri.
  4. Baris 19 (Run(flag.Args(), penyimpanan, time.Now, os.Stdout, os.Stderr)): empat dependency disuntikkan sekaligus: argumen, penyimpanan, jam, dan dua jalur keluaran. os.Exit tetap berada di luar Run agar defer di dalamnya sempat berjalan.

Pemakaian time.Now sebagai argumen adalah keputusan arsitektur, bukan gaya penulisan. Fungsi Run tidak memanggil time.Now() di dalam tubuhnya, melainkan menerima fungsi sekarang func() time.Time. Saat produksi, main mengirim time.Now sungguhan; saat test, kita mengirim fungsi yang selalu mengembalikan satu waktu beku — cara inilah yang membuat test alur CLI tetap deterministik.

Alur Satu Perintah #

Setiap perintah pengguna menempuh jalur yang sama:

Garis penuh pada diagram menunjukkan urutan panggilan dari lapisan antarmuka turun ke domain dan persistensi, lalu naik kembali membawa hasil. Kelalaian pengguna ditangkap di dua tempat berbeda: subperintah yang salah ditolak Run sebelum menyentuh data, sedangkan judul kosong atau id tidak dikenal ditolak di dekat tempat keputusan dibuat.

Kode lengkap seluruh berkas proyek tersedia pada bagian Berkas Sumber di bawah halaman ini.

Ringkasan Bagian 1 #

Kerangka berdiri: tiga lapisan dengan batas tegas, module bernama, titik masuk yang menyuntikkan seluruh dependency, dan alur perintah yang terpetakan. Bagian 2 mulai mengisi lapisan domain dengan model Task beserta aturan mainnya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

cli_test.go

Unduh berkas
go
package main

import (
	"bytes"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"
)

// penggunaUji menjalankan Run dengan penyimpanan baru berisi t.TempDir(),
// waktu beku, dan penampung keluaran. Hasilnya stdout, stderr, dan exit code.
func penggunaUji(t *testing.T, args ...string) (string, string, int) {
	t.Helper()
	p := PenyimpananBaru(filepath.Join(t.TempDir(), "tugas.json"))
	var out, errOut bytes.Buffer
	exit := Run(args, p, func() time.Time { return waktuBeku }, &out, &errOut)
	return out.String(), errOut.String(), exit
}

func TestRunTanpaSubperintah(t *testing.T) {
	stdout, stderr, exit := penggunaUji(t)

	if exit != exitPenggunaan {
		t.Errorf("exit = %d; want %d", exit, exitPenggunaan)
	}
	if stdout != "" {
		t.Errorf("stdout = %q; want kosong", stdout)
	}
	if !strings.Contains(stderr, "Penggunaan:") {
		t.Errorf("stderr = %q; want pesan penggunaan", stderr)
	}
}

func TestRunAlurLengkap(t *testing.T) {
	p := PenyimpananBaru(filepath.Join(t.TempDir(), "tugas.json"))
	sekarang := func() time.Time { return waktuBeku }

	jalankan := func(wantExit int, args ...string) string {
		t.Helper()
		var out, errOut bytes.Buffer
		if got := Run(args, p, sekarang, &out, &errOut); got != wantExit {
			t.Fatalf("Run(%v) exit = %d; want %d", args, got, wantExit)
		}
		if errOut.Len() > 0 {
			t.Fatalf("Run(%v) stderr tak terduga: %q", args, errOut.String())
		}
		return out.String()
	}

	stdout := jalankan(exitSukses, "tambah", "Belajar Go")
	if stdout != "Tugas 1 \"Belajar Go\" tersimpan.\n" {
		t.Errorf("tambah stdout = %q", stdout)
	}
	jalankan(exitSukses, "tambah", "Tulis dokumentasi")
	jalankan(exitSukses, "selesai", "2")

	stdout = jalankan(exitSukses, "list")
	want := "ID  STATUS  JUDUL\n1   [ ]     Belajar Go\n"
	if stdout != want {
		t.Errorf("list stdout = %q; want %q", stdout, want)
	}

	stdout = jalankan(exitSukses, "list", "--semua")
	want = "ID  STATUS  JUDUL\n1   [ ]     Belajar Go\n2   [x]     Tulis dokumentasi\n"
	if stdout != want {
		t.Errorf("list --semua stdout = %q; want %q", stdout, want)
	}

	jalankan(exitSukses, "hapus", "1")
	stdout = jalankan(exitSukses, "list")
	if stdout != "Tidak ada tugas yang belum selesai.\n" {
		t.Errorf("list setelah hapus stdout = %q", stdout)
	}
}

func TestRunTulisJSON(t *testing.T) {
	berkas := filepath.Join(t.TempDir(), "tugas.json")
	p := PenyimpananBaru(berkas)
	var out, errOut bytes.Buffer

	if got := Run([]string{"tambah", "Belajar Go"}, p, func() time.Time { return waktuBeku }, &out, &errOut); got != exitSukses {
		t.Fatalf("Run tambah exit = %d; want %d", got, exitSukses)
	}

	data, err := os.ReadFile(berkas)
	if err != nil {
		t.Fatalf("membaca berkas hasil tambah: %v", err)
	}
	want := `[
  {
    "id": 1,
    "judul": "Belajar Go",
    "selesai": false,
    "dibuat": "2026-01-15T08:00:00Z"
  }
]
`
	if string(data) != want {
		t.Errorf("isi berkas = %q; want %q", data, want)
	}
}

func TestRunKesalahan(t *testing.T) {
	cases := []struct {
		nama             string
		args             []string
		wantExit         int
		wantErrSepenggal string
	}{
		{"subperintah tak dikenal", []string{"lihat"}, exitPenggunaan, "tidak dikenal"},
		{"tambah tanpa judul", []string{"tambah"}, exitPenggunaan, "membutuhkan satu judul"},
		{"tambah dua judul", []string{"tambah", "a", "b"}, exitPenggunaan, "membutuhkan satu judul"},
		{"tambah judul kosong", []string{"tambah", "   "}, exitPenggunaan, "judul tugas tidak boleh kosong"},
		{"selesai id bukan angka", []string{"selesai", "abc"}, exitPenggunaan, "harus berupa angka"},
		{"selesai id negatif", []string{"selesai", "-1"}, exitPenggunaan, "harus berupa angka"},
		{"selesai id tidak ada", []string{"selesai", "9"}, exitData, "tidak ditemukan"},
		{"hapus id tidak ada", []string{"hapus", "9"}, exitData, "tidak ditemukan"},
		{"list flag tak dikenal", []string{"list", "--salah"}, exitPenggunaan, "flag provided but not defined"},
	}

	for _, c := range cases {
		t.Run(c.nama, func(t *testing.T) {
			stdout, stderr, exit := penggunaUji(t, c.args...)

			if exit != c.wantExit {
				t.Errorf("exit = %d; want %d", exit, c.wantExit)
			}
			if stdout != "" {
				t.Errorf("stdout = %q; want kosong", stdout)
			}
			if !strings.Contains(stderr, c.wantErrSepenggal) {
				t.Errorf("stderr = %q; want pesan memuat %q", stderr, c.wantErrSepenggal)
			}
		})
	}
}

func TestRunListKosong(t *testing.T) {
	stdout, _, exit := penggunaUji(t, "list")

	if exit != exitSukses {
		t.Errorf("exit = %d; want %d", exit, exitSukses)
	}
	if stdout != "Belum ada tugas tersimpan.\n" {
		t.Errorf("list pada penyimpanan kosong stdout = %q", stdout)
	}
}

cli.go

Unduh berkas
go
package main

import (
	"flag"
	"fmt"
	"io"
	"slices"
	"strconv"
	"text/tabwriter"
	"time"
)

// Kontrak exit code program. Nilai ini dibaca oleh skrip lain
// atau pipeline yang memanggil tugas.
const (
	exitSukses     = 0 // perintah berhasil
	exitPenggunaan = 1 // subperintah tak dikenal atau argumen salah
	exitData       = 2 // id tak ditemukan atau kegagalan penyimpanan
)

// Run menjalankan satu subperintah dan mengembalikan exit code.
// Parameter sekarang menyediakan waktu saat ini sehingga test dapat
// membekukannya; out menampung hasil perintah dan errOut menampung
// pesan galat agar keduanya tidak tercampur.
func Run(args []string, penyimpanan *Penyimpanan, sekarang func() time.Time, out, errOut io.Writer) int {
	if len(args) == 0 {
		tampilkanPenggunaan(errOut)
		return exitPenggunaan
	}

	perintah, sisa := args[0], args[1:]
	switch perintah {
	case "tambah":
		return perintahTambah(sisa, penyimpanan, sekarang, out, errOut)
	case "list":
		return perintahList(sisa, penyimpanan, out, errOut)
	case "selesai":
		return perintahSelesai(sisa, penyimpanan, out, errOut)
	case "hapus":
		return perintahHapus(sisa, penyimpanan, out, errOut)
	default:
		fmt.Fprintf(errOut, "subperintah %q tidak dikenal\n", perintah)
		tampilkanPenggunaan(errOut)
		return exitPenggunaan
	}
}

func tampilkanPenggunaan(errOut io.Writer) {
	fmt.Fprintln(errOut, "Penggunaan:")
	fmt.Fprintln(errOut, "  tugas [-data <berkas>] tambah \"<judul>\"")
	fmt.Fprintln(errOut, "  tugas [-data <berkas>] list [--semua]")
	fmt.Fprintln(errOut, "  tugas [-data <berkas>] selesai <id>")
	fmt.Fprintln(errOut, "  tugas [-data <berkas>] hapus <id>")
}

func perintahTambah(args []string, p *Penyimpanan, sekarang func() time.Time, out, errOut io.Writer) int {
	if len(args) != 1 {
		fmt.Fprintln(errOut, "perintah tambah membutuhkan satu judul tugas.")
		tampilkanPenggunaan(errOut)
		return exitPenggunaan
	}

	judul := args[0]
	if err := ValidasiJudul(judul); err != nil {
		fmt.Fprintln(errOut, err)
		return exitPenggunaan
	}

	tasks, err := p.Muat()
	if err != nil {
		fmt.Fprintln(errOut, "galat memuat tugas:", err)
		return exitData
	}

	t := Task{
		ID:     IDBerikutnya(tasks),
		Judul:  judul,
		Dibuat: sekarang(),
	}
	tasks = append(tasks, t)

	if err := p.Simpan(tasks); err != nil {
		fmt.Fprintln(errOut, "galat menyimpan tugas:", err)
		return exitData
	}

	fmt.Fprintf(out, "Tugas %d \"%s\" tersimpan.\n", t.ID, t.Judul)
	return exitSukses
}

func perintahList(args []string, p *Penyimpanan, out, errOut io.Writer) int {
	semua := false
	fs := flag.NewFlagSet("list", flag.ContinueOnError)
	fs.SetOutput(errOut)
	fs.BoolVar(&semua, "semua", false, "tampilkan juga tugas yang sudah selesai")
	if err := fs.Parse(args); err != nil {
		return exitPenggunaan
	}

	tasks, err := p.Muat()
	if err != nil {
		fmt.Fprintln(errOut, "galat memuat tugas:", err)
		return exitData
	}

	terpilih := tasks
	if !semua {
		terpilih = TugasAktif(tasks)
	}

	switch {
	case len(tasks) == 0:
		fmt.Fprintln(out, "Belum ada tugas tersimpan.")
	case len(terpilih) == 0:
		fmt.Fprintln(out, "Tidak ada tugas yang belum selesai.")
	default:
		slices.SortStableFunc(terpilih, Bandingkan)
		cetakTabel(out, terpilih)
	}
	return exitSukses
}

func cetakTabel(out io.Writer, tasks []Task) {
	w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
	fmt.Fprintln(w, "ID\tSTATUS\tJUDUL")
	for _, t := range tasks {
		status := "[ ]"
		if t.Selesai {
			status = "[x]"
		}
		fmt.Fprintf(w, "%d\t%s\t%s\n", t.ID, status, t.Judul)
	}
	w.Flush()
}

func perintahSelesai(args []string, p *Penyimpanan, out, errOut io.Writer) int {
	id, ok := parseID(args, "selesai", errOut)
	if !ok {
		return exitPenggunaan
	}

	tasks, err := p.Muat()
	if err != nil {
		fmt.Fprintln(errOut, "galat memuat tugas:", err)
		return exitData
	}

	i := CariIndeks(tasks, id)
	if i < 0 {
		fmt.Fprintf(errOut, "tugas dengan id %d tidak ditemukan.\n", id)
		return exitData
	}

	tasks[i].Selesai = true
	if err := p.Simpan(tasks); err != nil {
		fmt.Fprintln(errOut, "galat menyimpan tugas:", err)
		return exitData
	}

	fmt.Fprintf(out, "Tugas %d \"%s\" ditandai selesai.\n", tasks[i].ID, tasks[i].Judul)
	return exitSukses
}

func perintahHapus(args []string, p *Penyimpanan, out, errOut io.Writer) int {
	id, ok := parseID(args, "hapus", errOut)
	if !ok {
		return exitPenggunaan
	}

	tasks, err := p.Muat()
	if err != nil {
		fmt.Fprintln(errOut, "galat memuat tugas:", err)
		return exitData
	}

	i := CariIndeks(tasks, id)
	if i < 0 {
		fmt.Fprintf(errOut, "tugas dengan id %d tidak ditemukan.\n", id)
		return exitData
	}

	terhapus := tasks[i]
	tasks = slices.Delete(tasks, i, i+1)
	if err := p.Simpan(tasks); err != nil {
		fmt.Fprintln(errOut, "galat menyimpan tugas:", err)
		return exitData
	}

	fmt.Fprintf(out, "Tugas %d \"%s\" dihapus.\n", terhapus.ID, terhapus.Judul)
	return exitSukses
}

func parseID(args []string, perintah string, errOut io.Writer) (int, bool) {
	if len(args) != 1 {
		fmt.Fprintf(errOut, "perintah %s membutuhkan satu id tugas.\n", perintah)
		tampilkanPenggunaan(errOut)
		return 0, false
	}

	id, err := strconv.Atoi(args[0])
	if err != nil || id <= 0 {
		fmt.Fprintf(errOut, "id tugas harus berupa angka bulat positif, bukan %q.\n", args[0])
		return 0, false
	}
	return id, true
}

main.go

Unduh berkas
go
package main

import (
	"flag"
	"os"
	"time"
)

// berkasDefault adalah lokasi penyimpanan JSON ketika flag -data tidak dipakai.
const berkasDefault = "tugas.json"

func main() {
	berkas := flag.String("data", berkasDefault, "berkas penyimpanan JSON")
	flag.Parse()

	penyimpanan := PenyimpananBaru(*berkas)

	// os.Exit dipanggil di luar Run agar defer di dalam Run tetap bekerja.
	os.Exit(Run(flag.Args(), penyimpanan, time.Now, os.Stdout, os.Stderr))
}

storage_test.go

Unduh berkas
go
package main

import (
	"os"
	"path/filepath"
	"testing"
)

func TestSimpanMuat(t *testing.T) {
	p := PenyimpananBaru(filepath.Join(t.TempDir(), "tugas.json"))
	asli := tugasContoh()

	if err := p.Simpan(asli); err != nil {
		t.Fatalf("Simpan error tak terduga: %v", err)
	}

	dimuat, err := p.Muat()
	if err != nil {
		t.Fatalf("Muat error tak terduga: %v", err)
	}
	if len(dimuat) != len(asli) {
		t.Fatalf("Muat mengembalikan %d tugas; want %d", len(dimuat), len(asli))
	}
	for i := range asli {
		if !dimuat[i].Equal(asli[i]) {
			t.Errorf("tugas ke-%d berbeda setelah muat: got %+v; want %+v", i, dimuat[i], asli[i])
		}
	}
}

func TestMuatBerkasBelumAda(t *testing.T) {
	p := PenyimpananBaru(filepath.Join(t.TempDir(), "belum-ada.json"))

	tasks, err := p.Muat()
	if err != nil {
		t.Fatalf("Muat pada berkas yang belum ada error: %v", err)
	}
	if len(tasks) != 0 {
		t.Errorf("Muat mengembalikan %d tugas; want 0", len(tasks))
	}
}

func TestMuatJSONRusak(t *testing.T) {
	berkas := filepath.Join(t.TempDir(), "rusak.json")
	if err := os.WriteFile(berkas, []byte("bukan json"), 0o600); err != nil {
		t.Fatalf("menulis berkas uji: %v", err)
	}

	p := PenyimpananBaru(berkas)
	if _, err := p.Muat(); err == nil {
		t.Fatal("Muat pada JSON rusak harus error, tetapi berhasil")
	}
}

func TestSimpanIsiBerkas(t *testing.T) {
	direktori := t.TempDir()
	p := PenyimpananBaru(filepath.Join(direktori, "tugas.json"))

	if err := p.Simpan([]Task{{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}}); err != nil {
		t.Fatalf("Simpan error tak terduga: %v", err)
	}

	data, err := os.ReadFile(filepath.Join(direktori, "tugas.json"))
	if err != nil {
		t.Fatalf("membaca berkas hasil Simpan: %v", err)
	}

	want := `[
  {
    "id": 1,
    "judul": "Belajar Go",
    "selesai": false,
    "dibuat": "2026-01-15T08:00:00Z"
  }
]
`
	if string(data) != want {
		t.Errorf("isi berkas = %q; want %q", data, want)
	}

	sisa, err := filepath.Glob(filepath.Join(direktori, "tugas-*.tmp"))
	if err != nil {
		t.Fatalf("Glob error tak terduga: %v", err)
	}
	if len(sisa) != 0 {
		t.Errorf("berkas sementara tertinggal setelah Simpan: %v", sisa)
	}
}

func TestSimpanDaftarKosong(t *testing.T) {
	berkas := filepath.Join(t.TempDir(), "tugas.json")
	p := PenyimpananBaru(berkas)

	if err := p.Simpan(nil); err != nil {
		t.Fatalf("Simpan(nil) error tak terduga: %v", err)
	}

	data, err := os.ReadFile(berkas)
	if err != nil {
		t.Fatalf("membaca berkas hasil Simpan: %v", err)
	}
	if string(data) != "[]\n" {
		t.Errorf("isi berkas = %q; want %q", data, "[]\n")
	}
}

func TestSimpanMenggantiIsi(t *testing.T) {
	p := PenyimpananBaru(filepath.Join(t.TempDir(), "tugas.json"))

	if err := p.Simpan(tugasContoh()); err != nil {
		t.Fatalf("Simpan pertama error: %v", err)
	}
	if err := p.Simpan(nil); err != nil {
		t.Fatalf("Simpan kedua error: %v", err)
	}

	dimuat, err := p.Muat()
	if err != nil {
		t.Fatalf("Muat error tak terduga: %v", err)
	}
	if len(dimuat) != 0 {
		t.Errorf("Muat setelah Simpan kedua mengembalikan %d tugas; want 0", len(dimuat))
	}
}

storage.go

Unduh berkas
go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
)

// Penyimpanan mengurus pembacaan dan penulisan daftar tugas
// ke satu berkas JSON.
type Penyimpanan struct {
	berkas string
}

// PenyimpananBaru membuat penyimpanan yang menunjuk ke path berkas JSON.
func PenyimpananBaru(berkas string) *Penyimpanan {
	return &Penyimpanan{berkas: berkas}
}

// Muat membaca seluruh tugas dari berkas JSON.
// Berkas yang belum ada dianggap daftar kosong untuk aplikasi baru.
func (p *Penyimpanan) Muat() ([]Task, error) {
	isi, err := os.ReadFile(p.berkas)
	if err != nil {
		if errors.Is(err, fs.ErrNotExist) {
			return nil, nil
		}
		return nil, fmt.Errorf("membaca berkas %s: %w", p.berkas, err)
	}

	var tasks []Task
	if err := json.Unmarshal(isi, &tasks); err != nil {
		return nil, fmt.Errorf("mengurai JSON dari %s: %w", p.berkas, err)
	}
	return tasks, nil
}

// Simpan menulis seluruh tugas ke berkas JSON secara atomik:
// data ditulis dulu ke berkas sementara di direktori yang sama,
// lalu berkas sementara menggantikan berkas asli lewat rename.
// Dengan urutan ini, berkas asli tidak pernah berada dalam keadaan
// setengah tertulis.
func (p *Penyimpanan) Simpan(tasks []Task) error {
	if tasks == nil {
		tasks = []Task{}
	}

	data, err := json.MarshalIndent(tasks, "", "  ")
	if err != nil {
		return fmt.Errorf("menyusun JSON: %w", err)
	}
	data = append(data, '\n')

	berkasTmp, err := os.CreateTemp(filepath.Dir(p.berkas), "tugas-*.tmp")
	if err != nil {
		return fmt.Errorf("membuat berkas sementara: %w", err)
	}
	namaTmp := berkasTmp.Name()

	if _, err := berkasTmp.Write(data); err != nil {
		berkasTmp.Close()
		os.Remove(namaTmp)
		return fmt.Errorf("menulis berkas sementara: %w", err)
	}
	if err := berkasTmp.Close(); err != nil {
		os.Remove(namaTmp)
		return fmt.Errorf("menutup berkas sementara: %w", err)
	}
	if err := os.Rename(namaTmp, p.berkas); err != nil {
		os.Remove(namaTmp)
		return fmt.Errorf("mengganti berkas penyimpanan: %w", err)
	}
	return nil
}

task_test.go

Unduh berkas
go
package main

import (
	"encoding/json"
	"errors"
	"testing"
	"time"
)

// waktuBeku dipakai seluruh test agar hasil perbandingan deterministik.
var waktuBeku = time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC)

// tugasContoh mengembalikan salinan segar daftar tugas untuk test.
func tugasContoh() []Task {
	return []Task{
		{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku},
		{ID: 2, Judul: "Menulis dokumentasi", Selesai: true, Dibuat: waktuBeku.Add(2 * time.Hour)},
		{ID: 3, Judul: "Rilis aplikasi", Dibuat: waktuBeku.Add(4 * time.Hour)},
	}
}

func TestValidasiJudul(t *testing.T) {
	cases := []struct {
		nama        string
		judul       string
		wantErrSent error
	}{
		{"judul normal", "Belajar Go", nil},
		{"judul dengan spasi di tepi", "  Belajar Go  ", nil},
		{"judul kosong", "", ErrJudulKosong},
		{"judul hanya spasi", "   ", ErrJudulKosong},
		{"judul hanya tab", "\t", ErrJudulKosong},
	}

	for _, c := range cases {
		t.Run(c.nama, func(t *testing.T) {
			err := ValidasiJudul(c.judul)

			if c.wantErrSent == nil {
				if err != nil {
					t.Fatalf("ValidasiJudul(%q) error tak terduga: %v", c.judul, err)
				}
				return
			}

			if !errors.Is(err, c.wantErrSent) {
				t.Errorf("ValidasiJudul(%q) error = %v; want %v", c.judul, err, c.wantErrSent)
			}
		})
	}
}

func TestEqual(t *testing.T) {
	cases := []struct {
		nama      string
		t, other  Task
		wantEqual bool
	}{
		{"isi sama persis", Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}, Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}, true},
		{"id berbeda", Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}, Task{ID: 2, Judul: "Belajar Go", Dibuat: waktuBeku}, false},
		{"judul berbeda", Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}, Task{ID: 1, Judul: "Belajar PHP", Dibuat: waktuBeku}, false},
		{"status berbeda", Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}, Task{ID: 1, Judul: "Belajar Go", Selesai: true, Dibuat: waktuBeku}, false},
		{"waktu berbeda", Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku}, Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku.Add(time.Hour)}, false},
		{"instan sama zona beda", Task{ID: 1, Dibuat: waktuBeku}, Task{ID: 1, Dibuat: time.Date(2026, 1, 15, 15, 0, 0, 0, time.FixedZone("WIB", 7*60*60))}, true},
	}

	for _, c := range cases {
		t.Run(c.nama, func(t *testing.T) {
			if got := c.t.Equal(c.other); got != c.wantEqual {
				t.Errorf("Equal = %v; want %v", got, c.wantEqual)
			}
		})
	}
}

func TestBandingkan(t *testing.T) {
	cases := []struct {
		nama     string
		t, other Task
		wantSign int // -1, 0, atau 1
	}{
		{"dibuat lebih dulu", Task{ID: 1, Dibuat: waktuBeku}, Task{ID: 2, Dibuat: waktuBeku.Add(time.Hour)}, -1},
		{"dibuat lebih lambat", Task{ID: 2, Dibuat: waktuBeku.Add(time.Hour)}, Task{ID: 1, Dibuat: waktuBeku}, 1},
		{"waktu sama id lebih kecil", Task{ID: 1, Dibuat: waktuBeku}, Task{ID: 2, Dibuat: waktuBeku}, -1},
		{"waktu sama id lebih besar", Task{ID: 3, Dibuat: waktuBeku}, Task{ID: 2, Dibuat: waktuBeku}, 1},
	}

	for _, c := range cases {
		t.Run(c.nama, func(t *testing.T) {
			got := Bandingkan(c.t, c.other)
			if got == 0 || (got < 0) != (c.wantSign < 0) {
				t.Errorf("Bandingkan = %d; want tanda %d", got, c.wantSign)
			}
		})
	}
}

func TestIDBerikutnya(t *testing.T) {
	cases := []struct {
		nama  string
		tasks []Task
		want  int
	}{
		{"daftar kosong", nil, 1},
		{"berurutan", tugasContoh(), 4},
		{"id terbesar di tengah", []Task{{ID: 7}, {ID: 3}, {ID: 5}}, 8},
	}

	for _, c := range cases {
		t.Run(c.nama, func(t *testing.T) {
			if got := IDBerikutnya(c.tasks); got != c.want {
				t.Errorf("IDBerikutnya = %d; want %d", got, c.want)
			}
		})
	}
}

func TestTugasAktif(t *testing.T) {
	aktif := TugasAktif(tugasContoh())

	if len(aktif) != 2 {
		t.Fatalf("TugasAktif mengembalikan %d tugas; want 2", len(aktif))
	}
	for _, tsk := range aktif {
		if tsk.Selesai {
			t.Errorf("tugas %d selesai tetapi ikut terpilih", tsk.ID)
		}
	}

	if got := TugasAktif(nil); len(got) != 0 {
		t.Errorf("TugasAktif(nil) mengembalikan %d tugas; want 0", len(got))
	}
}

func TestCariIndeks(t *testing.T) {
	tasks := tugasContoh()

	if got := CariIndeks(tasks, 2); got != 1 {
		t.Errorf("CariIndeks(tasks, 2) = %d; want 1", got)
	}
	if got := CariIndeks(tasks, 9); got != -1 {
		t.Errorf("CariIndeks(tasks, 9) = %d; want -1", got)
	}
	if got := CariIndeks(nil, 1); got != -1 {
		t.Errorf("CariIndeks(nil, 1) = %d; want -1", got)
	}
}

func TestTaskMarshalJSON(t *testing.T) {
	b, err := json.Marshal(Task{ID: 1, Judul: "Belajar Go", Dibuat: waktuBeku})
	if err != nil {
		t.Fatalf("Marshal error tak terduga: %v", err)
	}

	want := `{"id":1,"judul":"Belajar Go","selesai":false,"dibuat":"2026-01-15T08:00:00Z"}`
	if string(b) != want {
		t.Errorf("Marshal = %s; want %s", b, want)
	}
}

task.go

Unduh berkas
go
package main

import (
	"errors"
	"strings"
	"time"
)

// Task menyimpan satu tugas pada daftar tugas.
type Task struct {
	ID      int       `json:"id"`
	Judul   string    `json:"judul"`
	Selesai bool      `json:"selesai"`
	Dibuat  time.Time `json:"dibuat"`
}

// ErrJudulKosong adalah sentinel error untuk judul yang kosong
// atau hanya berisi spasi.
var ErrJudulKosong = errors.New("judul tugas tidak boleh kosong")

// ValidasiJudul memeriksa bahwa judul berisi teks yang berarti,
// bukan string kosong ataupun hanya spasi.
func ValidasiJudul(judul string) error {
	if strings.TrimSpace(judul) == "" {
		return ErrJudulKosong
	}
	return nil
}

// Equal melaporkan apakah dua Task memiliki isi field yang sama persis.
// Perbandingan waktu memakai time.Time.Equal sehingga dua instan waktu
// yang sama tetap dianggap setara meski zona waktunya berbeda.
func (t Task) Equal(other Task) bool {
	return t.ID == other.ID &&
		t.Judul == other.Judul &&
		t.Selesai == other.Selesai &&
		t.Dibuat.Equal(other.Dibuat)
}

// Bandingkan membandingkan dua Task menurut waktu dibuat, dengan ID
// sebagai pemecah seri. Hasil negatif bila t dibuat lebih dulu, nol
// bila keduanya setara, dan positif bila t dibuat lebih lambat.
func Bandingkan(t, other Task) int {
	if c := t.Dibuat.Compare(other.Dibuat); c != 0 {
		return c
	}
	return t.ID - other.ID
}

// IDBerikutnya menghitung id untuk tugas baru: id terbesar saat ini + 1.
// Id tidak pernah dipakai ulang meskipun ada tugas yang dihapus.
func IDBerikutnya(tasks []Task) int {
	terbesar := 0
	for _, t := range tasks {
		if t.ID > terbesar {
			terbesar = t.ID
		}
	}
	return terbesar + 1
}

// TugasAktif mengembalikan hanya tugas yang belum selesai.
func TugasAktif(tasks []Task) []Task {
	var aktif []Task
	for _, t := range tasks {
		if !t.Selesai {
			aktif = append(aktif, t)
		}
	}
	return aktif
}

// CariIndeks mencari indeks tugas berdasarkan id pada slice.
// Fungsi mengembalikan -1 bila id tidak ditemukan.
func CariIndeks(tasks []Task, id int) int {
	for i, t := range tasks {
		if t.ID == id {
			return i
		}
	}
	return -1
}