Model dari Bagian 2 belum bisa apa-apa sampai ada lapisan yang menerjemahkan perintah terminal menjadi operasi atas daftar tugas. Pada bagian ini kita menulis cli.go: satu fungsi Run yang memilah subperintah, empat fungsi operasi yang mengikuti pola sama, dan pencetak tabel yang merapikan hasil. Kontrak exit code dari halaman overview diterapkan di setiap cabang kegagalan.
Dispatch Subperintah #
Seluruh perintah masuk lewat satu pintu bernama Run:
// 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
}
}Mari kita telaah baris-baris pentingnya:
- Baris 25 (
func Run(args []string, penyimpanan *Penyimpanan, sekarang func() time.Time, out, errOut io.Writer) int): seluruh dependency luar masuk sebagai parameter — argumen, penyimpanan, jam, dan dua jalur keluaran. Nilai balikintadalah exit code yang diteruskanmainkeos.Exit. - Baris 26-29: aplikasi tanpa subperintah menampilkan pesan penggunaan dan mengembalikan
exitPenggunaan. - Baris 31-32 (
perintah, sisa := args[0], args[1:]): argumen pertama menjadi nama subperintah, sisanya diteruskan ke fungsi operasi.switchkemudian memetakan nama ke fungsinya. - Baris 41-44: nama yang tidak dikenal jatuh ke cabang
default— pesan galat ditulis keerrOutlaluexitPenggunaandikembalikan.
Perintah tambah #
perintahTambah mengikuti alur validasi-muat-ubah-simpan:
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
}- Baris 57-61: jumlah argumen diperiksa lebih dulu — judul harus tepat satu.
- Baris 63-67:
ValidasiJuduldari Bagian 2 menolak judul kosong dengan sentinel error-nya. - Baris 69-73: daftar tugas dimuat dari penyimpanan; kegagalan pembacaan dilaporkan sebagai
exitData. - Baris 75-80:
Taskbaru dibangun denganIDBerikutnyadanDibuat: sekarang()— jam tidak dipanggil langsung daritime.Nowmelainkan lewat parameter, sesuai rancangan Bagian 1. - Baris 87 (
fmt.Fprintf(out, ...)): konfirmasi ditulis keoutsehingga pemanggil skrip dapat menyaringnya dari pesan galat.
Perintah list dan Tabel Rapi #
list memakai flag.FlagSet miliknya sendiri untuk flag --semua:
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
}Tiga keadaan ditangani terpisah pada baris 111-119: penyimpanan kosong, semua tugas sudah selesai, dan keadaan normal yang mencetak tabel. Sebelum dicetak, baris 117 mengurutkan hasil dengan slices.SortStableFunc(terpilih, Bandingkan) — pembanding dari Bagian 2 — sehingga tampilan tetap terurut meski urutan di berkas berubah. Pencetaknya memakai text/tabwriter:
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()
}Tabwriter menghitung lebar tiap kolom dari isi terpanjang lalu merapatkan seluruh baris; fungsi kita hanya perlu mengirim sel dipisah tab \t seperti pada baris 131. Hasilnya kolom sejajar sempurna tanpa format manual.
Perintah selesai dan hapus #
Kedua subperintah ini mengubah satu tugas berdasarkan id. Pola perintahSelesai:
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
}Alurnya empat tahap: parseID memvalidasi argumen (baris 137), Muat memuat data (baris 142), CariIndeks mencari posisi tugas (baris 148) dengan id tak ditemukan dilaporkan sebagai exitData, lalu field Selesai diubah dan seluruh daftar disimpan kembali (baris 154-158). perintahHapus mengikuti pola yang sama persis dengan dua perbedaan: salinan tugas disimpan dulu ke terhapus sebelum elemen dibuang lewat slices.Delete(tasks, i, i+1), dan pesan konfirmasinya menyebut tugas yang dihapus.
Validasi id terkumpul di satu fungsi bantu:
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
}strconv.Atoi menolak teks non-angka, dan pemeriksaan id <= 0 menolak nol dan angka negatif — keduanya jenis kesalahan penggunaan dengan exit code 1.
Sesi Uji Lengkap #
Mari kita jalankan seluruh alur dengan penyimpanan di berkas sementara:
T=$(mktemp -d)
go run . -data "$T/tugas.json" tambah "Belajar Go"
go run . -data "$T/tugas.json" tambah "Tulis dokumentasi"
go run . -data "$T/tugas.json" tambah "Rilis aplikasi"
go run . -data "$T/tugas.json" selesai 2
go run . -data "$T/tugas.json" list
go run . -data "$T/tugas.json" list --semua
go run . -data "$T/tugas.json" hapus 1
go run . -data "$T/tugas.json" listOutput yang Dihasilkan:
Tugas 1 "Belajar Go" tersimpan.
Tugas 2 "Tulis dokumentasi" tersimpan.
Tugas 3 "Rilis aplikasi" tersimpan.
Tugas 2 "Tulis dokumentasi" ditandai selesai.
ID STATUS JUDUL
1 [ ] Belajar Go
3 [ ] Rilis aplikasi
ID STATUS JUDUL
1 [ ] Belajar Go
2 [x] Tulis dokumentasi
3 [ ] Rilis aplikasi
Tugas 1 "Belajar Go" dihapus.
ID STATUS JUDUL
3 [ ] Rilis aplikasiPerhatikan tiga perilaku pada keluaran: list tanpa --semua menyembunyikan tugas id 2 yang sudah selesai; list --semua menampilkannya dengan penanda [x]; dan setelah tugas 1 dihapus, tugas 3 tetap mempertahankan id aslinya karena IDBerikutnya tidak pernah memakai ulang nomor.
Kode lengkap cli.go tersedia pada bagian Berkas Sumber di bawah halaman ini.
Ringkasan Bagian 3 #
Empat subperintah kini utuh: dispatch satu pintu di Run, alur validasi-muat-ubah-simpan yang konsisten, tabel rapi dari tabwriter, dan id yang divalidasi di satu tempat. Yang belum dibedah adalah isi Muat dan Simpan yang selama ini dipanggil terus-menerus — Bagian 4 membuka penyimpanan JSON tersebut.
Referensi #
- Dokumentasi text/tabwriter — cara kerja penjusaran kolom otomatis.
- Dokumentasi package flag
—
FlagSetuntuk flag per-subperintah. - Dokumentasi package slices
—
SortStableFuncdanDelete.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
cli_test.go
Unduh berkaspackage 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 berkaspackage 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 berkaspackage 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 berkaspackage 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 berkaspackage 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 berkaspackage 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 berkaspackage 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
}