Sejauh ini perpustakaan kita hidup selama proses JVM berjalan: begitu program berhenti, katalog, daftar anggota, dan pinjaman yang belum selesai hilang tanpa jejak. Bagian penutup ini memberi sistem kemampuan bertahan — state lengkap disimpan ke berkas teks berformat baris, lalu dibaca kembali menjadi objek yang siap bekerja, semuanya hanya dengan java.nio.file. Setelah persistensi berjalan, kita bangun harness pengujian berbasis main yang membuktikan setiap aturan bisnis dari Bagian 1 sampai 3, ditutup padanan kodenya pada JUnit 5. Dua keterbatasan lingkungan menjadi keputusan desain: tidak ada pustaka luar, dan seluruh keluaran harus deterministik.
Format Baris Terstruktur #
Object serialization (ObjectOutputStream) memang sekali baris jalan, tetapi format binernya rapuh terhadap perubahan class dan tidak bisa dibaca manusia. Kita memilih format teks sederhana: satu baris per record, kolom dipisah karakter |, tanggal memakai format ISO bawaan LocalDate:
BUKU|978-602-421-123-4|Laskar Pelangi|Andrea Hirata|2005
ANGGOTA|A-001|Budi Santoso|[email protected]|2026-01-02
PEMINJAMAN|978-602-421-123-4|A-001|2026-01-05|2026-01-19|-Kolom terakhir PEMINJAMAN memakai tanda - untuk transaksi yang masih berjalan — penanda yang lebih aman daripada string kosong karena baris kosong mudah hilang saat dibaca mata. Baris kosong dan baris berawalan # diperlakukan sebagai komentar dan dilewati pembaca.
Exception Terkendali untuk Prosedur Berkas #
Kegagalan berkas berbeda karakter dari pelanggaran aturan domain: kegagalan tersebut bersifat prosedural dan setiap pemanggil wajib menyadarinya. Karena itu exception penyimpanan dibuat checked, lengkap dengan konstruktor cause agar IOException dari lapisan bawah tetap tertinggal jejaknya:
// Kegagalan prosedur penyimpanan/pembacaan berkas: checked karena
// setiap pemanggil wajib menyadarinya secara eksplisit.
public class PersistensiGagalException extends Exception {
public PersistensiGagalException(String pesan) {
super(pesan);
}
public PersistensiGagalException(String pesan, Throwable cause) {
super(pesan, cause);
}
}Menyimpan dan Memuat State #
Class utilitas PenyimpananPerpustakaan memegang dua prosedur: simpan menulis seluruh isi Perpustakaan, muat membangun objek baru dari isi berkas. Keduanya memakai try-with-resources dengan Files.newBufferedWriter/newBufferedReader dan charset eksplisit StandardCharsets.UTF_8:
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
// Penyimpanan berbasis berkas: satu baris satu record, dipisah karakter '|'.
// Format:
// BUKU|isbn|judul|penulis|tahunTerbit
// ANGGOTA|id|nama|email|terdaftar
// PEMINJAMAN|isbn|idAnggota|tanggalPinjam|jatuhTempo|tanggalKembali(- bila masih aktif)
public final class PenyimpananPerpustakaan {
private PenyimpananPerpustakaan() {
// kumpulan prosedur statis: tidak perlu dibuat objeknya
}
public static void simpan(Perpustakaan perpus, Path berkas)
throws PersistensiGagalException {
try (BufferedWriter tulis = Files.newBufferedWriter(berkas, StandardCharsets.UTF_8)) {
for (Buku buku : perpus.semuaBuku()) {
tulis.write("BUKU|" + buku.isbn() + "|" + buku.judul() + "|"
+ buku.penulis() + "|" + buku.tahunTerbit());
tulis.newLine();
}
for (Anggota a : perpus.semuaAnggota()) {
tulis.write("ANGGOTA|" + a.id() + "|" + a.nama() + "|"
+ a.email() + "|" + a.terdaftar());
tulis.newLine();
}
for (Peminjaman p : perpus.riwayat()) {
String kembali = p.tanggalKembali() == null
? "-" : p.tanggalKembali().toString();
tulis.write("PEMINJAMAN|" + p.buku().isbn() + "|" + p.anggota().id() + "|"
+ p.tanggalPinjam() + "|" + p.jatuhTempo() + "|" + kembali);
tulis.newLine();
}
} catch (IOException e) {
throw new PersistensiGagalException("gagal menyimpan ke " + berkas, e);
}
}
public static Perpustakaan muat(Path berkas) throws PersistensiGagalException {
Perpustakaan perpus = new Perpustakaan();
try (BufferedReader baca = Files.newBufferedReader(berkas, StandardCharsets.UTF_8)) {
String baris;
while ((baris = baca.readLine()) != null) {
if (baris.isBlank() || baris.startsWith("#")) {
continue; // baris kosong dan komentar dilewati
}
String[] bagian = baris.split("\\|", -1);
switch (bagian[0]) {
case "BUKU" -> perpus.tambahBuku(new Buku(
bagian[1], bagian[2], bagian[3], Integer.parseInt(bagian[4])));
case "ANGGOTA" -> perpus.daftarkan(new Anggota(
bagian[1], bagian[2], bagian[3], LocalDate.parse(bagian[4])));
case "PEMINJAMAN" -> {
Buku buku = perpus.cariBukuIsbn(bagian[1]);
Anggota anggota = perpus.cariAnggota(bagian[2]);
LocalDate kembali = "-".equals(bagian[5])
? null : LocalDate.parse(bagian[5]);
perpus.pulihkanPeminjaman(new Peminjaman(
buku, anggota, LocalDate.parse(bagian[3]),
LocalDate.parse(bagian[4]), kembali));
}
default -> throw new PersistensiGagalException(
"baris tidak dikenal: " + baris);
}
}
} catch (IOException e) {
throw new PersistensiGagalException("gagal memuat dari " + berkas, e);
}
return perpus;
}
}Pembacaan memakai split("\\|", -1) — argumen -1 menjaga kolom kosong di ujung agar tidak dipangkas. Switch ekspresi menyeleksi jenis baris, dan baris yang tidak dikenal langsung ditolak, bukan didiamkan. Dua class lain berubah sedikit untuk mendukung pemulihan: Peminjaman mendapat constructor lima argumen yang mengisi seluruh field langsung (dengan validasi konsistensi tanggal), dan Perpustakaan mendapat pulihkanPeminjaman yang mengembalikan transaksi hasil pembacaan ke riwayat serta — bila masih aktif — ke peta sirkulasi:
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
// Transaksi sirkulasi: berubah saat buku dikembalikan, plus hitungan denda.
public class Peminjaman {
/** Lama peminjaman standar, dalam hari. */
public static final int LAMA_PINJAM_HARI = 14;
private final Buku buku;
private final Anggota anggota;
private final LocalDate tanggalPinjam;
private final LocalDate jatuhTempo;
private LocalDate tanggalKembali; // null selama buku belum kembali
public Peminjaman(Buku buku, Anggota anggota, LocalDate tanggalPinjam) {
this(buku, anggota, tanggalPinjam,
tanggalPinjam.plusDays(LAMA_PINJAM_HARI), null);
}
/** Constructor pemulihan: seluruh field diisi langsung saat memuat berkas. */
public Peminjaman(Buku buku, Anggota anggota, LocalDate tanggalPinjam,
LocalDate jatuhTempo, LocalDate tanggalKembali) {
this.buku = Objects.requireNonNull(buku, "buku tidak boleh null");
this.anggota = Objects.requireNonNull(anggota, "anggota tidak boleh null");
this.tanggalPinjam =
Objects.requireNonNull(tanggalPinjam, "tanggalPinjam tidak boleh null");
this.jatuhTempo =
Objects.requireNonNull(jatuhTempo, "jatuhTempo tidak boleh null");
if (jatuhTempo.isBefore(tanggalPinjam)) {
throw new IllegalArgumentException(
"jatuhTempo " + jatuhTempo + " lebih awal dari tanggalPinjam " + tanggalPinjam);
}
if (tanggalKembali != null && tanggalKembali.isBefore(tanggalPinjam)) {
throw new IllegalArgumentException(
"tanggalKembali " + tanggalKembali + " lebih awal dari tanggalPinjam "
+ tanggalPinjam);
}
this.tanggalKembali = tanggalKembali;
}
public Buku buku() {
return buku;
}
public Anggota anggota() {
return anggota;
}
public LocalDate tanggalPinjam() {
return tanggalPinjam;
}
public LocalDate jatuhTempo() {
return jatuhTempo;
}
/** Tanggal kembali aktual; null bila buku masih di tangan anggota. */
public LocalDate tanggalKembali() {
return tanggalKembali;
}
/** Apakah buku masih dipinjam? */
public boolean aktif() {
return tanggalKembali == null;
}
/** Catat pengembalian dan kunci transaksi agar tidak dikembalikan dua kali. */
public void kembalikan(LocalDate tanggal) {
Objects.requireNonNull(tanggal, "tanggal tidak boleh null");
if (tanggal.isBefore(tanggalPinjam)) {
throw new IllegalArgumentException(
"tanggal kembali " + tanggal + " lebih awal dari tanggal pinjam "
+ tanggalPinjam);
}
if (!aktif()) {
throw new IllegalStateException("peminjaman sudah selesai");
}
this.tanggalKembali = tanggal;
}
/**
* Selisih hari dari jatuh tempo; 0 bila tidak terlambat.
* Acuan: tanggal kembali aktual bila transaksi sudah selesai,
* atau tanggalHitung untuk pinjaman yang masih berjalan.
*/
public long hariTerlambat(LocalDate tanggalHitung) {
LocalDate acuan = (tanggalKembali != null) ? tanggalKembali : tanggalHitung;
return Math.max(0, ChronoUnit.DAYS.between(jatuhTempo, acuan));
}
/** Denda = hari terlambat dikali tarif per hari; tanpa pembulatan. */
public BigDecimal denda(LocalDate tanggalHitung, BigDecimal tarifPerHari) {
Objects.requireNonNull(tarifPerHari, "tarifPerHari tidak boleh null");
return BigDecimal.valueOf(hariTerlambat(tanggalHitung)).multiply(tarifPerHari);
}
// toString eksplisit: hanya memuat data domain, tanpa alamat objek.
@Override
public String toString() {
return "Peminjaman[judul=" + buku.judul() + ", anggota=" + anggota.nama()
+ ", pinjam=" + tanggalPinjam + ", jatuhTempo=" + jatuhTempo
+ ", kembali=" + (aktif() ? "-" : tanggalKembali) + "]";
}
}import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
// Layanan perpustakaan: katalog, keanggotaan, dan sirkulasi peminjaman.
public class Perpustakaan {
/** Jumlah buku maksimum yang boleh dipinjam satu anggota. */
public static final int BATAS_PINJAMAN_PER_ANGGOTA = 3;
/** Denda keterlambatan per hari. */
public static final BigDecimal TARIF_DENDA_PER_HARI = new BigDecimal("500");
private final Map<String, Buku> katalog = new LinkedHashMap<>();
private final Map<String, Anggota> anggota = new LinkedHashMap<>();
// Invarian: satu ISBN hanya boleh punya satu pinjaman aktif.
private final Map<String, Peminjaman> pinjamanAktif = new LinkedHashMap<>();
private final List<Peminjaman> riwayat = new ArrayList<>();
public void tambahBuku(Buku buku) {
Buku sebelumnya = katalog.putIfAbsent(buku.isbn(), buku);
if (sebelumnya != null) {
throw new PerpustakaanException(
"ISBN sudah terdaftar: " + buku.isbn());
}
}
public void daftarkan(Anggota baru) {
Anggota sebelumnya = anggota.putIfAbsent(baru.id(), baru);
if (sebelumnya != null) {
throw new PerpustakaanException(
"ID anggota sudah terdaftar: " + baru.id());
}
}
public Buku cariBukuIsbn(String isbn) {
Buku buku = katalog.get(isbn);
if (buku == null) {
throw new BukuTidakDitemukanException(
"ISBN tidak ada di katalog: " + isbn);
}
return buku;
}
public Anggota cariAnggota(String id) {
Anggota dicari = anggota.get(id);
if (dicari == null) {
throw new AnggotaTidakDitemukanException(
"ID anggota tidak terdaftar: " + id);
}
return dicari;
}
public List<Buku> cariBuku(String kataKunci) {
List<Buku> hasil = new ArrayList<>();
for (Buku buku : katalog.values()) {
if (buku.cocokKataKunci(kataKunci)) {
hasil.add(buku);
}
}
return hasil;
}
/**
* Pinjam sebuah buku. Melempar BukuTidakDitemukanException,
* AnggotaTidakDitemukanException, BukuDipinjamException, atau
* BatasPeminjamanTercapaiException bila aturan dilanggar.
*/
public Peminjaman pinjam(String isbn, String idAnggota, LocalDate tanggal) {
Buku buku = cariBukuIsbn(isbn);
Anggota peminjam = cariAnggota(idAnggota);
if (pinjamanAktif.containsKey(isbn)) {
throw new BukuDipinjamException(
"buku sudah dipinjam oleh " + pinjamanAktif.get(isbn).anggota().nama()
+ ": " + buku.judul());
}
if (hitungPinjamanAktif(idAnggota) >= BATAS_PINJAMAN_PER_ANGGOTA) {
throw new BatasPeminjamanTercapaiException(
"anggota " + peminjam.nama() + " sudah memegang "
+ BATAS_PINJAMAN_PER_ANGGOTA + " buku");
}
Peminjaman baru = new Peminjaman(buku, peminjam, tanggal);
pinjamanAktif.put(isbn, baru);
riwayat.add(baru);
return baru;
}
/**
* Kembalikan buku yang sedang dipinjam. Transaksi hanya dilepas dari
* sirkulasi setelah pengembalian dinyatakan sah oleh Peminjaman.
*/
public Peminjaman kembalikan(String isbn, LocalDate tanggal) {
Peminjaman pinjaman = pinjamanAktif.get(isbn);
if (pinjaman == null) {
throw new PerpustakaanException(
"buku tidak sedang dipinjam: " + isbn);
}
pinjaman.kembalikan(tanggal); // bisa melempar bila tanggal tidak sah
pinjamanAktif.remove(isbn); // dijalankan hanya bila pengembalian sah
return pinjaman;
}
/**
* Pulihkan transaksi hasil pembacaan berkas: masuk ke riwayat dan,
* bila masih aktif, kembali ke peta sirkulasi.
*/
public void pulihkanPeminjaman(Peminjaman pulihan) {
Objects.requireNonNull(pulihan, "pulihan tidak boleh null");
riwayat.add(pulihan);
if (pulihan.aktif()) {
pinjamanAktif.put(pulihan.buku().isbn(), pulihan);
}
}
/** Pinjaman aktif milik satu anggota, dalam urutan peminjaman. */
public List<Peminjaman> pinjamanAktifAnggota(String idAnggota) {
List<Peminjaman> hasil = new ArrayList<>();
for (Peminjaman p : pinjamanAktif.values()) {
if (p.anggota().id().equals(idAnggota)) {
hasil.add(p);
}
}
return hasil;
}
/** Seluruh transaksi (aktif dan selesai) dalam urutan kejadian. */
public List<Peminjaman> riwayat() {
return List.copyOf(riwayat);
}
public int jumlahPinjamanAktif() {
return pinjamanAktif.size();
}
public List<Buku> semuaBuku() {
return List.copyOf(katalog.values());
}
public List<Anggota> semuaAnggota() {
return List.copyOf(anggota.values());
}
public int jumlahBuku() {
return katalog.size();
}
public int jumlahAnggota() {
return anggota.size();
}
private int hitungPinjamanAktif(String idAnggota) {
int jumlah = 0;
for (Peminjaman p : pinjamanAktif.values()) {
if (p.anggota().id().equals(idAnggota)) {
jumlah++;
}
}
return jumlah;
}
}Demo Simpan dan Muat #
Program berikut membangun keadaan kecil — tiga buku, dua anggota, satu transaksi selesai, satu transaksi berjalan — menyimpannya ke perpustakaan.txt, lalu memuat salinan yang harus setara:
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
// Demo persistensi: simpan state ke berkas, muat kembali, buktikan kesetaraan.
public class LayarDemo {
private static final Path BERKAS = Path.of("perpustakaan.txt");
public static void main(String[] args) throws PersistensiGagalException {
Perpustakaan perpus = bangunKeadaan();
PenyimpananPerpustakaan.simpan(perpus, BERKAS);
System.out.println("tersimpan : " + Files.exists(BERKAS));
Perpustakaan salinan = PenyimpananPerpustakaan.muat(BERKAS);
System.out.println("buku : " + perpus.jumlahBuku() + " -> " + salinan.jumlahBuku());
System.out.println("anggota : " + perpus.jumlahAnggota() + " -> " + salinan.jumlahAnggota());
System.out.println("riwayat : " + perpus.riwayat().size() + " -> " + salinan.riwayat().size());
System.out.println("aktif : " + perpus.jumlahPinjamanAktif()
+ " -> " + salinan.jumlahPinjamanAktif());
// Record bernilai sama tetap setara setelah perjalanan lewat berkas.
Buku asli = perpus.cariBukuIsbn("978-602-421-123-4");
Buku hasilMuat = salinan.cariBukuIsbn("978-602-421-123-4");
System.out.println("setara : " + asli.equals(hasilMuat));
// Pinjaman aktif tetap hidup: salinan dapat menyelesaikan transaksi.
Peminjaman selesai = salinan.kembalikan(
"978-602-401-718-3", LocalDate.of(2026, 1, 25));
System.out.println("kembali : " + selesai);
System.out.println("denda : " + selesai.hariTerlambat(LocalDate.of(2026, 1, 25))
+ " hari terlambat, " + formatRupiah(
selesai.denda(LocalDate.of(2026, 1, 25), Perpustakaan.TARIF_DENDA_PER_HARI)));
// Isi berkas yang tertulis (charset UTF-8 eksplisit).
String isi;
try {
isi = Files.readString(BERKAS, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new PersistensiGagalException("gagal membaca " + BERKAS, e);
}
System.out.println("--- isi perpustakaan.txt ---");
System.out.print(isi);
}
static Perpustakaan bangunKeadaan() {
Perpustakaan perpus = new Perpustakaan();
perpus.tambahBuku(new Buku("978-602-421-123-4", "Laskar Pelangi",
"Andrea Hirata", 2005));
perpus.tambahBuku(new Buku("978-602-401-718-3", "Filosofi Teras",
"Henry Manampiring", 2018));
perpus.tambahBuku(new Buku("978-602-291-024-4", "Laut Bercerita",
"Leila S. Chudori", 2017));
perpus.daftarkan(new Anggota("A-001", "Budi Santoso",
"[email protected]", LocalDate.of(2026, 1, 2)));
perpus.daftarkan(new Anggota("A-002", "Siti Rahayu",
"[email protected]", LocalDate.of(2026, 1, 3)));
// Satu transaksi sudah selesai (tepat waktu), satu masih berjalan.
perpus.pinjam("978-602-421-123-4", "A-001", LocalDate.of(2026, 1, 5));
perpus.kembalikan("978-602-421-123-4", LocalDate.of(2026, 1, 10));
perpus.pinjam("978-602-401-718-3", "A-002", LocalDate.of(2026, 1, 5));
return perpus;
}
// Format uang sederhana: 3000 menjadi "Rp3.000".
static String formatRupiah(BigDecimal nilai) {
return "Rp" + nilai.setScale(0).toPlainString()
.replaceAll("\\B(?=(\\d{3})+(?!\\d))", ".");
}
}Output yang Dihasilkan:
tersimpan : true
buku : 3 -> 3
anggota : 2 -> 2
riwayat : 2 -> 2
aktif : 1 -> 1
setara : true
kembali : Peminjaman[judul=Filosofi Teras, anggota=Siti Rahayu, pinjam=2026-01-05, jatuhTempo=2026-01-19, kembali=2026-01-25]
denda : 6 hari terlambat, Rp3.000
--- isi perpustakaan.txt ---
BUKU|978-602-421-123-4|Laskar Pelangi|Andrea Hirata|2005
BUKU|978-602-401-718-3|Filosofi Teras|Henry Manampiring|2018
BUKU|978-602-291-024-4|Laut Bercerita|Leila S. Chudori|2017
ANGGOTA|A-001|Budi Santoso|[email protected]|2026-01-02
ANGGOTA|A-002|Siti Rahayu|[email protected]|2026-01-03
PEMINJAMAN|978-602-421-123-4|A-001|2026-01-05|2026-01-19|2026-01-10
PEMINJAMAN|978-602-401-718-3|A-002|2026-01-05|2026-01-19|-Tiga bukti pada keluaran tersebut. Baris buku : 3 -> 3 hingga aktif : 1 -> 1 menunjukkan seluruh jumlah pulih identik. Baris setara : true membuktikan equals record tetap bekerja setelah data menempuh perjalanan lewat berkas — objek berbeda, nilai sama. Baris kembali dan denda membuktikan pinjaman aktif hasil pemulihan benar-benar hidup: salinan dapat menyelesaikan transaksi Siti dan menghitung enam hari keterlambatan dengan denda Rp3.000.
Harness Pengujian Tanpa Pustaka Luar #
Lingkungan proyek ini tidak membawa JUnit, sehingga pengujian memakai pola harness berbasis main: satu method periksa(nama, kondisi) mencetak PASS/FAIL dan menghitung kegagalan, helper melempar memeriksa exception yang diharapkan, dan exit code 1 bila ada satu saja kegagalan:
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
// Harness pengujian berbasis main: tanpa pustaka uji, cukup JDK murni.
// Setiap periksa() mencetak PASS/FAIL; exit code bukan nol bila ada kegagalan.
public class UjiPerpustakaan {
private static final String ISBN_LASKAR = "978-602-421-123-4";
private static final String ISBN_FILOSOFI = "978-602-401-718-3";
private static final String ISBN_LAUT = "978-602-291-024-4";
private static final String ISBN_BUMI = "978-979-433-555-1";
private static int gagal = 0;
public static void main(String[] args) throws PersistensiGagalException {
ujiKatalog();
ujiSirkulasi();
ujiDenda();
ujiPersistensi();
System.out.println();
System.out.println(gagal == 0 ? "SEMUA UJI LULUS" : gagal + " UJI GAGAL");
System.exit(gagal == 0 ? 0 : 1);
}
static void ujiKatalog() {
System.out.println("== Katalog ==");
Perpustakaan perpus = perpusBaru();
periksa("buku duplikat ditolak", melempar(() -> perpus.tambahBuku(
new Buku(ISBN_LASKAR, "Laskar Pelangi", "Andrea Hirata", 2005)),
PerpustakaanException.class));
periksa("anggota duplikat ditolak", melempar(() -> perpus.daftarkan(
new Anggota("A-001", "Budi Santoso", "[email protected]",
LocalDate.of(2026, 1, 2))),
PerpustakaanException.class));
periksa("pencarian mengabaikan huruf besar", perpus.cariBuku("LASKAR").size() == 1);
periksa("pencarian tanpa hasil menghasilkan list kosong",
perpus.cariBuku("java").isEmpty());
}
static void ujiSirkulasi() {
System.out.println("== Sirkulasi ==");
Perpustakaan perpus = perpusBaru();
periksa("ISBN tidak dikenal ditolak", melempar(
() -> perpus.pinjam("978-000-000-000-0", "A-001", LocalDate.of(2026, 1, 5)),
BukuTidakDitemukanException.class));
periksa("anggota tidak terdaftar ditolak", melempar(
() -> perpus.pinjam(ISBN_LASKAR, "A-099", LocalDate.of(2026, 1, 5)),
AnggotaTidakDitemukanException.class));
perpus.pinjam(ISBN_LASKAR, "A-001", LocalDate.of(2026, 1, 5));
perpus.pinjam(ISBN_FILOSOFI, "A-001", LocalDate.of(2026, 1, 5));
perpus.pinjam(ISBN_LAUT, "A-001", LocalDate.of(2026, 1, 5));
periksa("buku yang sudah dipinjam ditolak", melempar(
() -> perpus.pinjam(ISBN_LASKAR, "A-002", LocalDate.of(2026, 1, 5)),
BukuDipinjamException.class));
periksa("batas tiga buku per anggota", melempar(
() -> perpus.pinjam(ISBN_BUMI, "A-001", LocalDate.of(2026, 1, 5)),
BatasPeminjamanTercapaiException.class));
perpus.kembalikan(ISBN_LASKAR, LocalDate.of(2026, 1, 10));
Peminjaman pinjamSiti = perpus.pinjam(ISBN_LASKAR, "A-002", LocalDate.of(2026, 1, 11));
periksa("buku yang dikembalikan dapat dipinjam lagi",
pinjamSiti.anggota().id().equals("A-002"));
perpus.kembalikan(ISBN_FILOSOFI, LocalDate.of(2026, 1, 10));
periksa("pengembalian ganda ditolak", melempar(
() -> perpus.kembalikan(ISBN_FILOSOFI, LocalDate.of(2026, 1, 12)),
PerpustakaanException.class));
periksa("buku yang tidak dipinjam tidak dapat dikembalikan", melempar(
() -> perpus.kembalikan(ISBN_BUMI, LocalDate.of(2026, 1, 12)),
PerpustakaanException.class));
}
static void ujiDenda() {
System.out.println("== Denda ==");
Perpustakaan perpus = perpusBaru();
Peminjaman tepat = perpus.pinjam(ISBN_LASKAR, "A-001", LocalDate.of(2026, 1, 5));
perpus.kembalikan(ISBN_LASKAR, LocalDate.of(2026, 1, 10));
periksa("pengembalian tepat waktu berdenda nol",
tepat.denda(LocalDate.of(2026, 1, 10), Perpustakaan.TARIF_DENDA_PER_HARI)
.compareTo(BigDecimal.ZERO) == 0);
Peminjaman terlambat = perpus.pinjam(ISBN_FILOSOFI, "A-002", LocalDate.of(2026, 1, 5));
perpus.kembalikan(ISBN_FILOSOFI, LocalDate.of(2026, 1, 25));
periksa("enam hari terlambat berdenda 3000",
terlambat.denda(LocalDate.of(2026, 1, 25), Perpustakaan.TARIF_DENDA_PER_HARI)
.compareTo(new BigDecimal("3000")) == 0);
Peminjaman berjalan = perpus.pinjam(ISBN_LAUT, "A-001", LocalDate.of(2026, 1, 5));
periksa("denda berjalan dihitung dari tanggal hitung",
berjalan.denda(LocalDate.of(2026, 2, 1), Perpustakaan.TARIF_DENDA_PER_HARI)
.compareTo(new BigDecimal("6500")) == 0);
}
static void ujiPersistensi() throws PersistensiGagalException {
System.out.println("== Persistensi ==");
Perpustakaan perpus = perpusBaru();
perpus.pinjam(ISBN_LASKAR, "A-001", LocalDate.of(2026, 1, 5));
perpus.kembalikan(ISBN_LASKAR, LocalDate.of(2026, 1, 10));
perpus.pinjam(ISBN_FILOSOFI, "A-002", LocalDate.of(2026, 1, 5));
Path berkas = Path.of("uji-perpustakaan.txt");
Path berkasRusak = Path.of("uji-rusak.txt");
try {
PenyimpananPerpustakaan.simpan(perpus, berkas);
Perpustakaan salinan = PenyimpananPerpustakaan.muat(berkas);
periksa("jumlah buku tetap setelah muat",
salinan.jumlahBuku() == perpus.jumlahBuku());
periksa("jumlah anggota tetap setelah muat",
salinan.jumlahAnggota() == perpus.jumlahAnggota());
periksa("riwayat tetap setelah muat",
salinan.riwayat().size() == perpus.riwayat().size());
periksa("pinjaman aktif dipulihkan", salinan.jumlahPinjamanAktif() == 1);
periksa("buku setara setelah perjalanan berkas",
salinan.cariBukuIsbn(ISBN_LASKAR).equals(perpus.cariBukuIsbn(ISBN_LASKAR)));
// Baris tidak dikenal harus ditolak, bukan didiamkan.
tulisBerkasRusak(berkasRusak);
try {
PenyimpananPerpustakaan.muat(berkasRusak);
periksa("baris tidak dikenal ditolak", false);
} catch (PersistensiGagalException e) {
periksa("baris tidak dikenal ditolak",
e.getMessage().contains("baris tidak dikenal"));
}
} finally {
hapusBilaAda(berkas);
hapusBilaAda(berkasRusak);
}
}
/** Cetak PASS/FAIL satu pernyataan dan akumulasikan jumlah kegagalan. */
static void periksa(String nama, boolean kondisi) {
if (kondisi) {
System.out.println("PASS " + nama);
} else {
System.out.println("FAIL " + nama);
gagal++;
}
}
/** Benar bila aksi melempar exception jenis yang diharapkan. */
static boolean melempar(Runnable aksi, Class<? extends Throwable> jenis) {
try {
aksi.run();
return false; // tidak melempar apa pun: gagal
} catch (Throwable t) {
return jenis.isInstance(t);
}
}
static void hapusBilaAda(Path berkas) {
try {
Files.deleteIfExists(berkas);
} catch (IOException e) {
// berkas uji tidak terhapus: tidak memengaruhi hasil pengujian
}
}
/** Siapkan berkas berisi baris rusak; kegagalan menulis = galat persiapan uji. */
static void tulisBerkasRusak(Path berkas) {
try {
Files.writeString(berkas, "SAMPAH|bukan|format\n", StandardCharsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException("tidak dapat menulis berkas uji: " + berkas, e);
}
}
static Perpustakaan perpusBaru() {
Perpustakaan perpus = new Perpustakaan();
perpus.tambahBuku(new Buku(ISBN_LASKAR, "Laskar Pelangi",
"Andrea Hirata", 2005));
perpus.tambahBuku(new Buku(ISBN_FILOSOFI, "Filosofi Teras",
"Henry Manampiring", 2018));
perpus.tambahBuku(new Buku(ISBN_LAUT, "Laut Bercerita",
"Leila S. Chudori", 2017));
perpus.tambahBuku(new Buku(ISBN_BUMI, "Bumi Manusia",
"Pramoedya Ananta Toer", 1980));
perpus.daftarkan(new Anggota("A-001", "Budi Santoso",
"[email protected]", LocalDate.of(2026, 1, 2)));
perpus.daftarkan(new Anggota("A-002", "Siti Rahayu",
"[email protected]", LocalDate.of(2026, 1, 3)));
return perpus;
}
}Output yang Dihasilkan:
== Katalog ==
PASS buku duplikat ditolak
PASS anggota duplikat ditolak
PASS pencarian mengabaikan huruf besar
PASS pencarian tanpa hasil menghasilkan list kosong
== Sirkulasi ==
PASS ISBN tidak dikenal ditolak
PASS anggota tidak terdaftar ditolak
PASS buku yang sudah dipinjam ditolak
PASS batas tiga buku per anggota
PASS buku yang dikembalikan dapat dipinjam lagi
PASS pengembalian ganda ditolak
PASS buku yang tidak dipinjam tidak dapat dikembalikan
== Denda ==
PASS pengembalian tepat waktu berdenda nol
PASS enam hari terlambat berdenda 3000
PASS denda berjalan dihitung dari tanggal hitung
== Persistensi ==
PASS jumlah buku tetap setelah muat
PASS jumlah anggota tetap setelah muat
PASS riwayat tetap setelah muat
PASS pinjaman aktif dipulihkan
PASS buku setara setelah perjalanan berkas
PASS baris tidak dikenal ditolak
SEMUA UJI LULUSPadanan JUnit 5 #
Bentuk JUnit 5 dari dua uji di atas berbunyi demikian — kode berikut konsep yang tidak dieksekusi di lingkungan ini karena JUnit bukan bagian JDK:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class PerpustakaanUji {
@Test
void bukuDuplikatDitolak() {
Perpustakaan perpus = new Perpustakaan();
perpus.tambahBuku(new Buku("978-602-421-123-4",
"Laskar Pelangi", "Andrea Hirata", 2005));
assertThrows(PerpustakaanException.class, () -> perpus.tambahBuku(
new Buku("978-602-421-123-4", "Laskar Pelangi",
"Andrea Hirata", 2005)));
}
@Test
void dendaEnamHariTerlambat() {
Perpustakaan perpus = new Perpustakaan();
perpus.tambahBuku(new Buku("978-602-421-123-4",
"Laskar Pelangi", "Andrea Hirata", 2005));
perpus.daftarkan(new Anggota("A-001", "Budi Santoso",
"[email protected]", LocalDate.of(2026, 1, 2)));
Peminjaman pinjaman = perpus.pinjam(
"978-602-421-123-4", "A-001", LocalDate.of(2026, 1, 5));
perpus.kembalikan("978-602-421-123-4", LocalDate.of(2026, 1, 25));
assertEquals(0, pinjaman.denda(LocalDate.of(2026, 1, 25),
Perpustakaan.TARIF_DENDA_PER_HARI)
.compareTo(new BigDecimal("3000")));
}
}Padanan pernyataannya lugas: periksa(..., kondisi) menjadi assertTrue, pembandingan denda menjadi assertEquals(0, ...compareTo(...)), dan helper melempar menjadi assertThrows. Ketika proyek kelak memakai Maven atau Gradle, harness main dapat dipindahkan ke JUnit tanpa mengubah satu pun aturan bisnis yang diuji.
Proyek selesai dalam empat bagian: model domain beku (record) dan transaksi yang berubah (class), katalog dan keanggotaan berbasis collection, sirkulasi dengan invarian dan denda presisi, lalu persistensi serta pengujian — seluruhnya ± 20 berkas Java murni tanpa satu pun pustaka luar. Langkah lanjut yang pas: ganti lapisan penyimpanan dengan database lewat JDBC, atau bawa domain yang sama ke proyek Aplikasi Inventaris untuk melihat pola serupa pada dunia stok barang.
Referensi #
- Java API Specification: java.nio.file.Files
- Java API Specification: java.nio.charset.StandardCharsets
- JUnit 5 User Guide
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
Anggota.java
Unduh berkasimport java.time.LocalDate;
// Keanggotaan: identitas anggota tidak berubah setelah pendaftaran.
public record Anggota(String id, String nama, String email, LocalDate terdaftar) {
public Anggota {
if (id == null || !id.matches("A-\\d{3}")) {
throw new IllegalArgumentException(
"id harus berformat A-NNN, diterima: " + id);
}
if (nama == null || nama.isBlank()) {
throw new IllegalArgumentException("nama wajib diisi");
}
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException(
"email harus memuat karakter '@', diterima: " + email);
}
nama = nama.trim();
email = email.trim();
}
}
AnggotaTidakDitemukanException.java
Unduh berkas// Nomor anggota yang dicari tidak terdaftar.
public class AnggotaTidakDitemukanException extends PerpustakaanException {
public AnggotaTidakDitemukanException(String pesan) {
super(pesan);
}
}
BatasPeminjamanTercapaiException.java
Unduh berkas// Anggota sudah memegang jumlah buku maksimum yang diizinkan.
public class BatasPeminjamanTercapaiException extends PerpustakaanException {
public BatasPeminjamanTercapaiException(String pesan) {
super(pesan);
}
}
Buku.java
Unduh berkasimport java.util.Locale;
// Model katalog: data buku beku (record) + pencocokan kata kunci pencarian.
public record Buku(String isbn, String judul, String penulis, int tahunTerbit) {
public Buku {
if (isbn == null || isbn.isBlank()) {
throw new IllegalArgumentException("isbn wajib diisi");
}
if (judul == null || judul.isBlank()) {
throw new IllegalArgumentException("judul wajib diisi");
}
if (tahunTerbit <= 0) {
throw new IllegalArgumentException(
"tahunTerbit harus positif: " + tahunTerbit);
}
isbn = isbn.trim();
judul = judul.trim();
penulis = (penulis == null || penulis.isBlank()) ? "Tanpa Nama" : penulis.trim();
}
/** Apakah judul, penulis, atau ISBN memuat kata kunci (huruf besar diabaikan)? */
public boolean cocokKataKunci(String kataKunci) {
String kunci = kataKunci.toLowerCase(Locale.ROOT);
return judul().toLowerCase(Locale.ROOT).contains(kunci)
|| penulis().toLowerCase(Locale.ROOT).contains(kunci)
|| isbn().contains(kunci);
}
}
BukuDipinjamException.java
Unduh berkas// Buku yang ingin dipinjam sedang berada di tangan anggota lain.
public class BukuDipinjamException extends PerpustakaanException {
public BukuDipinjamException(String pesan) {
super(pesan);
}
}
BukuTidakDitemukanException.java
Unduh berkas// Buku yang dicari tidak ada di katalog.
public class BukuTidakDitemukanException extends PerpustakaanException {
public BukuTidakDitemukanException(String pesan) {
super(pesan);
}
}
LayarDemo.java
Unduh berkasimport java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
// Demo persistensi: simpan state ke berkas, muat kembali, buktikan kesetaraan.
public class LayarDemo {
private static final Path BERKAS = Path.of("perpustakaan.txt");
public static void main(String[] args) throws PersistensiGagalException {
Perpustakaan perpus = bangunKeadaan();
PenyimpananPerpustakaan.simpan(perpus, BERKAS);
System.out.println("tersimpan : " + Files.exists(BERKAS));
Perpustakaan salinan = PenyimpananPerpustakaan.muat(BERKAS);
System.out.println("buku : " + perpus.jumlahBuku() + " -> " + salinan.jumlahBuku());
System.out.println("anggota : " + perpus.jumlahAnggota() + " -> " + salinan.jumlahAnggota());
System.out.println("riwayat : " + perpus.riwayat().size() + " -> " + salinan.riwayat().size());
System.out.println("aktif : " + perpus.jumlahPinjamanAktif()
+ " -> " + salinan.jumlahPinjamanAktif());
// Record bernilai sama tetap setara setelah perjalanan lewat berkas.
Buku asli = perpus.cariBukuIsbn("978-602-421-123-4");
Buku hasilMuat = salinan.cariBukuIsbn("978-602-421-123-4");
System.out.println("setara : " + asli.equals(hasilMuat));
// Pinjaman aktif tetap hidup: salinan dapat menyelesaikan transaksi.
Peminjaman selesai = salinan.kembalikan(
"978-602-401-718-3", LocalDate.of(2026, 1, 25));
System.out.println("kembali : " + selesai);
System.out.println("denda : " + selesai.hariTerlambat(LocalDate.of(2026, 1, 25))
+ " hari terlambat, " + formatRupiah(
selesai.denda(LocalDate.of(2026, 1, 25), Perpustakaan.TARIF_DENDA_PER_HARI)));
// Isi berkas yang tertulis (charset UTF-8 eksplisit).
String isi;
try {
isi = Files.readString(BERKAS, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new PersistensiGagalException("gagal membaca " + BERKAS, e);
}
System.out.println("--- isi perpustakaan.txt ---");
System.out.print(isi);
}
static Perpustakaan bangunKeadaan() {
Perpustakaan perpus = new Perpustakaan();
perpus.tambahBuku(new Buku("978-602-421-123-4", "Laskar Pelangi",
"Andrea Hirata", 2005));
perpus.tambahBuku(new Buku("978-602-401-718-3", "Filosofi Teras",
"Henry Manampiring", 2018));
perpus.tambahBuku(new Buku("978-602-291-024-4", "Laut Bercerita",
"Leila S. Chudori", 2017));
perpus.daftarkan(new Anggota("A-001", "Budi Santoso",
"[email protected]", LocalDate.of(2026, 1, 2)));
perpus.daftarkan(new Anggota("A-002", "Siti Rahayu",
"[email protected]", LocalDate.of(2026, 1, 3)));
// Satu transaksi sudah selesai (tepat waktu), satu masih berjalan.
perpus.pinjam("978-602-421-123-4", "A-001", LocalDate.of(2026, 1, 5));
perpus.kembalikan("978-602-421-123-4", LocalDate.of(2026, 1, 10));
perpus.pinjam("978-602-401-718-3", "A-002", LocalDate.of(2026, 1, 5));
return perpus;
}
// Format uang sederhana: 3000 menjadi "Rp3.000".
static String formatRupiah(BigDecimal nilai) {
return "Rp" + nilai.setScale(0).toPlainString()
.replaceAll("\\B(?=(\\d{3})+(?!\\d))", ".");
}
}
Peminjaman.java
Unduh berkasimport java.math.BigDecimal;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
// Transaksi sirkulasi: berubah saat buku dikembalikan, plus hitungan denda.
public class Peminjaman {
/** Lama peminjaman standar, dalam hari. */
public static final int LAMA_PINJAM_HARI = 14;
private final Buku buku;
private final Anggota anggota;
private final LocalDate tanggalPinjam;
private final LocalDate jatuhTempo;
private LocalDate tanggalKembali; // null selama buku belum kembali
public Peminjaman(Buku buku, Anggota anggota, LocalDate tanggalPinjam) {
this(buku, anggota, tanggalPinjam,
tanggalPinjam.plusDays(LAMA_PINJAM_HARI), null);
}
/** Constructor pemulihan: seluruh field diisi langsung saat memuat berkas. */
public Peminjaman(Buku buku, Anggota anggota, LocalDate tanggalPinjam,
LocalDate jatuhTempo, LocalDate tanggalKembali) {
this.buku = Objects.requireNonNull(buku, "buku tidak boleh null");
this.anggota = Objects.requireNonNull(anggota, "anggota tidak boleh null");
this.tanggalPinjam =
Objects.requireNonNull(tanggalPinjam, "tanggalPinjam tidak boleh null");
this.jatuhTempo =
Objects.requireNonNull(jatuhTempo, "jatuhTempo tidak boleh null");
if (jatuhTempo.isBefore(tanggalPinjam)) {
throw new IllegalArgumentException(
"jatuhTempo " + jatuhTempo + " lebih awal dari tanggalPinjam " + tanggalPinjam);
}
if (tanggalKembali != null && tanggalKembali.isBefore(tanggalPinjam)) {
throw new IllegalArgumentException(
"tanggalKembali " + tanggalKembali + " lebih awal dari tanggalPinjam "
+ tanggalPinjam);
}
this.tanggalKembali = tanggalKembali;
}
public Buku buku() {
return buku;
}
public Anggota anggota() {
return anggota;
}
public LocalDate tanggalPinjam() {
return tanggalPinjam;
}
public LocalDate jatuhTempo() {
return jatuhTempo;
}
/** Tanggal kembali aktual; null bila buku masih di tangan anggota. */
public LocalDate tanggalKembali() {
return tanggalKembali;
}
/** Apakah buku masih dipinjam? */
public boolean aktif() {
return tanggalKembali == null;
}
/** Catat pengembalian dan kunci transaksi agar tidak dikembalikan dua kali. */
public void kembalikan(LocalDate tanggal) {
Objects.requireNonNull(tanggal, "tanggal tidak boleh null");
if (tanggal.isBefore(tanggalPinjam)) {
throw new IllegalArgumentException(
"tanggal kembali " + tanggal + " lebih awal dari tanggal pinjam "
+ tanggalPinjam);
}
if (!aktif()) {
throw new IllegalStateException("peminjaman sudah selesai");
}
this.tanggalKembali = tanggal;
}
/**
* Selisih hari dari jatuh tempo; 0 bila tidak terlambat.
* Acuan: tanggal kembali aktual bila transaksi sudah selesai,
* atau tanggalHitung untuk pinjaman yang masih berjalan.
*/
public long hariTerlambat(LocalDate tanggalHitung) {
LocalDate acuan = (tanggalKembali != null) ? tanggalKembali : tanggalHitung;
return Math.max(0, ChronoUnit.DAYS.between(jatuhTempo, acuan));
}
/** Denda = hari terlambat dikali tarif per hari; tanpa pembulatan. */
public BigDecimal denda(LocalDate tanggalHitung, BigDecimal tarifPerHari) {
Objects.requireNonNull(tarifPerHari, "tarifPerHari tidak boleh null");
return BigDecimal.valueOf(hariTerlambat(tanggalHitung)).multiply(tarifPerHari);
}
// toString eksplisit: hanya memuat data domain, tanpa alamat objek.
@Override
public String toString() {
return "Peminjaman[judul=" + buku.judul() + ", anggota=" + anggota.nama()
+ ", pinjam=" + tanggalPinjam + ", jatuhTempo=" + jatuhTempo
+ ", kembali=" + (aktif() ? "-" : tanggalKembali) + "]";
}
}
PenyimpananPerpustakaan.java
Unduh berkasimport java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
// Penyimpanan berbasis berkas: satu baris satu record, dipisah karakter '|'.
// Format:
// BUKU|isbn|judul|penulis|tahunTerbit
// ANGGOTA|id|nama|email|terdaftar
// PEMINJAMAN|isbn|idAnggota|tanggalPinjam|jatuhTempo|tanggalKembali(- bila masih aktif)
public final class PenyimpananPerpustakaan {
private PenyimpananPerpustakaan() {
// kumpulan prosedur statis: tidak perlu dibuat objeknya
}
public static void simpan(Perpustakaan perpus, Path berkas)
throws PersistensiGagalException {
try (BufferedWriter tulis = Files.newBufferedWriter(berkas, StandardCharsets.UTF_8)) {
for (Buku buku : perpus.semuaBuku()) {
tulis.write("BUKU|" + buku.isbn() + "|" + buku.judul() + "|"
+ buku.penulis() + "|" + buku.tahunTerbit());
tulis.newLine();
}
for (Anggota a : perpus.semuaAnggota()) {
tulis.write("ANGGOTA|" + a.id() + "|" + a.nama() + "|"
+ a.email() + "|" + a.terdaftar());
tulis.newLine();
}
for (Peminjaman p : perpus.riwayat()) {
String kembali = p.tanggalKembali() == null
? "-" : p.tanggalKembali().toString();
tulis.write("PEMINJAMAN|" + p.buku().isbn() + "|" + p.anggota().id() + "|"
+ p.tanggalPinjam() + "|" + p.jatuhTempo() + "|" + kembali);
tulis.newLine();
}
} catch (IOException e) {
throw new PersistensiGagalException("gagal menyimpan ke " + berkas, e);
}
}
public static Perpustakaan muat(Path berkas) throws PersistensiGagalException {
Perpustakaan perpus = new Perpustakaan();
try (BufferedReader baca = Files.newBufferedReader(berkas, StandardCharsets.UTF_8)) {
String baris;
while ((baris = baca.readLine()) != null) {
if (baris.isBlank() || baris.startsWith("#")) {
continue; // baris kosong dan komentar dilewati
}
String[] bagian = baris.split("\\|", -1);
switch (bagian[0]) {
case "BUKU" -> perpus.tambahBuku(new Buku(
bagian[1], bagian[2], bagian[3], Integer.parseInt(bagian[4])));
case "ANGGOTA" -> perpus.daftarkan(new Anggota(
bagian[1], bagian[2], bagian[3], LocalDate.parse(bagian[4])));
case "PEMINJAMAN" -> {
Buku buku = perpus.cariBukuIsbn(bagian[1]);
Anggota anggota = perpus.cariAnggota(bagian[2]);
LocalDate kembali = "-".equals(bagian[5])
? null : LocalDate.parse(bagian[5]);
perpus.pulihkanPeminjaman(new Peminjaman(
buku, anggota, LocalDate.parse(bagian[3]),
LocalDate.parse(bagian[4]), kembali));
}
default -> throw new PersistensiGagalException(
"baris tidak dikenal: " + baris);
}
}
} catch (IOException e) {
throw new PersistensiGagalException("gagal memuat dari " + berkas, e);
}
return perpus;
}
}
Perpustakaan.java
Unduh berkasimport java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
// Layanan perpustakaan: katalog, keanggotaan, dan sirkulasi peminjaman.
public class Perpustakaan {
/** Jumlah buku maksimum yang boleh dipinjam satu anggota. */
public static final int BATAS_PINJAMAN_PER_ANGGOTA = 3;
/** Denda keterlambatan per hari. */
public static final BigDecimal TARIF_DENDA_PER_HARI = new BigDecimal("500");
private final Map<String, Buku> katalog = new LinkedHashMap<>();
private final Map<String, Anggota> anggota = new LinkedHashMap<>();
// Invarian: satu ISBN hanya boleh punya satu pinjaman aktif.
private final Map<String, Peminjaman> pinjamanAktif = new LinkedHashMap<>();
private final List<Peminjaman> riwayat = new ArrayList<>();
public void tambahBuku(Buku buku) {
Buku sebelumnya = katalog.putIfAbsent(buku.isbn(), buku);
if (sebelumnya != null) {
throw new PerpustakaanException(
"ISBN sudah terdaftar: " + buku.isbn());
}
}
public void daftarkan(Anggota baru) {
Anggota sebelumnya = anggota.putIfAbsent(baru.id(), baru);
if (sebelumnya != null) {
throw new PerpustakaanException(
"ID anggota sudah terdaftar: " + baru.id());
}
}
public Buku cariBukuIsbn(String isbn) {
Buku buku = katalog.get(isbn);
if (buku == null) {
throw new BukuTidakDitemukanException(
"ISBN tidak ada di katalog: " + isbn);
}
return buku;
}
public Anggota cariAnggota(String id) {
Anggota dicari = anggota.get(id);
if (dicari == null) {
throw new AnggotaTidakDitemukanException(
"ID anggota tidak terdaftar: " + id);
}
return dicari;
}
public List<Buku> cariBuku(String kataKunci) {
List<Buku> hasil = new ArrayList<>();
for (Buku buku : katalog.values()) {
if (buku.cocokKataKunci(kataKunci)) {
hasil.add(buku);
}
}
return hasil;
}
/**
* Pinjam sebuah buku. Melempar BukuTidakDitemukanException,
* AnggotaTidakDitemukanException, BukuDipinjamException, atau
* BatasPeminjamanTercapaiException bila aturan dilanggar.
*/
public Peminjaman pinjam(String isbn, String idAnggota, LocalDate tanggal) {
Buku buku = cariBukuIsbn(isbn);
Anggota peminjam = cariAnggota(idAnggota);
if (pinjamanAktif.containsKey(isbn)) {
throw new BukuDipinjamException(
"buku sudah dipinjam oleh " + pinjamanAktif.get(isbn).anggota().nama()
+ ": " + buku.judul());
}
if (hitungPinjamanAktif(idAnggota) >= BATAS_PINJAMAN_PER_ANGGOTA) {
throw new BatasPeminjamanTercapaiException(
"anggota " + peminjam.nama() + " sudah memegang "
+ BATAS_PINJAMAN_PER_ANGGOTA + " buku");
}
Peminjaman baru = new Peminjaman(buku, peminjam, tanggal);
pinjamanAktif.put(isbn, baru);
riwayat.add(baru);
return baru;
}
/**
* Kembalikan buku yang sedang dipinjam. Transaksi hanya dilepas dari
* sirkulasi setelah pengembalian dinyatakan sah oleh Peminjaman.
*/
public Peminjaman kembalikan(String isbn, LocalDate tanggal) {
Peminjaman pinjaman = pinjamanAktif.get(isbn);
if (pinjaman == null) {
throw new PerpustakaanException(
"buku tidak sedang dipinjam: " + isbn);
}
pinjaman.kembalikan(tanggal); // bisa melempar bila tanggal tidak sah
pinjamanAktif.remove(isbn); // dijalankan hanya bila pengembalian sah
return pinjaman;
}
/**
* Pulihkan transaksi hasil pembacaan berkas: masuk ke riwayat dan,
* bila masih aktif, kembali ke peta sirkulasi.
*/
public void pulihkanPeminjaman(Peminjaman pulihan) {
Objects.requireNonNull(pulihan, "pulihan tidak boleh null");
riwayat.add(pulihan);
if (pulihan.aktif()) {
pinjamanAktif.put(pulihan.buku().isbn(), pulihan);
}
}
/** Pinjaman aktif milik satu anggota, dalam urutan peminjaman. */
public List<Peminjaman> pinjamanAktifAnggota(String idAnggota) {
List<Peminjaman> hasil = new ArrayList<>();
for (Peminjaman p : pinjamanAktif.values()) {
if (p.anggota().id().equals(idAnggota)) {
hasil.add(p);
}
}
return hasil;
}
/** Seluruh transaksi (aktif dan selesai) dalam urutan kejadian. */
public List<Peminjaman> riwayat() {
return List.copyOf(riwayat);
}
public int jumlahPinjamanAktif() {
return pinjamanAktif.size();
}
public List<Buku> semuaBuku() {
return List.copyOf(katalog.values());
}
public List<Anggota> semuaAnggota() {
return List.copyOf(anggota.values());
}
public int jumlahBuku() {
return katalog.size();
}
public int jumlahAnggota() {
return anggota.size();
}
private int hitungPinjamanAktif(String idAnggota) {
int jumlah = 0;
for (Peminjaman p : pinjamanAktif.values()) {
if (p.anggota().id().equals(idAnggota)) {
jumlah++;
}
}
return jumlah;
}
}
PerpustakaanException.java
Unduh berkas// Induk exception domain: seluruh pelanggaran aturan perpustakaan.
public class PerpustakaanException extends RuntimeException {
public PerpustakaanException(String pesan) {
super(pesan);
}
}
PersistensiGagalException.java
Unduh berkas// Kegagalan prosedur penyimpanan/pembacaan berkas: checked karena
// setiap pemanggil wajib menyadarinya secara eksplisit.
public class PersistensiGagalException extends Exception {
public PersistensiGagalException(String pesan) {
super(pesan);
}
public PersistensiGagalException(String pesan, Throwable cause) {
super(pesan, cause);
}
}
UjiPerpustakaan.java
Unduh berkasimport java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
// Harness pengujian berbasis main: tanpa pustaka uji, cukup JDK murni.
// Setiap periksa() mencetak PASS/FAIL; exit code bukan nol bila ada kegagalan.
public class UjiPerpustakaan {
private static final String ISBN_LASKAR = "978-602-421-123-4";
private static final String ISBN_FILOSOFI = "978-602-401-718-3";
private static final String ISBN_LAUT = "978-602-291-024-4";
private static final String ISBN_BUMI = "978-979-433-555-1";
private static int gagal = 0;
public static void main(String[] args) throws PersistensiGagalException {
ujiKatalog();
ujiSirkulasi();
ujiDenda();
ujiPersistensi();
System.out.println();
System.out.println(gagal == 0 ? "SEMUA UJI LULUS" : gagal + " UJI GAGAL");
System.exit(gagal == 0 ? 0 : 1);
}
static void ujiKatalog() {
System.out.println("== Katalog ==");
Perpustakaan perpus = perpusBaru();
periksa("buku duplikat ditolak", melempar(() -> perpus.tambahBuku(
new Buku(ISBN_LASKAR, "Laskar Pelangi", "Andrea Hirata", 2005)),
PerpustakaanException.class));
periksa("anggota duplikat ditolak", melempar(() -> perpus.daftarkan(
new Anggota("A-001", "Budi Santoso", "[email protected]",
LocalDate.of(2026, 1, 2))),
PerpustakaanException.class));
periksa("pencarian mengabaikan huruf besar", perpus.cariBuku("LASKAR").size() == 1);
periksa("pencarian tanpa hasil menghasilkan list kosong",
perpus.cariBuku("java").isEmpty());
}
static void ujiSirkulasi() {
System.out.println("== Sirkulasi ==");
Perpustakaan perpus = perpusBaru();
periksa("ISBN tidak dikenal ditolak", melempar(
() -> perpus.pinjam("978-000-000-000-0", "A-001", LocalDate.of(2026, 1, 5)),
BukuTidakDitemukanException.class));
periksa("anggota tidak terdaftar ditolak", melempar(
() -> perpus.pinjam(ISBN_LASKAR, "A-099", LocalDate.of(2026, 1, 5)),
AnggotaTidakDitemukanException.class));
perpus.pinjam(ISBN_LASKAR, "A-001", LocalDate.of(2026, 1, 5));
perpus.pinjam(ISBN_FILOSOFI, "A-001", LocalDate.of(2026, 1, 5));
perpus.pinjam(ISBN_LAUT, "A-001", LocalDate.of(2026, 1, 5));
periksa("buku yang sudah dipinjam ditolak", melempar(
() -> perpus.pinjam(ISBN_LASKAR, "A-002", LocalDate.of(2026, 1, 5)),
BukuDipinjamException.class));
periksa("batas tiga buku per anggota", melempar(
() -> perpus.pinjam(ISBN_BUMI, "A-001", LocalDate.of(2026, 1, 5)),
BatasPeminjamanTercapaiException.class));
perpus.kembalikan(ISBN_LASKAR, LocalDate.of(2026, 1, 10));
Peminjaman pinjamSiti = perpus.pinjam(ISBN_LASKAR, "A-002", LocalDate.of(2026, 1, 11));
periksa("buku yang dikembalikan dapat dipinjam lagi",
pinjamSiti.anggota().id().equals("A-002"));
perpus.kembalikan(ISBN_FILOSOFI, LocalDate.of(2026, 1, 10));
periksa("pengembalian ganda ditolak", melempar(
() -> perpus.kembalikan(ISBN_FILOSOFI, LocalDate.of(2026, 1, 12)),
PerpustakaanException.class));
periksa("buku yang tidak dipinjam tidak dapat dikembalikan", melempar(
() -> perpus.kembalikan(ISBN_BUMI, LocalDate.of(2026, 1, 12)),
PerpustakaanException.class));
}
static void ujiDenda() {
System.out.println("== Denda ==");
Perpustakaan perpus = perpusBaru();
Peminjaman tepat = perpus.pinjam(ISBN_LASKAR, "A-001", LocalDate.of(2026, 1, 5));
perpus.kembalikan(ISBN_LASKAR, LocalDate.of(2026, 1, 10));
periksa("pengembalian tepat waktu berdenda nol",
tepat.denda(LocalDate.of(2026, 1, 10), Perpustakaan.TARIF_DENDA_PER_HARI)
.compareTo(BigDecimal.ZERO) == 0);
Peminjaman terlambat = perpus.pinjam(ISBN_FILOSOFI, "A-002", LocalDate.of(2026, 1, 5));
perpus.kembalikan(ISBN_FILOSOFI, LocalDate.of(2026, 1, 25));
periksa("enam hari terlambat berdenda 3000",
terlambat.denda(LocalDate.of(2026, 1, 25), Perpustakaan.TARIF_DENDA_PER_HARI)
.compareTo(new BigDecimal("3000")) == 0);
Peminjaman berjalan = perpus.pinjam(ISBN_LAUT, "A-001", LocalDate.of(2026, 1, 5));
periksa("denda berjalan dihitung dari tanggal hitung",
berjalan.denda(LocalDate.of(2026, 2, 1), Perpustakaan.TARIF_DENDA_PER_HARI)
.compareTo(new BigDecimal("6500")) == 0);
}
static void ujiPersistensi() throws PersistensiGagalException {
System.out.println("== Persistensi ==");
Perpustakaan perpus = perpusBaru();
perpus.pinjam(ISBN_LASKAR, "A-001", LocalDate.of(2026, 1, 5));
perpus.kembalikan(ISBN_LASKAR, LocalDate.of(2026, 1, 10));
perpus.pinjam(ISBN_FILOSOFI, "A-002", LocalDate.of(2026, 1, 5));
Path berkas = Path.of("uji-perpustakaan.txt");
Path berkasRusak = Path.of("uji-rusak.txt");
try {
PenyimpananPerpustakaan.simpan(perpus, berkas);
Perpustakaan salinan = PenyimpananPerpustakaan.muat(berkas);
periksa("jumlah buku tetap setelah muat",
salinan.jumlahBuku() == perpus.jumlahBuku());
periksa("jumlah anggota tetap setelah muat",
salinan.jumlahAnggota() == perpus.jumlahAnggota());
periksa("riwayat tetap setelah muat",
salinan.riwayat().size() == perpus.riwayat().size());
periksa("pinjaman aktif dipulihkan", salinan.jumlahPinjamanAktif() == 1);
periksa("buku setara setelah perjalanan berkas",
salinan.cariBukuIsbn(ISBN_LASKAR).equals(perpus.cariBukuIsbn(ISBN_LASKAR)));
// Baris tidak dikenal harus ditolak, bukan didiamkan.
tulisBerkasRusak(berkasRusak);
try {
PenyimpananPerpustakaan.muat(berkasRusak);
periksa("baris tidak dikenal ditolak", false);
} catch (PersistensiGagalException e) {
periksa("baris tidak dikenal ditolak",
e.getMessage().contains("baris tidak dikenal"));
}
} finally {
hapusBilaAda(berkas);
hapusBilaAda(berkasRusak);
}
}
/** Cetak PASS/FAIL satu pernyataan dan akumulasikan jumlah kegagalan. */
static void periksa(String nama, boolean kondisi) {
if (kondisi) {
System.out.println("PASS " + nama);
} else {
System.out.println("FAIL " + nama);
gagal++;
}
}
/** Benar bila aksi melempar exception jenis yang diharapkan. */
static boolean melempar(Runnable aksi, Class<? extends Throwable> jenis) {
try {
aksi.run();
return false; // tidak melempar apa pun: gagal
} catch (Throwable t) {
return jenis.isInstance(t);
}
}
static void hapusBilaAda(Path berkas) {
try {
Files.deleteIfExists(berkas);
} catch (IOException e) {
// berkas uji tidak terhapus: tidak memengaruhi hasil pengujian
}
}
/** Siapkan berkas berisi baris rusak; kegagalan menulis = galat persiapan uji. */
static void tulisBerkasRusak(Path berkas) {
try {
Files.writeString(berkas, "SAMPAH|bukan|format\n", StandardCharsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException("tidak dapat menulis berkas uji: " + berkas, e);
}
}
static Perpustakaan perpusBaru() {
Perpustakaan perpus = new Perpustakaan();
perpus.tambahBuku(new Buku(ISBN_LASKAR, "Laskar Pelangi",
"Andrea Hirata", 2005));
perpus.tambahBuku(new Buku(ISBN_FILOSOFI, "Filosofi Teras",
"Henry Manampiring", 2018));
perpus.tambahBuku(new Buku(ISBN_LAUT, "Laut Bercerita",
"Leila S. Chudori", 2017));
perpus.tambahBuku(new Buku(ISBN_BUMI, "Bumi Manusia",
"Pramoedya Ananta Toer", 1980));
perpus.daftarkan(new Anggota("A-001", "Budi Santoso",
"[email protected]", LocalDate.of(2026, 1, 2)));
perpus.daftarkan(new Anggota("A-002", "Siti Rahayu",
"[email protected]", LocalDate.of(2026, 1, 3)));
return perpus;
}
}