Bagian penutup proyek ini menuntaskan dua kebutuhan: jaminan otomatis bahwa perilaku inti tetap benar, dan bentuk akhir yang bisa dibagikan. Pengujian memakai harness berbasis method main — mencetak PASS/FAIL per pemeriksaan dengan exit code — karena lingkungan kerja proyek ini belum menyertakan pustaka JUnit. Seluruh pengujian berjalan tanpa satu pun jendela terbuka, buah langsung dari disiplin UI tipis, logika tebal sejak Bagian 2. Setelah lulus, berkas sumber dirakit menjadi kontak.jar lalu dijalankan.

Mengapa Logika Inti Bisa Diuji Tanpa Layar #

Menguji aplikasi desktop secara manual berarti membuka jendela dan memeriksa tabel baris demi baris — JFrame bahkan melempar HeadlessException saat dibuat tanpa server grafis. Otomatisasi hanya mungkin bila yang diperiksa bukan jendelanya, melainkan aturan di baliknya. Proyek ini disusun agar begitu: DaftarKontak, KontakDao, dan KontakDaoBerkas tidak mengimport satu pun kelas Swing, sehingga seluruh aturannya bisa dipanggil dari method main mana pun. Pemetaan berikut menunjukkan siapa saja yang diuji harness:

LapisanMenyentuh Swing?Ikut harness
Kontak, DaftarKontak, KontakDaoMemori, KontakDaoBerkastidakya
KontakTableModel, PanelFormKontak, PanelTabelKontak, JendelaUtamayatidak
AplikasiKontakya (EDT)tidak — sadar-headless

Pemetaan itu menegaskan hukum proyek ini: semakin banyak aturan di lapisan bebas Swing, semakin banyak perilaku yang diperiksa otomatis. Komponen tampilan tetap teruji lewat doClick() pada DemoKontak Bagian 2, di luar harness ini.

Harness Pengujian Berbasis main #

UjiKontak menghimpun 27 pemeriksaan dalam enam kelompok: validasi masukan, operasi CRUD, pencarian, DAO memori, DAO berkas, dan penolakan berkas cacat. Setiap pemeriksaan bernama, hasilnya dicetak jelas, dan satu saja kegagalan mengubah exit code menjadi 1:

java UjiKontak.java
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

/**
 * Harness pengujian berbasis main: setiap pemeriksaan bernama,
 * hasil dicetak PASS/FAIL, dan kegagalan mengubah exit code menjadi 1.
 */
public class UjiKontak {

    private static int gagal = 0;

    public static void main(String[] args) throws Exception {
        ujiValidasi();
        ujiCrud();
        ujiPencarian();
        ujiDaoMemori();
        ujiDaoBerkas();
        ujiBerkasRusak();

        System.out.println();
        System.out.println("Pengujian selesai; jumlah gagal: " + gagal + ".");
        if (gagal > 0) {
            System.exit(1);
        }
    }

    private static void ujiValidasi() throws Exception {
        DaftarKontak daftar = new DaftarKontak();
        periksa("nama kosong ditolak",
                lempar(() -> daftar.tambah("   ", "[email protected]", "0812"),
                        KontakInvalidException.class));
        periksa("email tanpa @ ditolak",
                lempar(() -> daftar.tambah("Rina", "rina.example.com", "0812"),
                        KontakInvalidException.class));
        periksa("email tanpa titik pada domain ditolak",
                lempar(() -> daftar.tambah("Rina", "rina@localhost", "0812"),
                        KontakInvalidException.class));
        periksa("telepon kosong ditolak",
                lempar(() -> daftar.tambah("Rina", "[email protected]", "  "),
                        KontakInvalidException.class));
        periksa("field memuat karakter | ditolak",
                lempar(() -> daftar.tambah("Budi|Dua", "[email protected]", "0812"),
                        KontakInvalidException.class));

        DaftarKontak berisi = new DaftarKontak();
        berisi.tambah("Budi", "[email protected]", "0812-1000-1001");
        periksa("email duplikat ditolak",
                lempar(() -> berisi.tambah("Budi Kedua", "[email protected]", "0812"),
                        KontakInvalidException.class));
    }

    private static void ujiCrud() throws Exception {
        DaftarKontak daftar = new DaftarKontak();
        Kontak budi = daftar.tambah("  Budi  ", "[email protected]", "0812-1000-1001");
        daftar.tambah("Siti", "[email protected]", "0812-1000-1002");
        daftar.tambah("Dewi", "[email protected]", "0813-2000-2003");

        periksa("tambah mengembalikan id pertama 1", budi.id() == 1);
        periksa("tambah memangkas spasi field",
                budi.nama().equals("Budi"));
        periksa("id bertambah berurutan",
                daftar.semua().get(2).id() == 3);

        Kontak ubah = daftar.ubah(2, "Siti Rahayu", "[email protected]",
                "0815-3000-3004");
        periksa("ubah mempertahankan id",
                ubah.id() == 2 && daftar.cari(2).orElseThrow().nama().equals("Siti Rahayu"));
        periksa("ubah id tidak ada ditolak",
                lempar(() -> daftar.ubah(9, "X", "[email protected]", "0812"),
                        KontakTidakDitemukanException.class));

        Kontak terhapus = daftar.hapus(1);
        periksa("hapus mengembalikan kontak yang dihapus",
                terhapus.id() == 1 && daftar.jumlah() == 2);
        periksa("hapus kedua kali id sama ditolak",
                lempar(() -> daftar.hapus(1),
                        KontakTidakDitemukanException.class));
        periksa("id tidak dipakai ulang setelah hapus",
                daftar.tambah("Rina", "[email protected]", "0814-4000-4005").id() == 4);
    }

    private static void ujiPencarian() throws Exception {
        DaftarKontak daftar = new DaftarKontak();
        daftar.tambah("Budi", "[email protected]", "0812-1000-1001");
        daftar.tambah("Siti", "[email protected]", "0812-1000-1002");
        daftar.tambah("Dewi", "[email protected]", "0813-2000-2003");

        periksa("pencarian mengabaikan huruf besar",
                daftar.cari("SIT").size() == 1
                        && daftar.cari("SIT").get(0).nama().equals("Siti"));
        periksa("pencarian pada telepon menemukan dua kontak",
                daftar.cari("0812").size() == 2);
        periksa("pencarian tanpa hasil mengembalikan daftar kosong",
                daftar.cari("tidak-ada").isEmpty());
        periksa("cari id yang tidak ada mengembalikan kosong",
                daftar.cari(99).isEmpty());
    }

    private static void ujiDaoMemori() throws Exception {
        KontakDaoMemori dao = new KontakDaoMemori();
        dao.tambah(new Kontak(1, "Budi", "[email protected]", "0812-1000-1001"));
        dao.tambah(new Kontak(2, "Siti", "[email protected]", "0812-1000-1002"));
        dao.perbarui(new Kontak(2, "Siti Rahayu", "[email protected]", "0815-3000-3004"));

        periksa("dao memori memuat dua kontak", dao.muatSemua().size() == 2);
        periksa("dao memori menerapkan pembaruan",
                dao.muatSemua().get(1).nama().equals("Siti Rahayu"));
        dao.hapus(1);
        periksa("dao memori menghapus kontak", dao.muatSemua().size() == 1);
    }

    private static void ujiDaoBerkas() throws Exception {
        Path berkas = Path.of("uji-kontak.txt");
        Files.deleteIfExists(berkas);  // mulai kosong agar eksekusi berulang identik
        DaftarKontak daftar = new DaftarKontak(new KontakDaoBerkas(berkas));
        daftar.tambah("Budi", "[email protected]", "0812-1000-1001");
        daftar.tambah("Siti", "[email protected]", "0812-1000-1002");
        daftar.tambah("Dewi", "[email protected]", "0813-2000-2003");

        periksa("berkas tersimpan", Files.exists(berkas));
        periksa("baris pertama berkas benar",
                Files.readAllLines(berkas, StandardCharsets.UTF_8).get(0)
                        .equals("1|Budi|[email protected]|0812-1000-1001"));

        DaftarKontak termuat = new DaftarKontak(new KontakDaoBerkas(berkas));
        periksa("daftar baru memuat data dari berkas",
                termuat.jumlah() == 3 && termuat.cari(3).isPresent());

        daftar.ubah(2, "Siti Rahayu", "[email protected]", "0815-3000-3004");
        daftar.hapus(1);
        DaftarKontak akhir = new DaftarKontak(new KontakDaoBerkas(berkas));
        periksa("ubah dan hapus tercermin pada berkas",
                akhir.jumlah() == 2
                        && akhir.cari(2).orElseThrow().nama().equals("Siti Rahayu"));
        periksa("id berikutnya dilanjutkan dari berkas",
                akhir.tambah("Rina", "[email protected]", "0814-4000-4005").id() == 4);
    }

    private static void ujiBerkasRusak() throws Exception {
        Path rusak = Path.of("uji-rusak.txt");
        Files.writeString(rusak, "baris tanpa pemisah\n", StandardCharsets.UTF_8);
        periksa("baris rusak memicu PersistensiGagalException",
                lempar(() -> new DaftarKontak(new KontakDaoBerkas(rusak)),
                        PersistensiGagalException.class));
    }

    /** Mencetak hasil satu pemeriksaan dan menghitung kegagalan. */
    private static void periksa(String nama, boolean kondisi) {
        if (kondisi) {
            System.out.println("PASS " + nama);
        } else {
            System.out.println("FAIL " + nama);
            gagal++;
        }
    }

    /** Menjalankan perintah; true bila melempar exception bertipe persis. */
    private static boolean lempar(Perintah perintah,
            Class<? extends Exception> jenis) {
        try {
            perintah.jalankan();
            return false;
        } catch (Exception e) {
            return e.getClass() == jenis;
        }
    }

    /** Antarmuka satu method agar blok uji bisa melempar exception terperiksa. */
    interface Perintah {
        void jalankan() throws Exception;
    }
}

Output yang Dihasilkan:

text
PASS nama kosong ditolak
PASS email tanpa @ ditolak
PASS email tanpa titik pada domain ditolak
PASS telepon kosong ditolak
PASS field memuat karakter | ditolak
PASS email duplikat ditolak
PASS tambah mengembalikan id pertama 1
PASS tambah memangkas spasi field
PASS id bertambah berurutan
PASS ubah mempertahankan id
PASS ubah id tidak ada ditolak
PASS hapus mengembalikan kontak yang dihapus
PASS hapus kedua kali id sama ditolak
PASS id tidak dipakai ulang setelah hapus
PASS pencarian mengabaikan huruf besar
PASS pencarian pada telepon menemukan dua kontak
PASS pencarian tanpa hasil mengembalikan daftar kosong
PASS cari id yang tidak ada mengembalikan kosong
PASS dao memori memuat dua kontak
PASS dao memori menerapkan pembaruan
PASS dao memori menghapus kontak
PASS berkas tersimpan
PASS baris pertama berkas benar
PASS daftar baru memuat data dari berkas
PASS ubah dan hapus tercermin pada berkas
PASS id berikutnya dilanjutkan dari berkas
PASS baris rusak memicu PersistensiGagalException

Pengujian selesai; jumlah gagal: 0.

Bedah kerangkanya:

  1. Field gagal dan method periksa(...): satu tempat pencatatan kegagalan dan pencetakan hasil.
  2. Blok penutup main: ringkasan lalu System.exit(1) bila ada kegagalan; exit code menjadi sinyal pipeline otomatis.
  3. Method lempar(Perintah, Class): padanan pengujian “harus melempar”; perbandingan e.getClass() == jenis menolak exception salah tipe, dan antarmuka Perintah mengizinkan lambda melempar exception terperiksa.
  4. Method ujiDaoBerkas(): berkas sementara dihapus dulu lewat deleteIfExists agar eksekusi berulang selalu dimulai dari keadaan sama — syarat pengujian deterministik.
  5. Method ujiBerkasRusak(): berkas cacat sengaja ditulis lalu penolakannya diperiksa — kegagalan pembacaan adalah perilaku yang diuji.

Padanan JUnit 5 #

JUnit 5 adalah standar industri pengujian Java. Dengan JUnit, kelompok pemeriksaan yang sama menjadi method beranotasi @Test dan perbandingan nilai memakai assertion:

java Padanan JUnit 5 — membutuhkan dependency JUnit, tidak dieksekusi di lingkungan ini
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class UjiKontakJUnit {

    @Test
    void tambahMenetapkanIdBerurutan() throws Exception {
        DaftarKontak daftar = new DaftarKontak();
        Kontak budi = daftar.tambah("  Budi  ", "[email protected]", "0812-1000-1001");
        assertEquals(1, budi.id());
        assertEquals("Budi", budi.nama());
    }

    @Test
    void emailDuplikatDitolak() throws Exception {
        DaftarKontak daftar = new DaftarKontak();
        daftar.tambah("Budi", "[email protected]", "0812-1000-1001");
        assertThrows(KontakInvalidException.class,
                () -> daftar.tambah("Budi Kedua", "[email protected]", "0812"));
    }

    @Test
    void berkasRusakDitolak() throws Exception {
        Path rusak = Path.of("uji-junit-rusak.txt");
        Files.writeString(rusak, "baris tanpa pemisah\n", StandardCharsets.UTF_8);
        assertThrows(PersistensiGagalException.class,
                () -> new DaftarKontak(new KontakDaoBerkas(rusak)));
    }
}

Padanannya lugas: periksa(...) menjadi assertTrue atau assertEquals, helper lempar menjadi assertThrows, dan ringkasan exit code menjadi laporan test runner. Panduan lengkapnya ada di docs.junit.org; saat build tool tersedia, pindahkan pemeriksaan per kelompok.

Kompilasi dan Perakitan JAR #

Distribusi mengikuti pola chapter Packaging Aplikasi Desktop: kompilasi seluruh sumber, bungkus bytecode menjadi arsip JAR dengan titik masuk pada manifest, lalu jalankan:

Sesi berikut dikutip dari eksekusi sungguhan dengan JDK 25:

text
$ javac -d kelas *.java
$ jar --create --file kontak.jar --main-class AplikasiKontak -C kelas .
$ jar --list --file kontak.jar
META-INF/
META-INF/MANIFEST.MF
AplikasiKontak.class
DaftarKontak.class
JendelaUtama.class
Kontak.class
KontakDao.class
KontakDaoBerkas.class
KontakDaoMemori.class
KontakInvalidException.class
KontakTableModel.class
KontakTidakDitemukanException.class
PanelTabelKontak$1.class
PanelTabelKontak.class
PersistensiGagalException.class
UjiKontak$Perintah.class
UjiKontak.class
$ java -jar kontak.jar
Lingkungan tanpa layar terdeteksi; jendela tidak dapat dibuka.
Jalankan aplikasi ini pada komputer dengan tampilan grafis.

Dua baris keluaran itu adalah perilaku sadar-headless AplikasiKontak: main memeriksa GraphicsEnvironment.isHeadless() lebih dulu sehingga exit code tetap 0. Di komputer dengan tampilan grafis, perintah yang sama membuka jendela Buku Kontak.

Harness ikut berada di dalam arsip:

text
$ java -cp kontak.jar UjiKontak
PASS nama kosong ditolak
PASS email tanpa @ ditolak
PASS email tanpa titik pada domain ditolak
PASS telepon kosong ditolak
PASS field memuat karakter | ditolak
PASS email duplikat ditolak
PASS tambah mengembalikan id pertama 1
PASS tambah memangkas spasi field
PASS id bertambah berurutan
PASS ubah mempertahankan id
PASS ubah id tidak ada ditolak
PASS hapus mengembalikan kontak yang dihapus
PASS hapus kedua kali id sama ditolak
PASS id tidak dipakai ulang setelah hapus
PASS pencarian mengabaikan huruf besar
PASS pencarian pada telepon menemukan dua kontak
PASS pencarian tanpa hasil mengembalikan daftar kosong
PASS cari id yang tidak ada mengembalikan kosong
PASS dao memori memuat dua kontak
PASS dao memori menerapkan pembaruan
PASS dao memori menghapus kontak
PASS berkas tersimpan
PASS baris pertama berkas benar
PASS daftar baru memuat data dari berkas
PASS ubah dan hapus tercermin pada berkas
PASS id berikutnya dilanjutkan dari berkas
PASS baris rusak memicu PersistensiGagalException

Pengujian selesai; jumlah gagal: 0.

Bedah perintahnya:

  1. javac -d kelas *.java: compiler menerjemahkan empat belas berkas sumber sekaligus — hasilnya enam belas berkas .class, karena RowFilter anonim dan antarmuka Perintah menjadi berkas sendiri dengan tanda $.
  2. jar --create --file kontak.jar --main-class AplikasiKontak -C kelas .: alat jar membungkus isi kelas/, dan --main-class menuliskan Main-Class: AplikasiKontak ke manifest tiga baris.
  3. java -cp kontak.jar UjiKontak: mode classpath menyebut nama class eksplisit sehingga manifest tidak dibaca — cocok untuk program sekunder dalam arsip yang sama; exit code 0 menandakan 27 pemeriksaan lulus.

Citra Mandiri dengan jpackage #

java -jar kontak.jar menuntut runtime Java 25 di komputer pengguna. Untuk pengguna tanpa Java, jpackage membungkus arsip beserta runtime menjadi citra mandiri:

text
$ jpackage --type app-image --name buku-kontak \
      --input . --main-jar kontak.jar --main-class AplikasiKontak
$ ls buku-kontak
bin
lib
$ buku-kontak/bin/buku-kontak
Lingkungan tanpa layar terdeteksi; jendela tidak dapat dibuka.
Jalankan aplikasi ini pada komputer dengan tampilan grafis.

Direktori buku-kontak/ memuat bin/ berisi peluncur asli sistem operasi dan lib/ berisi aplikasi beserta runtime khusus. Pesan yang sama membuktikan rantai kemasan membawa aplikasi yang benar. Ganti --type menjadi deb, rpm, msi, atau dmg untuk pemasang asli — dibahas tuntas di chapter Packaging Aplikasi Desktop.

Proyek aplikasi desktop tuntas: antarmuka di Bagian 1, aturan data teruji di Bagian 2, penyimpanan lewat DAO di Bagian 3, harness dan kemasan di Bagian 4. Pola berlapis yang sama — aturan terpisah dari tampilan, penyimpanan di balik antarmuka — menjadi bekal proyek berikutnya, aplikasi-web-spring-boot, yang melayani banyak pengguna lewat HTTP.

Referensi #

Berkas Sumber

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

AplikasiKontak.java

Unduh berkas
java
import java.awt.GraphicsEnvironment;
import java.nio.file.Path;
import javax.swing.SwingUtilities;

/**
 * Titik masuk aplikasi Buku Kontak: memilih penyimpanan berkas,
 * memuat data, lalu membuka jendela utama pada Event Dispatch Thread.
 */
public class AplikasiKontak {

    public static void main(String[] args) {
        if (GraphicsEnvironment.isHeadless()) {
            System.out.println("Lingkungan tanpa layar terdeteksi; jendela tidak dapat dibuka.");
            System.out.println("Jalankan aplikasi ini pada komputer dengan tampilan grafis.");
            return;
        }
        SwingUtilities.invokeLater(AplikasiKontak::bukaJendela);
    }

    private static void bukaJendela() {
        DaftarKontak daftar;
        try {
            daftar = new DaftarKontak(new KontakDaoBerkas(Path.of("kontak.txt")));
        } catch (PersistensiGagalException e) {
            System.err.println("Gagal memuat penyimpanan: " + e.getMessage());
            daftar = new DaftarKontak();
        }
        new JendelaUtama(daftar).setVisible(true);
    }
}

DaftarKontak.java

Unduh berkas
java
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;

/**
 * Logika inti aplikasi Buku Kontak: daftar kontak dengan validasi,
 * operasi CRUD, dan pencarian. Kelas ini tidak mengenal satu pun
 * kelas Swing sehingga bisa diuji tanpa layar.
 */
public class DaftarKontak {

    private final KontakDao dao;
    private final List<Kontak> kontak = new ArrayList<>();
    private int idBerikutnya = 1;

    /** Daftar kontak tanpa penyimpanan permanen (penyimpanan memori). */
    public DaftarKontak() {
        this.dao = new KontakDaoMemori();
    }

    /**
     * Daftar kontak yang dimuat dari penyimpanan dan menulis
     * kembali ke penyimpanan yang sama pada setiap perubahan.
     */
    public DaftarKontak(KontakDao dao) throws PersistensiGagalException {
        this.dao = dao;
        kontak.addAll(dao.muatSemua());
        for (Kontak k : kontak) {
            idBerikutnya = Math.max(idBerikutnya, k.id() + 1);
        }
    }

    /** Menambah kontak baru setelah seluruh field tervalidasi. */
    public Kontak tambah(String nama, String email, String telepon)
            throws KontakInvalidException, PersistensiGagalException {
        periksa(nama, email, telepon);
        periksaEmailBelumTerpakai(email, 0);

        Kontak baru = new Kontak(idBerikutnya,
                nama.strip(), email.strip(), telepon.strip());
        dao.tambah(baru);
        kontak.add(baru);
        idBerikutnya++;
        return baru;
    }

    /** Menimpa kontak berdasarkan id dengan nilai field yang baru. */
    public Kontak ubah(int id, String nama, String email, String telepon)
            throws KontakInvalidException, KontakTidakDitemukanException,
            PersistensiGagalException {
        periksa(nama, email, telepon);
        periksaEmailBelumTerpakai(email, id);

        int posisi = indeksDariId(id);
        if (posisi < 0) {
            throw new KontakTidakDitemukanException(id);
        }
        Kontak baru = new Kontak(id,
                nama.strip(), email.strip(), telepon.strip());
        dao.perbarui(baru);
        kontak.set(posisi, baru);
        return baru;
    }

    /** Menghapus kontak berdasarkan id dan mengembalikan kontaknya. */
    public Kontak hapus(int id)
            throws KontakTidakDitemukanException, PersistensiGagalException {
        int posisi = indeksDariId(id);
        if (posisi < 0) {
            throw new KontakTidakDitemukanException(id);
        }
        Kontak terhapus = kontak.remove(posisi);
        dao.hapus(id);
        return terhapus;
    }

    /** Salinan seluruh kontak, terurut sesuai urutan penambahan. */
    public List<Kontak> semua() {
        return List.copyOf(kontak);
    }

    /** Kontak berdasarkan id, atau kosong bila tidak ada. */
    public Optional<Kontak> cari(int id) {
        for (Kontak k : kontak) {
            if (k.id() == id) {
                return Optional.of(k);
            }
        }
        return Optional.empty();
    }

    /** Kontak yang nama, email, atau teleponnya memuat kata kunci. */
    public List<Kontak> cari(String kataKunci) {
        String kunci = kataKunci.strip().toLowerCase(Locale.ROOT);
        List<Kontak> hasil = new ArrayList<>();
        for (Kontak k : kontak) {
            if (k.nama().toLowerCase(Locale.ROOT).contains(kunci)
                    || k.email().toLowerCase(Locale.ROOT).contains(kunci)
                    || k.telepon().toLowerCase(Locale.ROOT).contains(kunci)) {
                hasil.add(k);
            }
        }
        return hasil;
    }

    public int jumlah() {
        return kontak.size();
    }

    private int indeksDariId(int id) {
        for (int i = 0; i < kontak.size(); i++) {
            if (kontak.get(i).id() == id) {
                return i;
            }
        }
        return -1;
    }

    /** Validasi seluruh field sebelum kontak disimpan. */
    private static void periksa(String nama, String email, String telepon)
            throws KontakInvalidException {
        if (nama == null || nama.isBlank()) {
            throw new KontakInvalidException("nama tidak boleh kosong");
        }
        if (email == null || email.isBlank()) {
            throw new KontakInvalidException("email tidak boleh kosong");
        }
        if (telepon == null || telepon.isBlank()) {
            throw new KontakInvalidException("telepon tidak boleh kosong");
        }
        periksaFormatEmail(email.strip());
        periksaTanpaPemisah(nama.strip(), email.strip(), telepon.strip());
    }

    /** Email harus berformat [email protected]. */
    private static void periksaFormatEmail(String email)
            throws KontakInvalidException {
        int posisiAt = email.indexOf('@');
        if (posisiAt <= 0 || posisiAt != email.lastIndexOf('@')
                || email.indexOf('.', posisiAt) < 0) {
            throw new KontakInvalidException(
                    "email harus berformat [email protected]: " + email);
        }
    }

    /** Karakter | dipakai sebagai pemisah kolom pada berkas penyimpanan. */
    private static void periksaTanpaPemisah(String nama, String email, String telepon)
            throws KontakInvalidException {
        if (nama.indexOf('|') >= 0 || email.indexOf('|') >= 0
                || telepon.indexOf('|') >= 0) {
            throw new KontakInvalidException(
                    "field tidak boleh memuat karakter |");
        }
    }

    /** Email hanya boleh dipakai satu kontak. */
    private void periksaEmailBelumTerpakai(String email, int idKecuali)
            throws KontakInvalidException {
        for (Kontak k : kontak) {
            if (k.id() != idKecuali
                    && k.email().equalsIgnoreCase(email.strip())) {
                throw new KontakInvalidException(
                        "email sudah dipakai kontak lain: " + email.strip());
            }
        }
    }
}

JendelaUtama.java

Unduh berkas
java
import java.awt.BorderLayout;
import java.awt.FlowLayout;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTextField;

/**
 * Jendela utama aplikasi Buku Kontak.
 * JFrame tidak bisa dibuat pada lingkungan tanpa layar, sehingga
 * perakitan isi jendela dipisahkan ke method static bangunPanelTombol
 * yang bisa diuji tanpa membuka jendela nyata.
 */
public class JendelaUtama extends JFrame {

    public JendelaUtama(DaftarKontak daftar) {
        super("Buku Kontak");
        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

        KontakTableModel model = new KontakTableModel(daftar);
        PanelFormKontak form = new PanelFormKontak();
        PanelTabelKontak tabel = new PanelTabelKontak(model);
        JLabel status = new JLabel("Siap.");
        JPanel tombol = bangunPanelTombol(form, model, tabel, status);

        JPanel selatan = new JPanel(new BorderLayout(4, 4));
        selatan.add(tombol, BorderLayout.NORTH);
        selatan.add(status, BorderLayout.SOUTH);

        JPanel isi = new JPanel(new BorderLayout(8, 8));
        isi.add(form, BorderLayout.NORTH);
        isi.add(tabel, BorderLayout.CENTER);
        isi.add(selatan, BorderLayout.SOUTH);
        setContentPane(isi);

        pack();
        setLocationRelativeTo(null);
    }

    /**
     * Membangun panel tombol beserta seluruh aksinya.
     * Setiap tombol hanya membaca formulir, memanggil satu operasi
     * KontakTableModel, lalu melaporkan hasil pada label status.
     */
    static JPanel bangunPanelTombol(PanelFormKontak form, KontakTableModel model,
            PanelTabelKontak tabel, JLabel status) {
        JPanel tombol = new JPanel(new FlowLayout(FlowLayout.LEADING));

        JButton tambah = new JButton("Tambah");
        tambah.addActionListener(e -> {
            try {
                Kontak baru = model.tambah(form.nama(), form.email(), form.telepon());
                form.kosongkan();
                tabel.pilihBaris(model.jumlahBaris() - 1);
                status.setText("Kontak " + baru.nama() + " (id " + baru.id() + ") ditambahkan.");
            } catch (KontakInvalidException | PersistensiGagalException ex) {
                status.setText("Gagal menambah: " + ex.getMessage());
            }
        });

        JButton ubah = new JButton("Ubah");
        ubah.addActionListener(e -> {
            int id = tabel.idTerpilih();
            if (id < 0) {
                status.setText("Pilih kontak pada tabel terlebih dahulu.");
                return;
            }
            try {
                Kontak baru = model.ubah(id, form.nama(), form.email(), form.telepon());
                status.setText("Kontak id " + baru.id() + " diubah menjadi " + baru.nama() + ".");
            } catch (KontakInvalidException | KontakTidakDitemukanException
                    | PersistensiGagalException ex) {
                status.setText("Gagal mengubah: " + ex.getMessage());
            }
        });

        JButton hapus = new JButton("Hapus");
        hapus.addActionListener(e -> {
            int id = tabel.idTerpilih();
            if (id < 0) {
                status.setText("Pilih kontak pada tabel terlebih dahulu.");
                return;
            }
            try {
                Kontak terhapus = model.hapus(id);
                status.setText("Kontak " + terhapus.nama() + " (id " + id + ") dihapus.");
            } catch (KontakTidakDitemukanException | PersistensiGagalException ex) {
                status.setText("Gagal menghapus: " + ex.getMessage());
            }
        });

        JTextField isianCari = new JTextField(10);
        JButton cari = new JButton("Cari");
        cari.addActionListener(e -> {
            tabel.saring(isianCari.getText());
            status.setText(tabel.tabel().getRowCount() + " kontak cocok untuk \""
                    + isianCari.getText().strip() + "\".");
        });

        tombol.add(tambah);
        tombol.add(ubah);
        tombol.add(hapus);
        tombol.add(isianCari);
        tombol.add(cari);
        return tombol;
    }
}

Kontak.java

Unduh berkas
java
/**
 * Satu baris data kontak pada aplikasi Buku Kontak.
 * Record hanya menyimpan nilai; aturan bisnis hidup di DaftarKontak.
 */
public record Kontak(int id, String nama, String email, String telepon) {
}

KontakDao.java

Unduh berkas
java
import java.util.List;

/**
 * Kontrak penyimpanan kontak — pola DAO (Data Access Object).
 * DaftarKontak hanya mengenal antarmuka ini, sehingga jenis penyimpanan
 * bisa ditukar (memori, berkas, database) tanpa menyentuh kode lain.
 */
public interface KontakDao {

    /** Memuat seluruh kontak yang tersimpan. */
    List<Kontak> muatSemua() throws PersistensiGagalException;

    /** Menyimpan kontak baru ke penyimpanan. */
    void tambah(Kontak kontak) throws PersistensiGagalException;

    /** Menimpa kontak lama berdasarkan id dengan data baru. */
    void perbarui(Kontak kontak) throws PersistensiGagalException;

    /** Menghapus kontak berdasarkan id dari penyimpanan. */
    void hapus(int id) throws PersistensiGagalException;
}

KontakDaoBerkas.java

Unduh berkas
java
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

/**
 * Penyimpanan berkas teks: satu kontak per baris dengan format
 * id|nama|email|telepon. Setiap perubahan ditulis ulang seluruhnya
 * dengan charset UTF-8.
 */
public class KontakDaoBerkas implements KontakDao {

    private final Path berkas;

    public KontakDaoBerkas(Path berkas) {
        this.berkas = berkas;
    }

    @Override
    public List<Kontak> muatSemua() throws PersistensiGagalException {
        if (!Files.exists(berkas)) {
            return List.of();
        }
        List<Kontak> hasil = new ArrayList<>();
        try {
            List<String> barisSemua = Files.readAllLines(berkas, StandardCharsets.UTF_8);
            for (int i = 0; i < barisSemua.size(); i++) {
                String baris = barisSemua.get(i);
                if (baris.isBlank()) {
                    continue;
                }
                hasil.add(uraikan(baris, i + 1));
            }
            return hasil;
        } catch (IOException e) {
            throw new PersistensiGagalException(
                    "gagal membaca " + berkas, e);
        }
    }

    @Override
    public void tambah(Kontak kontak) throws PersistensiGagalException {
        List<Kontak> data = new ArrayList<>(muatSemua());
        data.add(kontak);
        tulisSemua(data);
    }

    @Override
    public void perbarui(Kontak kontak) throws PersistensiGagalException {
        List<Kontak> data = new ArrayList<>(muatSemua());
        for (int i = 0; i < data.size(); i++) {
            if (data.get(i).id() == kontak.id()) {
                data.set(i, kontak);
                tulisSemua(data);
                return;
            }
        }
        throw new PersistensiGagalException(
                "id " + kontak.id() + " tidak ada di " + berkas);
    }

    @Override
    public void hapus(int id) throws PersistensiGagalException {
        List<Kontak> data = new ArrayList<>(muatSemua());
        for (Kontak k : data) {
            if (k.id() == id) {
                data.remove(k);
                tulisSemua(data);
                return;
            }
        }
        throw new PersistensiGagalException(
                "id " + id + " tidak ada di " + berkas);
    }

    /** Mengubah satu baris berkas menjadi objek Kontak. */
    private static Kontak uraikan(String baris, int nomor)
            throws PersistensiGagalException {
        String[] bagian = baris.split("\\|", 4);
        if (bagian.length != 4) {
            throw new PersistensiGagalException(
                    "baris rusak pada nomor " + nomor + ": \"" + baris + "\"");
        }
        try {
            int id = Integer.parseInt(bagian[0].strip());
            return new Kontak(id, bagian[1], bagian[2], bagian[3]);
        } catch (NumberFormatException e) {
            throw new PersistensiGagalException(
                    "id bukan angka pada baris " + nomor + ": " + bagian[0]);
        }
    }

    /** Menulis ulang seluruh isi berkas dari daftar kontak. */
    private void tulisSemua(List<Kontak> data) throws PersistensiGagalException {
        StringBuilder teks = new StringBuilder();
        for (Kontak k : data) {
            teks.append(k.id()).append('|')
                    .append(k.nama()).append('|')
                    .append(k.email()).append('|')
                    .append(k.telepon()).append('\n');
        }
        try {
            Files.writeString(berkas, teks.toString(), StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new PersistensiGagalException(
                    "gagal menulis " + berkas, e);
        }
    }
}

KontakDaoMemori.java

Unduh berkas
java
import java.util.ArrayList;
import java.util.List;

/** Penyimpanan di memori: cepat, tanpa berkas, cocok untuk pengujian. */
public class KontakDaoMemori implements KontakDao {

    private final List<Kontak> simpanan = new ArrayList<>();

    @Override
    public List<Kontak> muatSemua() {
        return List.copyOf(simpanan);
    }

    @Override
    public void tambah(Kontak kontak) {
        simpanan.add(kontak);
    }

    @Override
    public void perbarui(Kontak kontak) throws PersistensiGagalException {
        int posisi = indeksDariId(kontak.id());
        if (posisi < 0) {
            throw new PersistensiGagalException(
                    "id " + kontak.id() + " tidak ada di penyimpanan memori");
        }
        simpanan.set(posisi, kontak);
    }

    @Override
    public void hapus(int id) throws PersistensiGagalException {
        int posisi = indeksDariId(id);
        if (posisi < 0) {
            throw new PersistensiGagalException(
                    "id " + id + " tidak ada di penyimpanan memori");
        }
        simpanan.remove(posisi);
    }

    private int indeksDariId(int id) {
        for (int i = 0; i < simpanan.size(); i++) {
            if (simpanan.get(i).id() == id) {
                return i;
            }
        }
        return -1;
    }
}

KontakInvalidException.java

Unduh berkas
java
/**
 * Masukan kontak yang melanggar aturan validasi:
 * field kosong, format email salah, email dipakai kontak lain,
 * atau field memuat karakter pemisah penyimpanan.
 */
public class KontakInvalidException extends Exception {

    public KontakInvalidException(String pesan) {
        super(pesan);
    }
}

KontakTableModel.java

Unduh berkas
java
import javax.swing.table.AbstractTableModel;

/**
 * TableModel kustom yang membaca DaftarKontak sebagai sumber data.
 * Operasi CRUD diteruskan ke DaftarKontak, lalu notifikasi perubahan
 * dikirim ke view lewat keluarga method fire... .
 */
public class KontakTableModel extends AbstractTableModel {

    private static final String[] KOLUM = { "Id", "Nama", "Email", "Telepon" };

    private final DaftarKontak daftar;

    public KontakTableModel(DaftarKontak daftar) {
        this.daftar = daftar;
    }

    @Override
    public int getRowCount() {
        return daftar.jumlah();
    }

    @Override
    public int getColumnCount() {
        return KOLUM.length;
    }

    @Override
    public String getColumnName(int kolom) {
        return KOLUM[kolom];
    }

    @Override
    public Object getValueAt(int baris, int kolom) {
        Kontak k = daftar.semua().get(baris);
        return switch (kolom) {
            case 0 -> k.id();
            case 1 -> k.nama();
            case 2 -> k.email();
            default -> k.telepon();
        };
    }

    /** Menambah kontak lewat DaftarKontak lalu memberi tahu view. */
    public Kontak tambah(String nama, String email, String telepon)
            throws KontakInvalidException, PersistensiGagalException {
        Kontak baru = daftar.tambah(nama, email, telepon);
        int baris = daftar.jumlah() - 1;
        fireTableRowsInserted(baris, baris);
        return baru;
    }

    /** Mengubah kontak lewat DaftarKontak lalu memberi tahu view. */
    public Kontak ubah(int id, String nama, String email, String telepon)
            throws KontakInvalidException, KontakTidakDitemukanException,
            PersistensiGagalException {
        int baris = barisDariId(id);
        Kontak baru = daftar.ubah(id, nama, email, telepon);
        fireTableRowsUpdated(baris, baris);
        return baru;
    }

    /** Menghapus kontak lewat DaftarKontak lalu memberi tahu view. */
    public Kontak hapus(int id)
            throws KontakTidakDitemukanException, PersistensiGagalException {
        int baris = barisDariId(id);
        Kontak terhapus = daftar.hapus(id);
        fireTableRowsDeleted(baris, baris);
        return terhapus;
    }

    /** Kontak pada baris model — dipakai aksi terhadap baris terpilih. */
    public Kontak kontakDiBaris(int baris) {
        return daftar.semua().get(baris);
    }

    /** Jumlah baris saat ini pada model. */
    public int jumlahBaris() {
        return daftar.jumlah();
    }

    private int barisDariId(int id) {
        for (int i = 0; i < daftar.jumlah(); i++) {
            if (daftar.semua().get(i).id() == id) {
                return i;
            }
        }
        return -1;
    }
}

KontakTidakDitemukanException.java

Unduh berkas
java
/** Id kontak tidak ada pada daftar saat kontak akan diubah atau dihapus. */
public class KontakTidakDitemukanException extends Exception {

    public KontakTidakDitemukanException(int id) {
        super("kontak dengan id " + id + " tidak ditemukan");
    }
}

PanelFormKontak.java

Unduh berkas
java
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTextField;

/**
 * Panel formulir kontak: tiga isian berjajar dengan label rata kanan.
 * Panel hanya membaca dan menulis komponen; validasi tetap di DaftarKontak.
 */
public class PanelFormKontak extends JPanel {

    private final JTextField isianNama = new JTextField(16);
    private final JTextField isianEmail = new JTextField(16);
    private final JTextField isianTelepon = new JTextField(16);

    public PanelFormKontak() {
        super(new GridBagLayout());
        GridBagConstraints k = new GridBagConstraints();
        k.insets = new Insets(4, 4, 4, 4);
        k.anchor = GridBagConstraints.LINE_END;

        String[] label = { "Nama:", "Email:", "Telepon:" };
        JTextField[] isian = { isianNama, isianEmail, isianTelepon };
        for (int i = 0; i < label.length; i++) {
            k.gridx = 0;
            k.gridy = i;
            add(new JLabel(label[i]), k);
            k.gridx = 1;
            k.fill = GridBagConstraints.HORIZONTAL;
            k.weightx = 1.0;
            add(isian[i], k);
            k.fill = GridBagConstraints.NONE;
            k.weightx = 0.0;
        }
    }

    /** Nilai isian nama, sudah dipangkas spasinya. */
    public String nama() {
        return isianNama.getText().strip();
    }

    /** Nilai isian email, sudah dipangkas spasinya. */
    public String email() {
        return isianEmail.getText().strip();
    }

    /** Nilai isian telepon, sudah dipangkas spasinya. */
    public String telepon() {
        return isianTelepon.getText().strip();
    }

    /** Mengisi formulir dari satu kontak — dipakai saat mengubah data. */
    public void isi(Kontak kontak) {
        isianNama.setText(kontak.nama());
        isianEmail.setText(kontak.email());
        isianTelepon.setText(kontak.telepon());
    }

    /** Mengosongkan seluruh isian setelah kontak selesai disimpan. */
    public void kosongkan() {
        isianNama.setText("");
        isianEmail.setText("");
        isianTelepon.setText("");
    }
}

PanelTabelKontak.java

Unduh berkas
java
import java.util.Locale;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.ListSelectionModel;
import javax.swing.RowFilter;
import javax.swing.table.TableModel;
import javax.swing.table.TableRowSorter;

/**
 * Panel tabel kontak: JTable di dalam JScrollPane.
 * Panel memasang TableRowSorter agar pencarian bisa menyaring baris
 * yang tampil tanpa mengubah data pada model.
 */
public class PanelTabelKontak extends JScrollPane {

    private final JTable tabel;
    private final TableRowSorter<TableModel> sorter;

    public PanelTabelKontak(KontakTableModel model) {
        super();
        tabel = new JTable(model);
        tabel.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
        sorter = new TableRowSorter<>(model);
        tabel.setRowSorter(sorter);
        setViewportView(tabel);
    }

    /** Tabel yang dipakai panel — pembacaan nilai lewat view ini. */
    public JTable tabel() {
        return tabel;
    }

    /** Id kontak pada baris terpilih, atau -1 bila tidak ada yang terpilih. */
    public int idTerpilih() {
        int barisTampil = tabel.getSelectedRow();
        if (barisTampil < 0) {
            return -1;
        }
        int barisModel = tabel.convertRowIndexToModel(barisTampil);
        return (int) tabel.getModel().getValueAt(barisModel, 0);
    }

    /** Menandai baris model tertentu pada tampilan. */
    public void pilihBaris(int barisModel) {
        int barisTampil = tabel.convertRowIndexToView(barisModel);
        if (barisTampil >= 0) {
            tabel.setRowSelectionInterval(barisTampil, barisTampil);
        } else {
            tabel.clearSelection();
        }
    }

    /**
     * Menyaring baris yang tampil dengan kata kunci pada kolom nama,
     * email, atau telepon. Kata kunci kosong menghapus saringan.
     */
    public void saring(String kataKunci) {
        String kunci = kataKunci == null ? "" : kataKunci.strip();
        if (kunci.isEmpty()) {
            sorter.setRowFilter(null);
            return;
        }
        sorter.setRowFilter(new RowFilter<TableModel, Integer>() {
            @Override
            public boolean include(Entry<? extends TableModel, ? extends Integer> entri) {
                for (int kolom = 1; kolom < entri.getValueCount(); kolom++) {
                    if (entri.getStringValue(kolom)
                            .toLowerCase(Locale.ROOT).contains(kunci)) {
                        return true;
                    }
                }
                return false;
            }
        });
    }
}

PersistensiGagalException.java

Unduh berkas
java
/** Kegagalan saat membaca atau menulis penyimpanan kontak. */
public class PersistensiGagalException extends Exception {

    public PersistensiGagalException(String pesan) {
        super(pesan);
    }

    /** Menyimpan sebab asli (misalnya IOException) agar bisa ditelusuri. */
    public PersistensiGagalException(String pesan, Throwable sebab) {
        super(pesan, sebab);
    }
}

UjiKontak.java

Unduh berkas
java
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

/**
 * Harness pengujian berbasis main: setiap pemeriksaan bernama,
 * hasil dicetak PASS/FAIL, dan kegagalan mengubah exit code menjadi 1.
 */
public class UjiKontak {

    private static int gagal = 0;

    public static void main(String[] args) throws Exception {
        ujiValidasi();
        ujiCrud();
        ujiPencarian();
        ujiDaoMemori();
        ujiDaoBerkas();
        ujiBerkasRusak();

        System.out.println();
        System.out.println("Pengujian selesai; jumlah gagal: " + gagal + ".");
        if (gagal > 0) {
            System.exit(1);
        }
    }

    private static void ujiValidasi() throws Exception {
        DaftarKontak daftar = new DaftarKontak();
        periksa("nama kosong ditolak",
                lempar(() -> daftar.tambah("   ", "[email protected]", "0812"),
                        KontakInvalidException.class));
        periksa("email tanpa @ ditolak",
                lempar(() -> daftar.tambah("Rina", "rina.example.com", "0812"),
                        KontakInvalidException.class));
        periksa("email tanpa titik pada domain ditolak",
                lempar(() -> daftar.tambah("Rina", "rina@localhost", "0812"),
                        KontakInvalidException.class));
        periksa("telepon kosong ditolak",
                lempar(() -> daftar.tambah("Rina", "[email protected]", "  "),
                        KontakInvalidException.class));
        periksa("field memuat karakter | ditolak",
                lempar(() -> daftar.tambah("Budi|Dua", "[email protected]", "0812"),
                        KontakInvalidException.class));

        DaftarKontak berisi = new DaftarKontak();
        berisi.tambah("Budi", "[email protected]", "0812-1000-1001");
        periksa("email duplikat ditolak",
                lempar(() -> berisi.tambah("Budi Kedua", "[email protected]", "0812"),
                        KontakInvalidException.class));
    }

    private static void ujiCrud() throws Exception {
        DaftarKontak daftar = new DaftarKontak();
        Kontak budi = daftar.tambah("  Budi  ", "[email protected]", "0812-1000-1001");
        daftar.tambah("Siti", "[email protected]", "0812-1000-1002");
        daftar.tambah("Dewi", "[email protected]", "0813-2000-2003");

        periksa("tambah mengembalikan id pertama 1", budi.id() == 1);
        periksa("tambah memangkas spasi field",
                budi.nama().equals("Budi"));
        periksa("id bertambah berurutan",
                daftar.semua().get(2).id() == 3);

        Kontak ubah = daftar.ubah(2, "Siti Rahayu", "[email protected]",
                "0815-3000-3004");
        periksa("ubah mempertahankan id",
                ubah.id() == 2 && daftar.cari(2).orElseThrow().nama().equals("Siti Rahayu"));
        periksa("ubah id tidak ada ditolak",
                lempar(() -> daftar.ubah(9, "X", "[email protected]", "0812"),
                        KontakTidakDitemukanException.class));

        Kontak terhapus = daftar.hapus(1);
        periksa("hapus mengembalikan kontak yang dihapus",
                terhapus.id() == 1 && daftar.jumlah() == 2);
        periksa("hapus kedua kali id sama ditolak",
                lempar(() -> daftar.hapus(1),
                        KontakTidakDitemukanException.class));
        periksa("id tidak dipakai ulang setelah hapus",
                daftar.tambah("Rina", "[email protected]", "0814-4000-4005").id() == 4);
    }

    private static void ujiPencarian() throws Exception {
        DaftarKontak daftar = new DaftarKontak();
        daftar.tambah("Budi", "[email protected]", "0812-1000-1001");
        daftar.tambah("Siti", "[email protected]", "0812-1000-1002");
        daftar.tambah("Dewi", "[email protected]", "0813-2000-2003");

        periksa("pencarian mengabaikan huruf besar",
                daftar.cari("SIT").size() == 1
                        && daftar.cari("SIT").get(0).nama().equals("Siti"));
        periksa("pencarian pada telepon menemukan dua kontak",
                daftar.cari("0812").size() == 2);
        periksa("pencarian tanpa hasil mengembalikan daftar kosong",
                daftar.cari("tidak-ada").isEmpty());
        periksa("cari id yang tidak ada mengembalikan kosong",
                daftar.cari(99).isEmpty());
    }

    private static void ujiDaoMemori() throws Exception {
        KontakDaoMemori dao = new KontakDaoMemori();
        dao.tambah(new Kontak(1, "Budi", "[email protected]", "0812-1000-1001"));
        dao.tambah(new Kontak(2, "Siti", "[email protected]", "0812-1000-1002"));
        dao.perbarui(new Kontak(2, "Siti Rahayu", "[email protected]", "0815-3000-3004"));

        periksa("dao memori memuat dua kontak", dao.muatSemua().size() == 2);
        periksa("dao memori menerapkan pembaruan",
                dao.muatSemua().get(1).nama().equals("Siti Rahayu"));
        dao.hapus(1);
        periksa("dao memori menghapus kontak", dao.muatSemua().size() == 1);
    }

    private static void ujiDaoBerkas() throws Exception {
        Path berkas = Path.of("uji-kontak.txt");
        Files.deleteIfExists(berkas);  // mulai kosong agar eksekusi berulang identik
        DaftarKontak daftar = new DaftarKontak(new KontakDaoBerkas(berkas));
        daftar.tambah("Budi", "[email protected]", "0812-1000-1001");
        daftar.tambah("Siti", "[email protected]", "0812-1000-1002");
        daftar.tambah("Dewi", "[email protected]", "0813-2000-2003");

        periksa("berkas tersimpan", Files.exists(berkas));
        periksa("baris pertama berkas benar",
                Files.readAllLines(berkas, StandardCharsets.UTF_8).get(0)
                        .equals("1|Budi|[email protected]|0812-1000-1001"));

        DaftarKontak termuat = new DaftarKontak(new KontakDaoBerkas(berkas));
        periksa("daftar baru memuat data dari berkas",
                termuat.jumlah() == 3 && termuat.cari(3).isPresent());

        daftar.ubah(2, "Siti Rahayu", "[email protected]", "0815-3000-3004");
        daftar.hapus(1);
        DaftarKontak akhir = new DaftarKontak(new KontakDaoBerkas(berkas));
        periksa("ubah dan hapus tercermin pada berkas",
                akhir.jumlah() == 2
                        && akhir.cari(2).orElseThrow().nama().equals("Siti Rahayu"));
        periksa("id berikutnya dilanjutkan dari berkas",
                akhir.tambah("Rina", "[email protected]", "0814-4000-4005").id() == 4);
    }

    private static void ujiBerkasRusak() throws Exception {
        Path rusak = Path.of("uji-rusak.txt");
        Files.writeString(rusak, "baris tanpa pemisah\n", StandardCharsets.UTF_8);
        periksa("baris rusak memicu PersistensiGagalException",
                lempar(() -> new DaftarKontak(new KontakDaoBerkas(rusak)),
                        PersistensiGagalException.class));
    }

    /** Mencetak hasil satu pemeriksaan dan menghitung kegagalan. */
    private static void periksa(String nama, boolean kondisi) {
        if (kondisi) {
            System.out.println("PASS " + nama);
        } else {
            System.out.println("FAIL " + nama);
            gagal++;
        }
    }

    /** Menjalankan perintah; true bila melempar exception bertipe persis. */
    private static boolean lempar(Perintah perintah,
            Class<? extends Exception> jenis) {
        try {
            perintah.jalankan();
            return false;
        } catch (Exception e) {
            return e.getClass() == jenis;
        }
    }

    /** Antarmuka satu method agar blok uji bisa melempar exception terperiksa. */
    interface Perintah {
        void jalankan() throws Exception;
    }
}