Empat bagian terakhir menghasilkan aplikasi yang tampak lengkap, tetapi “tampak” bukan kata yang bisa kita serahkan kepada pengguna. Klaim seperti “muat mengganti seluruh data” atau “stok negatif selalu ditolak” baru menjadi janji yang layak dipercaya setelah diuji otomatis — diulang ratusan kali tanpa bosan, tanpa kelalaian mata. Bagian penutup proyek ini memasang dua lapis pengujian: sesi REPL terprogram yang memperlihatkan aplikasi utuh bekerja, dan test harness unit yang memanggil function satu per satu dengan gaya Arrange-Act-Assert. Polanya persis yang dipakai proyek kalkulator, kali ini dengan lebih banyak fungsi, lebih banyak jenis galat, dan satu tantangan baru: berkas sementara yang tidak boleh meninggalkan bekas.

Dua Lapis Pengujian, Dua Jawaban Berbeda #

Kedua lapis itu tidak saling menggantikan. Sesi REPL menjawab pertanyaan integrasi: bila semua komponen dirangkai, apakah perintah pengguna mengalir dengan benar dari tokenisasi sampai keluaran? Harness unit menjawab pertanyaan mekanis: bila tambah menerima stok -1, apakah jenis galatnya benar-benar DataTidakSah? Lapis pertama nyaman dibaca manusia, lapis kedua tajam menunjuk baris yang salah. Keduanya berbagi satu kontrak yang sama — exit code 0 berarti semua lulus — sehingga keduanya bisa digantungkan ke pipeline CI tanpa perubahan apa pun. Kemungkinan itu ada berkat keputusan Bagian 1 dan 2: logika adalah function murni, galat adalah tipe, dan I/O terkunci di main.

Sesi Lengkap Aplikasi Jadi #

Program final di bawah adalah src/inventaris.cpp utuh, dijalankan dengan skenario terprogram: tiga produk ditambahkan, satu ditolak karena kode duplikat, pencarian dan filter bekerja, data disimpan lalu dimuat kembali, dan satu perintah asing diakhiri pesan yang mengarahkan pengguna ke bantuan:

cpp
// inventaris.cpp — sistem manajemen inventaris berbasis terminal (REPL perintah).
//
// Kompilasi:
//   g++ -std=c++23 -Wall -Wextra inventaris.cpp -o inventaris
// Pakai:
//   ./inventaris               (interaktif: ketik perintah, akhiri dengan Ctrl+D)
//   ./inventaris < skenario.txt (batch: satu perintah per baris)
//
// Kontrak exit code:
//   0 — seluruh perintah dieksekusi berhasil (atau masukan kosong)
//   1 — ada minimal satu perintah yang galat

#include <algorithm>     // std::find_if, std::copy_if, std::sort
#include <cctype>        // std::tolower
#include <charconv>      // std::from_chars
#include <cstddef>       // std::size_t
#include <expected>      // std::expected, std::unexpected
#include <fstream>       // std::ifstream, std::ofstream
#include <iostream>      // std::getline, std::cin
#include <optional>      // std::optional
#include <print>         // std::println
#include <string>
#include <string_view>
#include <vector>

// ---------- model data ----------

struct Produk {
    std::string kode;
    std::string nama;
    long long harga = 0;   // dalam rupiah
    int stok = 0;

    // invariant yang harus dipatuhi setiap produk
    bool sah() const {
        return !kode.empty() && !nama.empty() && harga >= 0 && stok >= 0;
    }
};

// ---------- jenis galat dan pesannya ----------

enum class JenisGalat {
    KodeKosong,       // kode produk berupa string kosong
    DataTidakSah,     // nilai melanggar invariant (negatif, kosong, bukan angka)
    KodeDuplikat,     // kode sudah dipakai produk lain
    TidakDitemukan,   // kode tidak ada dalam inventaris
    BerkasGagal,      // berkas tidak dapat dibuka
    BarisRusak,       // baris berkas tidak mengikuti format
};

struct Galat {
    JenisGalat jenis;
    std::string rincian;
};

std::string pesanGalat(const Galat& g) {
    switch (g.jenis) {
        case JenisGalat::KodeKosong:
            return "kode tidak boleh kosong";
        case JenisGalat::DataTidakSah:
            return "data tidak sah: " + g.rincian;
        case JenisGalat::KodeDuplikat:
            return "kode duplikat: " + g.rincian;
        case JenisGalat::TidakDitemukan:
            return "produk tidak ditemukan: " + g.rincian;
        case JenisGalat::BerkasGagal:
            return "berkas tidak dapat dibuka: " + g.rincian;
        case JenisGalat::BarisRusak:
            return "baris rusak: " + g.rincian;
    }
    return "galat tidak diketahui";
}

// ---------- utilitas umum ----------

std::vector<std::string_view> tokenisasi(std::string_view baris) {
    std::vector<std::string_view> hasil;
    std::size_t i = 0;
    while (i < baris.size()) {
        while (i < baris.size() && (baris[i] == ' ' || baris[i] == '\t')) {
            ++i;
        }
        std::size_t mulai = i;
        while (i < baris.size() && baris[i] != ' ' && baris[i] != '\t') {
            ++i;
        }
        if (i > mulai) {
            hasil.push_back(baris.substr(mulai, i - mulai));
        }
    }
    return hasil;
}

// pengurai angka bulat: seluruh token harus terpakai (dari_chars, tanpa exception)
template <typename T>
std::expected<T, std::string> parseAngka(std::string_view teks) {
    T nilai{};
    const char* akhir = teks.data() + teks.size();
    auto [ptr, ec] = std::from_chars(teks.data(), akhir, nilai);
    if (ec != std::errc{} || ptr != akhir) {
        return std::unexpected(std::string(teks));
    }
    return nilai;
}

std::string keHurufKecil(std::string_view teks) {
    std::string hasil;
    hasil.reserve(teks.size());
    for (char c : teks) {
        hasil.push_back(
            static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
    }
    return hasil;
}

// ---------- repository: inventaris di memori ----------

enum class Urutkan { Harga, Nama, Stok };

class Inventaris {
public:
    std::expected<void, Galat> tambah(const Produk& p) {
        if (p.kode.empty()) {
            return std::unexpected(Galat{JenisGalat::KodeKosong, ""});
        }
        if (!p.sah()) {
            return std::unexpected(Galat{JenisGalat::DataTidakSah, p.kode});
        }
        if (temukan(p.kode).has_value()) {
            return std::unexpected(Galat{JenisGalat::KodeDuplikat, p.kode});
        }
        produk_.push_back(p);
        return {};
    }

    std::expected<void, Galat> ubah(std::string_view kode, long long hargaBaru,
                                    int stokBaru) {
        auto indeks = temukan(kode);
        if (!indeks) {
            return std::unexpected(
                Galat{JenisGalat::TidakDitemukan, std::string(kode)});
        }
        if (hargaBaru < 0) {
            return std::unexpected(Galat{JenisGalat::DataTidakSah,
                                         std::string(kode) + " (harga negatif)"});
        }
        if (stokBaru < 0) {
            return std::unexpected(Galat{JenisGalat::DataTidakSah,
                                         std::string(kode) + " (stok negatif)"});
        }
        produk_[*indeks].harga = hargaBaru;
        produk_[*indeks].stok = stokBaru;
        return {};
    }

    std::expected<void, Galat> hapus(std::string_view kode) {
        auto indeks = temukan(kode);
        if (!indeks) {
            return std::unexpected(
                Galat{JenisGalat::TidakDitemukan, std::string(kode)});
        }
        produk_.erase(produk_.begin() + static_cast<std::ptrdiff_t>(*indeks));
        return {};
    }

    const Produk* cari(std::string_view kode) const {
        auto indeks = temukan(kode);
        return indeks ? &produk_[*indeks] : nullptr;
    }

    std::vector<Produk> daftar() const {
        return produk_;   // urutan menyimpan (urutan masuk)
    }

    std::size_t jumlah() const {
        return produk_.size();
    }

    // pencarian nama: tidak peka huruf besar/kecil, pola boleh sebagian nama
    std::vector<Produk> cariNama(std::string_view kataKunci) const {
        const std::string kunci = keHurufKecil(kataKunci);
        std::vector<Produk> hasil;
        for (const Produk& p : produk_) {
            if (keHurufKecil(p.nama).find(kunci) != std::string::npos) {
                hasil.push_back(p);
            }
        }
        return hasil;
    }

    std::vector<Produk> filterStok(int minimum) const {
        std::vector<Produk> hasil;
        std::copy_if(produk_.begin(), produk_.end(),
                     std::back_inserter(hasil),
                     [minimum](const Produk& p) { return p.stok >= minimum; });
        return hasil;
    }

    std::vector<Produk> urut(Urutkan bidang) const {
        std::vector<Produk> hasil = produk_;
        switch (bidang) {
            case Urutkan::Harga:
                std::sort(hasil.begin(), hasil.end(),
                          [](const Produk& a, const Produk& b) {
                              if (a.harga != b.harga) return a.harga < b.harga;
                              return a.kode < b.kode;  // penentu seri
                          });
                break;
            case Urutkan::Stok:
                std::sort(hasil.begin(), hasil.end(),
                          [](const Produk& a, const Produk& b) {
                              if (a.stok != b.stok) return a.stok < b.stok;
                              return a.kode < b.kode;
                          });
                break;
            case Urutkan::Nama:
                std::sort(hasil.begin(), hasil.end(),
                          [](const Produk& a, const Produk& b) {
                              int urut = keHurufKecil(a.nama)
                                             .compare(keHurufKecil(b.nama));
                              if (urut != 0) return urut < 0;
                              return a.kode < b.kode;
                          });
                break;
        }
        return hasil;
    }

private:
    // posisi produk berdasar kode; std::nullopt bila tidak ada
    std::optional<std::size_t> temukan(std::string_view kode) const {
        for (std::size_t i = 0; i < produk_.size(); ++i) {
            if (std::string_view(produk_[i].kode) == kode) {
                return i;
            }
        }
        return std::nullopt;
    }

    std::vector<Produk> produk_;
};

// ---------- persistensi: format teks satu baris per produk ----------

// format baris: kode|nama|harga|stok
std::optional<Produk> uraiBarisProduk(std::string_view baris) {
    std::vector<std::string_view> bagian;
    std::size_t mulai = 0;
    while (true) {
        std::size_t pipa = baris.find('|', mulai);
        if (pipa == std::string_view::npos) {
            bagian.push_back(baris.substr(mulai));
            break;
        }
        bagian.push_back(baris.substr(mulai, pipa - mulai));
        mulai = pipa + 1;
    }
    if (bagian.size() != 4) {
        return std::nullopt;
    }

    auto harga = parseAngka<long long>(bagian[2]);
    auto stok = parseAngka<int>(bagian[3]);
    if (!harga || !stok) {
        return std::nullopt;
    }

    Produk p{std::string(bagian[0]), std::string(bagian[1]), *harga, *stok};
    if (!p.sah()) {
        return std::nullopt;
    }
    return p;
}

std::expected<void, Galat> simpanKeFile(const Inventaris& inv,
                                        const std::string& namaBerkas) {
    std::ofstream keluar(namaBerkas);
    if (!keluar) {
        return std::unexpected(Galat{JenisGalat::BerkasGagal, namaBerkas});
    }
    for (const Produk& p : inv.daftar()) {
        keluar << p.kode << '|' << p.nama << '|' << p.harga << '|' << p.stok
               << '\n';
    }
    return {};
}

std::expected<Inventaris, Galat> muatDariFile(const std::string& namaBerkas) {
    std::ifstream masuk(namaBerkas);
    if (!masuk) {
        return std::unexpected(Galat{JenisGalat::BerkasGagal, namaBerkas});
    }
    Inventaris inv;
    std::string baris;
    int nomorBaris = 0;
    while (std::getline(masuk, baris)) {
        ++nomorBaris;
        if (baris.empty()) {
            continue;   // baris kosong (mis. di akhir berkas) diabaikan
        }
        auto p = uraiBarisProduk(baris);
        if (!p) {
            return std::unexpected(
                Galat{JenisGalat::BarisRusak,
                      std::to_string(nomorBaris) + ": " + baris});
        }
        auto hasil = inv.tambah(*p);
        if (!hasil) {
            return std::unexpected(hasil.error());
        }
    }
    return inv;   // data lama hanya diganti bila seluruh berkas berhasil dibaca
}

// ---------- antarmuka: cetak dan bantuan ----------

void cetakDaftar(const std::vector<Produk>& daftar) {
    if (daftar.empty()) {
        std::println("inventaris kosong");
        return;
    }
    std::println("{:<5} {:<16} {:>8} {:>5}", "KODE", "NAMA", "HARGA", "STOK");
    for (const Produk& p : daftar) {
        std::println("{:<5} {:<16} {:>8} {:>5}", p.kode, p.nama, p.harga,
                     p.stok);
    }
}

void cetakBantuan() {
    std::println("Perintah yang tersedia:");
    std::println("  tambah <kode> <nama> <harga> <stok>   menambahkan produk baru");
    std::println("  ubah <kode> <harga> <stok>            mengubah harga dan stok");
    std::println("  hapus <kode>                          menghapus produk");
    std::println("  daftar                                menampilkan seluruh produk");
    std::println("  cari <kata>                           mencari berdasar nama");
    std::println("  filter <stok-minimum>                 produk dengan stok >= nilai");
    std::println("  urut harga|nama|stok                  menampilkan daftar terurut");
    std::println("  simpan <berkas>                       menyimpan data ke berkas teks");
    std::println("  muat <berkas>                         memuat data dari berkas teks");
    std::println("  bantuan                               menampilkan daftar perintah ini");
    std::println("  keluar                                mengakhiri program");
}

// ---------- main: pilah perintah ----------

int main() {
    Inventaris inv;
    int jumlahGalat = 0;
    std::string baris;

    while (std::getline(std::cin, baris)) {
        const auto token = tokenisasi(baris);
        if (token.empty()) {
            continue;   // baris kosong diabaikan
        }
        const std::string_view perintah = token[0];

        if (perintah == "tambah") {
            if (token.size() != 5) {
                ++jumlahGalat;
                std::println("galat: penggunaan: tambah <kode> <nama> <harga> <stok>");
                continue;
            }
            auto harga = parseAngka<long long>(token[3]);
            auto stok = parseAngka<int>(token[4]);
            if (!harga || !stok) {
                ++jumlahGalat;
                std::println("galat: data tidak sah: harga dan stok harus angka bulat");
                continue;
            }
            if (*harga < 0 || *stok < 0) {
                ++jumlahGalat;
                std::println("galat: data tidak sah: harga dan stok tidak boleh negatif");
                continue;
            }
            Produk p{std::string(token[1]), std::string(token[2]), *harga, *stok};
            auto hasil = inv.tambah(p);
            if (hasil) {
                std::println("ditambahkan: {} ({})", p.kode, p.nama);
            } else {
                ++jumlahGalat;
                std::println("galat: {}", pesanGalat(hasil.error()));
            }
        } else if (perintah == "ubah") {
            if (token.size() != 4) {
                ++jumlahGalat;
                std::println("galat: penggunaan: ubah <kode> <harga> <stok>");
                continue;
            }
            auto harga = parseAngka<long long>(token[2]);
            auto stok = parseAngka<int>(token[3]);
            if (!harga || !stok) {
                ++jumlahGalat;
                std::println("galat: data tidak sah: harga dan stok harus angka bulat");
                continue;
            }
            auto hasil = inv.ubah(token[1], *harga, *stok);
            if (hasil) {
                std::println("diubah: {}", token[1]);
            } else {
                ++jumlahGalat;
                std::println("galat: {}", pesanGalat(hasil.error()));
            }
        } else if (perintah == "hapus") {
            if (token.size() != 2) {
                ++jumlahGalat;
                std::println("galat: penggunaan: hapus <kode>");
                continue;
            }
            auto hasil = inv.hapus(token[1]);
            if (hasil) {
                std::println("dihapus: {}", token[1]);
            } else {
                ++jumlahGalat;
                std::println("galat: {}", pesanGalat(hasil.error()));
            }
        } else if (perintah == "daftar") {
            cetakDaftar(inv.daftar());
        } else if (perintah == "cari") {
            if (token.size() != 2) {
                ++jumlahGalat;
                std::println("galat: penggunaan: cari <kata>");
                continue;
            }
            auto hasil = inv.cariNama(token[1]);
            if (hasil.empty()) {
                std::println("tidak ada produk yang cocok dengan '{}'", token[1]);
            } else {
                cetakDaftar(hasil);
            }
        } else if (perintah == "filter") {
            if (token.size() != 2) {
                ++jumlahGalat;
                std::println("galat: penggunaan: filter <stok-minimum>");
                continue;
            }
            auto minimum = parseAngka<int>(token[1]);
            if (!minimum || *minimum < 0) {
                ++jumlahGalat;
                std::println("galat: data tidak sah: stok-minimum harus angka bulat >= 0");
                continue;
            }
            auto hasil = inv.filterStok(*minimum);
            if (hasil.empty()) {
                std::println("tidak ada produk dengan stok >= {}", *minimum);
            } else {
                cetakDaftar(hasil);
            }
        } else if (perintah == "urut") {
            if (token.size() != 2) {
                ++jumlahGalat;
                std::println("galat: penggunaan: urut harga|nama|stok");
                continue;
            }
            Urutkan bidang;
            if (token[1] == "harga") {
                bidang = Urutkan::Harga;
            } else if (token[1] == "nama") {
                bidang = Urutkan::Nama;
            } else if (token[1] == "stok") {
                bidang = Urutkan::Stok;
            } else {
                ++jumlahGalat;
                std::println("galat: penggunaan: urut harga|nama|stok");
                continue;
            }
            cetakDaftar(inv.urut(bidang));
        } else if (perintah == "simpan") {
            if (token.size() != 2) {
                ++jumlahGalat;
                std::println("galat: penggunaan: simpan <berkas>");
                continue;
            }
            auto hasil = simpanKeFile(inv, std::string(token[1]));
            if (hasil) {
                std::println("tersimpan: {} ({} produk)", token[1], inv.jumlah());
            } else {
                ++jumlahGalat;
                std::println("galat: {}", pesanGalat(hasil.error()));
            }
        } else if (perintah == "muat") {
            if (token.size() != 2) {
                ++jumlahGalat;
                std::println("galat: penggunaan: muat <berkas>");
                continue;
            }
            auto hasil = muatDariFile(std::string(token[1]));
            if (hasil) {
                inv = std::move(*hasil);
                std::println("dimuat: {} ({} produk)", token[1], inv.jumlah());
            } else {
                ++jumlahGalat;
                std::println("galat: {}", pesanGalat(hasil.error()));
            }
        } else if (perintah == "bantuan") {
            cetakBantuan();
        } else if (perintah == "keluar") {
            break;
        } else {
            ++jumlahGalat;
            std::println("galat: perintah tidak dikenal: {}", perintah);
            std::println("ketik 'bantuan' untuk daftar perintah");
        }
    }

    std::println("selesai.");
    return jumlahGalat == 0 ? 0 : 1;
}

Skenario sengaja menyelipkan dua kegagalan — kode duplikat dan perintah tidak dikenal — supaya jalur galat aplikasi ikut tampil, bukan hanya jalur bahagia.

Output yang Dihasilkan:

text
ditambahkan: P001 (Kopi)
ditambahkan: P002 (Gula)
ditambahkan: P003 (Pensil)
galat: kode duplikat: P001
KODE  NAMA                HARGA  STOK
P001  Kopi                15000    20
P002  Gula                12000     5
P003  Pensil               3000   100
KODE  NAMA                HARGA  STOK
P002  Gula                12000     5
KODE  NAMA                HARGA  STOK
P003  Pensil               3000   100
KODE  NAMA                HARGA  STOK
P003  Pensil               3000   100
P002  Gula                12000     5
P001  Kopi                15000    20
diubah: P002
dihapus: P003
tersimpan: inv.txt (2 produk)
dimuat: inv.txt (2 produk)
KODE  NAMA                HARGA  STOK
P001  Kopi                15000    20
P002  Gula                13000    10
galat: perintah tidak dikenal: perintah-tidak-kenal
ketik 'bantuan' untuk daftar perintah
selesai.

Cermati dua hal dari sesi ini. Pertama, galat tidak menghentikan sesi: setelah tambah P001 kedua ditolak, perintah berikutnya tetap dieksekusi, persis perilaku shell yang baik. Kedua, baris selesai. muncul dari perintah keluar — bukan dari kehabisan masukan — dan perintah daftar sesudah keluar tidak pernah dieksekusi. Exit code 1 di akhir adalah kontrak yang bekerja: dua galat dalam sesi tercatat sebagai status program, siap dibaca script.

Harness Unit: 51 Pemeriksaan dalam Sekali Jalan #

Lapis kedua memanggil method Inventaris dan function persistensi secara langsung. Tiga belas kelompok uji berjalan sekaligus:

cpp
// uji-inventaris.cpp — mini test harness untuk fungsi aplikasi inventaris.
// Pola mengikuti harness-uji.cpp pada Chapter Unit Testing: macro CHECK,
// penghitung lulus/gagal, dan kontrak exit code.
//
// Kompilasi:
//   g++ -std=c++23 -Wall -Wextra uji-inventaris.cpp -o uji-inventaris
// Jalankan:
//   ./uji-inventaris    (exit 0 = semua pengujian lulus, exit 1 = ada yang gagal)
//
// Catatan: pengujian round-trip menulis berkas sementara di direktori kerja
// dan menghapusnya di akhir; harness tidak menyisakan artefak apa pun.

#include <algorithm>     // std::copy_if, std::sort
#include <cctype>        // std::tolower
#include <charconv>      // std::from_chars
#include <cstddef>       // std::size_t
#include <expected>      // std::expected, std::unexpected
#include <filesystem>    // std::filesystem::remove
#include <fstream>       // std::ifstream, std::ofstream
#include <iostream>
#include <optional>      // std::optional
#include <string>
#include <string_view>
#include <vector>

// ---------- penghitung harness ----------

static int jumlahLulus = 0;
static int jumlahGagal = 0;
static std::string namaUjiAktif;

#define MULAI_UJI(nama) namaUjiAktif = (nama)
#define CHECK_TRUE(kondisi, deskripsi) \
    cekBoolean((kondisi), (deskripsi), #kondisi)
#define CHECK_EQ(aktual, diharapkan) \
    cekSama((aktual), (diharapkan), #aktual)
#define CHECK_GALAT(hasil, jenisDiharapkan, deskripsi) \
    cekGalatUmum((hasil), JenisGalat::jenisDiharapkan, (deskripsi))

void cekBoolean(bool kondisi, const std::string& deskripsi,
                const char* ekspresi) {
    if (kondisi) {
        ++jumlahLulus;
        std::cout << "[ LULUS ] " << namaUjiAktif << " - " << deskripsi << "\n";
    } else {
        ++jumlahGagal;
        std::cout << "[ GAGAL ] " << namaUjiAktif << " - " << deskripsi
                  << " (" << ekspresi << " bernilai false)\n";
    }
}

template <typename T, typename U>
void cekSama(const T& aktual, const U& diharapkan, const char* ekspresi) {
    if (aktual == diharapkan) {
        ++jumlahLulus;
        std::cout << "[ LULUS ] " << namaUjiAktif << " - " << ekspresi << "\n";
    } else {
        ++jumlahGagal;
        std::cout << "[ GAGAL ] " << namaUjiAktif << " - " << ekspresi
                  << " (aktual " << aktual << ", diharapkan " << diharapkan
                  << ")\n";
    }
}

// ---------- kode yang diuji (identik dengan src/inventaris.cpp) ----------

struct Produk {
    std::string kode;
    std::string nama;
    long long harga = 0;
    int stok = 0;

    bool sah() const {
        return !kode.empty() && !nama.empty() && harga >= 0 && stok >= 0;
    }
};

enum class JenisGalat {
    KodeKosong,
    DataTidakSah,
    KodeDuplikat,
    TidakDitemukan,
    BerkasGagal,
    BarisRusak,
};

struct Galat {
    JenisGalat jenis;
    std::string rincian;
};

std::string pesanGalat(const Galat& g) {
    switch (g.jenis) {
        case JenisGalat::KodeKosong:
            return "kode tidak boleh kosong";
        case JenisGalat::DataTidakSah:
            return "data tidak sah: " + g.rincian;
        case JenisGalat::KodeDuplikat:
            return "kode duplikat: " + g.rincian;
        case JenisGalat::TidakDitemukan:
            return "produk tidak ditemukan: " + g.rincian;
        case JenisGalat::BerkasGagal:
            return "berkas tidak dapat dibuka: " + g.rincian;
        case JenisGalat::BarisRusak:
            return "baris rusak: " + g.rincian;
    }
    return "galat tidak diketahui";
}

template <typename T>
std::expected<T, std::string> parseAngka(std::string_view teks) {
    T nilai{};
    const char* akhir = teks.data() + teks.size();
    auto [ptr, ec] = std::from_chars(teks.data(), akhir, nilai);
    if (ec != std::errc{} || ptr != akhir) {
        return std::unexpected(std::string(teks));
    }
    return nilai;
}

std::string keHurufKecil(std::string_view teks) {
    std::string hasil;
    hasil.reserve(teks.size());
    for (char c : teks) {
        hasil.push_back(
            static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
    }
    return hasil;
}

enum class Urutkan { Harga, Nama, Stok };

class Inventaris {
public:
    std::expected<void, Galat> tambah(const Produk& p) {
        if (p.kode.empty()) {
            return std::unexpected(Galat{JenisGalat::KodeKosong, ""});
        }
        if (!p.sah()) {
            return std::unexpected(Galat{JenisGalat::DataTidakSah, p.kode});
        }
        if (temukan(p.kode).has_value()) {
            return std::unexpected(Galat{JenisGalat::KodeDuplikat, p.kode});
        }
        produk_.push_back(p);
        return {};
    }

    std::expected<void, Galat> ubah(std::string_view kode, long long hargaBaru,
                                    int stokBaru) {
        auto indeks = temukan(kode);
        if (!indeks) {
            return std::unexpected(
                Galat{JenisGalat::TidakDitemukan, std::string(kode)});
        }
        if (hargaBaru < 0) {
            return std::unexpected(Galat{JenisGalat::DataTidakSah,
                                         std::string(kode) + " (harga negatif)"});
        }
        if (stokBaru < 0) {
            return std::unexpected(Galat{JenisGalat::DataTidakSah,
                                         std::string(kode) + " (stok negatif)"});
        }
        produk_[*indeks].harga = hargaBaru;
        produk_[*indeks].stok = stokBaru;
        return {};
    }

    std::expected<void, Galat> hapus(std::string_view kode) {
        auto indeks = temukan(kode);
        if (!indeks) {
            return std::unexpected(
                Galat{JenisGalat::TidakDitemukan, std::string(kode)});
        }
        produk_.erase(produk_.begin() + static_cast<std::ptrdiff_t>(*indeks));
        return {};
    }

    const Produk* cari(std::string_view kode) const {
        auto indeks = temukan(kode);
        return indeks ? &produk_[*indeks] : nullptr;
    }

    std::vector<Produk> daftar() const {
        return produk_;
    }

    std::size_t jumlah() const {
        return produk_.size();
    }

    std::vector<Produk> cariNama(std::string_view kataKunci) const {
        const std::string kunci = keHurufKecil(kataKunci);
        std::vector<Produk> hasil;
        for (const Produk& p : produk_) {
            if (keHurufKecil(p.nama).find(kunci) != std::string::npos) {
                hasil.push_back(p);
            }
        }
        return hasil;
    }

    std::vector<Produk> filterStok(int minimum) const {
        std::vector<Produk> hasil;
        std::copy_if(produk_.begin(), produk_.end(),
                     std::back_inserter(hasil),
                     [minimum](const Produk& p) { return p.stok >= minimum; });
        return hasil;
    }

    std::vector<Produk> urut(Urutkan bidang) const {
        std::vector<Produk> hasil = produk_;
        switch (bidang) {
            case Urutkan::Harga:
                std::sort(hasil.begin(), hasil.end(),
                          [](const Produk& a, const Produk& b) {
                              if (a.harga != b.harga) return a.harga < b.harga;
                              return a.kode < b.kode;
                          });
                break;
            case Urutkan::Stok:
                std::sort(hasil.begin(), hasil.end(),
                          [](const Produk& a, const Produk& b) {
                              if (a.stok != b.stok) return a.stok < b.stok;
                              return a.kode < b.kode;
                          });
                break;
            case Urutkan::Nama:
                std::sort(hasil.begin(), hasil.end(),
                          [](const Produk& a, const Produk& b) {
                              int urut = keHurufKecil(a.nama)
                                             .compare(keHurufKecil(b.nama));
                              if (urut != 0) return urut < 0;
                              return a.kode < b.kode;
                          });
                break;
        }
        return hasil;
    }

private:
    std::optional<std::size_t> temukan(std::string_view kode) const {
        for (std::size_t i = 0; i < produk_.size(); ++i) {
            if (std::string_view(produk_[i].kode) == kode) {
                return i;
            }
        }
        return std::nullopt;
    }

    std::vector<Produk> produk_;
};

std::optional<Produk> uraiBarisProduk(std::string_view baris) {
    std::vector<std::string_view> bagian;
    std::size_t mulai = 0;
    while (true) {
        std::size_t pipa = baris.find('|', mulai);
        if (pipa == std::string_view::npos) {
            bagian.push_back(baris.substr(mulai));
            break;
        }
        bagian.push_back(baris.substr(mulai, pipa - mulai));
        mulai = pipa + 1;
    }
    if (bagian.size() != 4) {
        return std::nullopt;
    }

    auto harga = parseAngka<long long>(bagian[2]);
    auto stok = parseAngka<int>(bagian[3]);
    if (!harga || !stok) {
        return std::nullopt;
    }

    Produk p{std::string(bagian[0]), std::string(bagian[1]), *harga, *stok};
    if (!p.sah()) {
        return std::nullopt;
    }
    return p;
}

std::expected<void, Galat> simpanKeFile(const Inventaris& inv,
                                        const std::string& namaBerkas) {
    std::ofstream keluar(namaBerkas);
    if (!keluar) {
        return std::unexpected(Galat{JenisGalat::BerkasGagal, namaBerkas});
    }
    for (const Produk& p : inv.daftar()) {
        keluar << p.kode << '|' << p.nama << '|' << p.harga << '|' << p.stok
               << '\n';
    }
    return {};
}

std::expected<Inventaris, Galat> muatDariFile(const std::string& namaBerkas) {
    std::ifstream masuk(namaBerkas);
    if (!masuk) {
        return std::unexpected(Galat{JenisGalat::BerkasGagal, namaBerkas});
    }
    Inventaris inv;
    std::string baris;
    int nomorBaris = 0;
    while (std::getline(masuk, baris)) {
        ++nomorBaris;
        if (baris.empty()) {
            continue;
        }
        auto p = uraiBarisProduk(baris);
        if (!p) {
            return std::unexpected(
                Galat{JenisGalat::BarisRusak,
                      std::to_string(nomorBaris) + ": " + baris});
        }
        auto hasil = inv.tambah(*p);
        if (!hasil) {
            return std::unexpected(hasil.error());
        }
    }
    return inv;
}

// ---------- helper harness untuk tipe galat inventaris ----------

template <typename T>
void cekGalatUmum(const std::expected<T, Galat>& hasil, JenisGalat jenis,
                  const std::string& deskripsi) {
    if (!hasil && hasil.error().jenis == jenis) {
        ++jumlahLulus;
        std::cout << "[ LULUS ] " << namaUjiAktif << " - " << deskripsi << "\n";
    } else {
        ++jumlahGagal;
        std::cout << "[ GAGAL ] " << namaUjiAktif << " - " << deskripsi << "\n";
    }
}

// ---------- data uji bersama ----------

Inventaris buatInventarisUji() {
    Inventaris inv;
    inv.tambah(Produk{"P001", "Kopi", 15000, 20});
    inv.tambah(Produk{"P002", "Gula", 12000, 5});
    inv.tambah(Produk{"P003", "Pensil", 3000, 100});
    return inv;
}

// ---------- test case, gaya Arrange-Act-Assert ----------

void ujiTambah() {
    MULAI_UJI("tambah");
    Inventaris inv;
    auto hasil = inv.tambah(Produk{"P001", "Kopi", 15000, 20});
    CHECK_TRUE(hasil.has_value(), "produk valid diterima");
    CHECK_EQ(inv.jumlah(), 1u);
    const Produk* p = inv.cari("P001");
    CHECK_TRUE(p != nullptr, "P001 ditemukan lewat cari()");
    CHECK_EQ(p->nama, std::string("Kopi"));
    CHECK_EQ(p->harga, 15000);
    CHECK_EQ(p->stok, 20);
}

void ujiKodeDuplikat() {
    MULAI_UJI("kode duplikat");
    Inventaris inv = buatInventarisUji();
    CHECK_GALAT(inv.tambah(Produk{"P001", "Teh", 5000, 7}), KodeDuplikat,
                "kode yang sudah dipakai ditolak");
    CHECK_EQ(inv.jumlah(), 3u);
}

void ujiInvariant() {
    MULAI_UJI("invariant produk");
    CHECK_TRUE(!Produk{}.sah(), "produk kosong tidak sah");
    Inventaris inv;
    CHECK_GALAT(inv.tambah(Produk{"", "TanpaKode", 100, 1}), KodeKosong,
                "kode kosong ditolak");
    CHECK_GALAT(inv.tambah(Produk{"P001", "Kopi", -100, 1}), DataTidakSah,
                "harga negatif ditolak");
    CHECK_GALAT(inv.tambah(Produk{"P001", "Kopi", 100, -1}), DataTidakSah,
                "stok negatif ditolak");
    CHECK_EQ(inv.jumlah(), 0u);
}

void ujiUbah() {
    MULAI_UJI("ubah");
    Inventaris inv = buatInventarisUji();
    auto hasil = inv.ubah("P002", 13000, 10);
    CHECK_TRUE(hasil.has_value(), "ubah produk yang ada berhasil");
    CHECK_EQ(inv.cari("P002")->harga, 13000);
    CHECK_EQ(inv.cari("P002")->stok, 10);
    CHECK_GALAT(inv.ubah("P999", 100, 1), TidakDitemukan,
                "ubah kode yang tidak ada ditolak");
    CHECK_GALAT(inv.ubah("P002", -1, 0), DataTidakSah,
                "harga negatif pada ubah ditolak");
}

void ujiHapus() {
    MULAI_UJI("hapus");
    Inventaris inv = buatInventarisUji();
    auto hasil = inv.hapus("P002");
    CHECK_TRUE(hasil.has_value(), "hapus produk yang ada berhasil");
    CHECK_EQ(inv.jumlah(), 2u);
    CHECK_TRUE(inv.cari("P002") == nullptr, "P002 tidak ditemukan lagi");
    CHECK_GALAT(inv.hapus("P999"), TidakDitemukan,
                "hapus kode yang tidak ada ditolak");
}

void ujiCariNama() {
    MULAI_UJI("cari nama");
    Inventaris inv = buatInventarisUji();
    auto hasil1 = inv.cariNama("kopi");
    CHECK_TRUE(hasil1.size() == 1u, "kata kunci kecil menemukan Kopi");
    CHECK_EQ(hasil1[0].kode, std::string("P001"));
    auto hasil2 = inv.cariNama("KOPI");
    CHECK_TRUE(hasil2.size() == 1u, "kata kunci besar juga menemukan Kopi");
    auto hasil3 = inv.cariNama("gul");
    CHECK_TRUE(hasil3.size() == 1u, "pola sebagian nama ditemukan");
    auto hasil4 = inv.cariNama("coklat");
    CHECK_TRUE(hasil4.empty(), "kata kunci tanpa hasil mengembalikan kosong");
}

void ujiFilterStok() {
    MULAI_UJI("filter stok");
    Inventaris inv = buatInventarisUji();
    auto minimum50 = inv.filterStok(50);
    CHECK_TRUE(minimum50.size() == 1u, "hanya stok >= 50 yang lolos");
    CHECK_EQ(minimum50[0].kode, std::string("P003"));
    auto minimum0 = inv.filterStok(0);
    CHECK_TRUE(minimum0.size() == 3u, "batas 0 meloloskan semua produk");
}

void ujiUrut() {
    MULAI_UJI("urut");
    Inventaris inv = buatInventarisUji();
    CHECK_EQ(inv.urut(Urutkan::Harga)[0].kode, std::string("P003"));
    CHECK_EQ(inv.urut(Urutkan::Stok)[0].kode, std::string("P002"));
    CHECK_EQ(inv.urut(Urutkan::Nama)[0].kode, std::string("P002"));
    CHECK_TRUE(inv.daftar()[0].kode == "P001",
               "urut() tidak mengubah urutan asli");
}

void ujiUraiBaris() {
    MULAI_UJI("urai baris berkas");
    auto p1 = uraiBarisProduk("P009|Teh|3000|7");
    CHECK_TRUE(p1.has_value(), "baris lengkap terurai");
    CHECK_EQ(p1->nama, std::string("Teh"));
    CHECK_EQ(p1->harga, 3000);
    CHECK_EQ(p1->stok, 7);
    CHECK_TRUE(!uraiBarisProduk("P009|Teh|abc|7").has_value(),
               "harga bukan angka ditolak");
    CHECK_TRUE(!uraiBarisProduk("P009 Teh 3000 7").has_value(),
               "baris tanpa pemisah ditolak");
}

void ujiRoundTripFile() {
    MULAI_UJI("round-trip berkas");
    std::filesystem::remove("inventaris-uji-data.txt");

    Inventaris asal = buatInventarisUji();
    auto tersimpan = simpanKeFile(asal, "inventaris-uji-data.txt");
    CHECK_TRUE(tersimpan.has_value(), "simpan berhasil");

    auto dimuat = muatDariFile("inventaris-uji-data.txt");
    CHECK_TRUE(dimuat.has_value(), "muat berhasil");
    Inventaris salinan = std::move(*dimuat);
    CHECK_EQ(salinan.jumlah(), 3u);
    const Produk* p = salinan.cari("P003");
    CHECK_TRUE(p != nullptr, "P003 ditemukan kembali");
    CHECK_EQ(p->nama, std::string("Pensil"));
    CHECK_EQ(p->harga, 3000);
    CHECK_EQ(p->stok, 100);

    std::filesystem::remove("inventaris-uji-data.txt");
}

void ujiBerkasGagal() {
    MULAI_UJI("berkas gagal");
    CHECK_GALAT(muatDariFile("inventaris-uji-tidak-ada.txt"), BerkasGagal,
                "muat berkas yang tidak ada ditolak");
}

void ujiBarisRusak() {
    MULAI_UJI("baris rusak");
    std::filesystem::remove("inventaris-uji-rusak.txt");
    {
        std::ofstream keluar("inventaris-uji-rusak.txt");
        keluar << "P001|Kopi|15000|20\n";
        keluar << "P002 Gula 12000 5\n";   // tanpa pemisah '|'
    }
    auto hasil = muatDariFile("inventaris-uji-rusak.txt");
    CHECK_GALAT(hasil, BarisRusak, "berkas dengan baris rusak ditolak");
    std::filesystem::remove("inventaris-uji-rusak.txt");
}

void ujiPesanGalat() {
    MULAI_UJI("pesan galat");
    CHECK_EQ(pesanGalat({JenisGalat::TidakDitemukan, "P009"}),
             std::string("produk tidak ditemukan: P009"));
    CHECK_EQ(pesanGalat({JenisGalat::KodeDuplikat, "P001"}),
             std::string("kode duplikat: P001"));
}

int main() {
    ujiTambah();
    ujiKodeDuplikat();
    ujiInvariant();
    ujiUbah();
    ujiHapus();
    ujiCariNama();
    ujiFilterStok();
    ujiUrut();
    ujiUraiBaris();
    ujiRoundTripFile();
    ujiBerkasGagal();
    ujiBarisRusak();
    ujiPesanGalat();

    std::cout << "\nringkasan: " << jumlahLulus << " lulus, " << jumlahGagal
              << " gagal\n";
    return jumlahGagal == 0 ? 0 : 1;
}

Output yang Dihasilkan:

text
[ LULUS ] tambah - produk valid diterima
[ LULUS ] tambah - inv.jumlah()
[ LULUS ] tambah - P001 ditemukan lewat cari()
[ LULUS ] tambah - p->nama
[ LULUS ] tambah - p->harga
[ LULUS ] tambah - p->stok
[ LULUS ] kode duplikat - kode yang sudah dipakai ditolak
[ LULUS ] kode duplikat - inv.jumlah()
[ LULUS ] invariant produk - produk kosong tidak sah
[ LULUS ] invariant produk - kode kosong ditolak
[ LULUS ] invariant produk - harga negatif ditolak
[ LULUS ] invariant produk - stok negatif ditolak
[ LULUS ] invariant produk - inv.jumlah()
[ LULUS ] ubah - ubah produk yang ada berhasil
[ LULUS ] ubah - inv.cari("P002")->harga
[ LULUS ] ubah - inv.cari("P002")->stok
[ LULUS ] ubah - ubah kode yang tidak ada ditolak
[ LULUS ] ubah - harga negatif pada ubah ditolak
[ LULUS ] hapus - hapus produk yang ada berhasil
[ LULUS ] hapus - inv.jumlah()
[ LULUS ] hapus - P002 tidak ditemukan lagi
[ LULUS ] hapus - hapus kode yang tidak ada ditolak
[ LULUS ] cari nama - kata kunci kecil menemukan Kopi
[ LULUS ] cari nama - hasil1[0].kode
[ LULUS ] cari nama - kata kunci besar juga menemukan Kopi
[ LULUS ] cari nama - pola sebagian nama ditemukan
[ LULUS ] cari nama - kata kunci tanpa hasil mengembalikan kosong
[ LULUS ] filter stok - hanya stok >= 50 yang lolos
[ LULUS ] filter stok - minimum50[0].kode
[ LULUS ] filter stok - batas 0 meloloskan semua produk
[ LULUS ] urut - inv.urut(Urutkan::Harga)[0].kode
[ LULUS ] urut - inv.urut(Urutkan::Stok)[0].kode
[ LULUS ] urut - inv.urut(Urutkan::Nama)[0].kode
[ LULUS ] urut - urut() tidak mengubah urutan asli
[ LULUS ] urai baris berkas - baris lengkap terurai
[ LULUS ] urai baris berkas - p1->nama
[ LULUS ] urai baris berkas - p1->harga
[ LULUS ] urai baris berkas - p1->stok
[ LULUS ] urai baris berkas - harga bukan angka ditolak
[ LULUS ] urai baris berkas - baris tanpa pemisah ditolak
[ LULUS ] round-trip berkas - simpan berhasil
[ LULUS ] round-trip berkas - muat berhasil
[ LULUS ] round-trip berkas - salinan.jumlah()
[ LULUS ] round-trip berkas - P003 ditemukan kembali
[ LULUS ] round-trip berkas - p->nama
[ LULUS ] round-trip berkas - p->harga
[ LULUS ] round-trip berkas - p->stok
[ LULUS ] berkas gagal - muat berkas yang tidak ada ditolak
[ LULUS ] baris rusak - berkas dengan baris rusak ditolak
[ LULUS ] pesan galat - pesanGalat({JenisGalat::TidakDitemukan, "P009"})
[ LULUS ] pesan galat - pesanGalat({JenisGalat::KodeDuplikat, "P001"})

ringkasan: 51 lulus, 0 gagal

Semua 51 pemeriksaan lulus dan program keluar dengan exit code 0. Yang dikunci oleh daftar panjang itu patut dirangkum, karena sebagian adalah perilaku yang gampang rusak diam-diam:

Kelompok ujiPerilaku yang dikunci
invariant produkkode kosong, harga negatif, dan stok negatif ditolak dengan jenis galat yang tepat
urutstd::sort menghasilkan urutan deterministik dan tidak merusak urutan asal
urai baris berkasharga bukan angka dan baris tanpa pemisah ditolak sebelum menyentuh inventaris
round-trip berkassimpan lalu muat mengembalikan data yang sama persis
berkas gagalmemuat berkas yang tidak ada menghasilkan BerkasGagal, bukan crash
baris rusaksatu baris cacat menggagalkan seluruh muat — semantik all-or-nothing dari Bagian 4

Dua detail teknis harness layak dicatat. Kelompok round-trip menulis inventaris-uji-data.txt ke direktori kerja lalu menghapusnya dengan std::filesystem::remove di kedua ujungnya — pengujian yang menyisakan sampah akan merusak run berikutnya dan menjatuhkan kepercayaan pada seluruh harness. Dan kelompok urut sengaja membandingkan daftar()[0] sesudah tiga kali pengurutan: bukti bahwa pendekatan copy-then-sort dari Bagian 3 bukan sekadar gaya, melainkan kontrak.

Dengan lulusnya kedua lapis ini, proyek inventaris tuntas: model data yang menjaga invariant-nya sendiri, CRUD yang memvalidasi setiap perubahan, pencarian dan pengurutan yang deterministik, persistensi yang gagal-utuh, dan kini bukti otomatis untuk semuanya. Proyek berikutnya menaikkan satu tingkat lagi — sistem manajemen perpustakaan dengan class yang saling berelasi — dan kita akan menemukan pola-pola yang sama, dari invariant sampai harness, menunggu untuk dipakai ulang.

Rujukan resmi: std::expected (cppreference) , std::filesystem (cppreference) .