Lapisan terakhir yang belum dibedah adalah storage.go — bagian yang membuat tugas bertahan setelah program ditutup. Desainnya sengaja dibuat tipis: satu struct kecil bernama Penyimpanan yang tahu lokasi berkas, dua method untuk membaca dan menulis, dan satu jaminan penting bahwa berkas tidak pernah rusak setengah jalan.
Format Berkas #
Seluruh daftar tugas disimpan sebagai satu array JSON dengan indentasi dua spasi. Berikut isi tugas.json untuk tiga tugas dari sesi Bagian 3 — waktunya berasal dari fixture test yang dibekukan agar contoh ini deterministik; pada pemakaian nyata, kolom dibuat berisi waktu aktual saat tugas dibuat:
[
{
"id": 1,
"judul": "Belajar Go",
"selesai": false,
"dibuat": "2026-01-15T08:00:00Z"
},
{
"id": 2,
"judul": "Menulis dokumentasi",
"selesai": true,
"dibuat": "2026-01-15T10:00:00Z"
},
{
"id": 3,
"judul": "Rilis aplikasi",
"selesai": false,
"dibuat": "2026-01-15T12:00:00Z"
}
]Nama kunci id, judul, selesai, dan dibuat berasal dari struct tag pada Task di Bagian 2, sedangkan format waktu seperti 2026-01-15T08:00:00Z adalah hasil time.Time yang di-encode sebagai RFC 3339 — standar yang juga dipakai API modern.
Membaca: Method Muat #
Pembacaan menangani dua keadaan khusus sebelum mengembalikan data:
// 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
}- Baris 26 (
os.ReadFile(p.berkas)): seluruh isi berkas dibaca sekaligus — cukup untuk berkas kecil seperti daftar tugas. - Baris 28-30: berkas yang belum ada bukanlah galat.
errors.Is(err, fs.ErrNotExist)mengenali kondisi tersebut dan mengembalikan daftar kosong, karena absennya berkas berarti aplikasi dipakai pertama kali. - Baris 31: kegagalan baca lain — misalnya izin ditolak — tetap dilaporkan sebagai error dengan konteks path berkasnya.
- Baris 34-37:
json.Unmarshalmengubah teks JSON menjadi[]Task; isi yang rusak menghasilkan error yang dibungkus dengan%wsehingga penyebabnya terbawa.
Menulis Secara Atomik: Method Simpan #
Penulisan memakai pola tulis-ke-berkas-sementara lalu ganti-nama:
// 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')Baris 47-49 mengganti slice nil dengan slice kosong sebelum di-encode. Tanpa penggantian ini, json.MarshalIndent(nil) menghasilkan teks null alih-alih [] — dua makna yang berbeda bagi pembaca berkas. Baris 55 menambahkan baris baru di akhir berkas sesuai konvensi berkas teks Unix.
Kemudian bagian atomiknya:
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
}- Baris 57 (
os.CreateTemp(filepath.Dir(p.berkas), "tugas-*.tmp")): berkas sementara dibuat di direktori yang sama dengan berkas asli — syarat penting agaros.Renamepada baris 72 tidak melintasi sistem berkas yang berbeda. - Baris 63-71: data ditulis dan berkas sementara ditutup. Setiap kegagalan membersihkan dirinya sendiri lewat
os.Remove(namaTmp)sehingga tidak ada sampah.tmptertinggal. - Baris 72 (
os.Rename(namaTmp, p.berkas)): penggantian nama bersifat atomik pada sistem berkas yang sama — pembaca lain tidak pernah melihattugas.jsondalam keadaan setengah tertulis.
Berkas sementara diberi awalan tugas- dan akhiran .tmp agar mudah dikenali; Bagian 5 memverifikasi bahwa tidak ada berkas semacam itu tertinggal setelah Simpan.
Kode lengkap storage.go tersedia pada bagian Berkas Sumber di bawah halaman ini.
Ringkasan Bagian 4 #
Penyimpanan selesai: Muat membedakan aplikasi baru dari berkas bermasalah, Simpan menulis secara atomik lewat temp-rename, dan format JSON-nya menjaga kunci serta waktu yang rapi. Satu lapisan lagi yang belum teruji: seluruh janji dari keempat berkas produksi dibuktikan oleh test pada Bagian 5.
Referensi #
- Dokumentasi encoding/json
— perilaku
MarshalIndent,Unmarshal, dannull. - Dokumentasi os.CreateTemp — pola berkas sementara.
- JSON and Go — artikel resmi dasar encoding JSON.
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
}