Semua bagian mesin sudah selesai: parser dari Bagian 1, validasi dari Bagian 2, dan agregasi dari Bagian 3. Bagian terakhir ini menambahkan lapisan penyajian — laporan — lalu mengunci seluruh proyek dengan pengujian. Laporan yang baik menyembunyikan kerumitan: pembacanya tidak perlu tahu ada std::expected di balik layar; yang ia butuhkan adalah tabel yang sejajar, daftar baris yang ditolak, dan baris TOTAL yang bisa dicek ulang dengan kalkulator. Kita rakit semuanya menjadi satu program utuh, bedah alurnya sekali lagi dari ujung ke ujung, lalu tutup proyek dengan test harness dan kontrak exit code.

Merancang Tabel dengan iomanip #

Angka dari Bagian 3 berpotensi panjangnya berbeda-beda — pendapatan bisa lima sampai dua belas digit — sehingga mencetak mentah membuat kolom tidak sejajar dan laporan sulit dibaca. Header <iomanip> menyediakan manipulator untuk mengatur tampilan std::cout: std::setw(n) mengatur lebar minimal keluaran berikutnya, std::left/std::right menentukan perataan, dan pasangan std::fixed + std::setprecision(2) mengunci angka riil menjadi dua desimal. Satu hal yang sering mengejutkan: setw hanya berlaku untuk satu item keluaran berikutnya, sementara left/right dan fixed bersifat menempel sampai diganti — itulah kenapa header laporan kita menuliskannya berantai:

cpp satu baris header laporan: manipulator bekerja berantai
std::cout << std::left << std::setw(14) << "Produk" << std::right
          << std::setw(11) << "Transaksi" << std::setw(8) << "Unit"
          << std::setw(13) << "Pendapatan" << std::setw(12) << "Rata-rata"
          << std::setw(9) << "HargaMin" << std::setw(10) << "HargaMaks"
          << '\n';

Lebar kolom laporan kita dirancang sebagai satu kesatuan:

KolomsetwPerataan
Produk14kiri
Transaksi11kanan
Unit8kanan
Pendapatan13kanan
Rata-rata12kanan, fixed 2 desimal
HargaMin9kanan
HargaMaks10kanan

Tujuh kolom itu menempatkan tepi kanan tabel pada lebar 77 karakter, sedangkan garis pemisah dibuat dengan std::string(78, '-') — satu karakter lebih panjang, sehingga tabel selalu berada di dalam garis. Bila Anda mengubah lebar kolom mana pun, setel ulang juga panjang garisnya agar laporan tetap rapi. Angka rata-rata dan TOTAL dihitung dalam double persis seperti pola Bagian 3, sementara akumulasi global (flag pertama untuk min/maks lintas produk) berjalan di dalam loop cetak agar tidak perlu iterasi kedua.

Program Lengkap: analisis-csv-final.cpp #

Berikut program utuh hasil empat bagian ini — versi final yang juga tersimpan di src/analisis-csv.cpp dengan isi identik. Program ini self-contained: bila berkas data tidak ada, salinan tertanam dipakai dan laporannya tetap sama persis.

cpp
// analisis-csv.cpp — alat analisis data penjualan berformat CSV.
//
// Program membaca berkas data/penjualan.csv (kolom: tanggal, produk,
// jumlah, harga), memvalidasi setiap baris, menghitung statistik per
// produk, lalu mencetak laporan ringkas ke stdout:
//
//     g++ -std=c++23 -Wall -Wextra analisis-csv.cpp -o analisis-csv
//     ./analisis-csv
//
// Bila berkas data tidak ditemukan (misalnya program dijalankan dari
// folder lain), program memakai salinan data yang tertanam di dalam
// kode, sehingga tetap bisa dicoba di lingkungan mana pun — dan
// laporannya selalu sama persis.

#include <charconv>
#include <expected>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <map>
#include <optional>
#include <sstream>
#include <string>
#include <string_view>
#include <vector>

// ============================ Parser ============================

// Memecah satu baris CSV berdasarkan tanda koma. Kolom kosong tetap
// dihasilkan, sehingga baris yang kehilangan kolom bisa terdeteksi.
//
// Batasan parser ini: tidak ada dukungan tanda kutip, jadi nilai kolom
// tidak boleh mengandung koma (pembahasannya ada di Bagian 1).
std::vector<std::string> pisah(const std::string& teks, char pemisah) {
    std::vector<std::string> hasil;
    std::string::size_type awal = 0;
    while (true) {
        const std::string::size_type posisi = teks.find(pemisah, awal);
        if (posisi == std::string::npos) {
            hasil.push_back(teks.substr(awal));
            return hasil;
        }
        hasil.push_back(teks.substr(awal, posisi - awal));
        awal = posisi + 1;
    }
}

// ============================ Validasi ============================

// Satu baris penjualan yang sudah lolos validasi.
struct BarisPenjualan {
    std::string tanggal;
    std::string produk;
    long long jumlah = 0;
    long long harga = 0;

    long long nilai() const { return jumlah * harga; }  // nilai transaksi (rupiah)
};

// Konversi teks ke bilangan bulat yang ketat memakai std::from_chars:
// seluruh teks harus terpakai, tanpa spasi, tanpa tanda '+'.
// Mengembalikan std::nullopt bila gagal — tanpa exception.
std::optional<long long> keAngka(const std::string& teks) {
    long long nilai = 0;
    const char* awal = teks.data();
    const char* akhir = awal + teks.size();
    auto [ptr, ec] = std::from_chars(awal, akhir, nilai);
    if (ec != std::errc{} || ptr != akhir) {
        return std::nullopt;
    }
    return nilai;
}

// Memvalidasi dan mengubah satu baris (sudah dipecah menjadi kolom)
// menjadi data penjualan. std::expected membawa pesan galat yang
// spesifik bila baris ditolak.
std::expected<BarisPenjualan, std::string> parseBaris(
    const std::vector<std::string>& kolom) {
    if (kolom.size() != 4) {
        return std::unexpected("harus 4 kolom, ditemukan "
                               + std::to_string(kolom.size()));
    }
    if (kolom[0].empty()) {
        return std::unexpected("tanggal tidak boleh kosong");
    }
    if (kolom[1].empty()) {
        return std::unexpected("produk tidak boleh kosong");
    }
    const auto jumlah = keAngka(kolom[2]);
    if (!jumlah) {
        return std::unexpected("jumlah bukan bilangan bulat: \"" + kolom[2] + "\"");
    }
    if (*jumlah <= 0) {
        return std::unexpected("jumlah harus lebih dari 0");
    }
    const auto harga = keAngka(kolom[3]);
    if (!harga) {
        return std::unexpected("harga bukan bilangan bulat: \"" + kolom[3] + "\"");
    }
    if (*harga < 0) {
        return std::unexpected("harga tidak boleh negatif");
    }
    return BarisPenjualan{kolom[0], kolom[1], *jumlah, *harga};
}

// ============================ Pembacaan berkas ============================

struct HasilBaca {
    std::vector<BarisPenjualan> valid;  // baris yang lolos validasi
    std::vector<std::string> galat;     // "baris N: alasan" untuk yang ditolak
};

// Membaca stream baris demi baris. Baris pertama yang berupa header
// ("tanggal,...") dilewati, baris kosong diabaikan. Baris tidak valid
// dicatat beserta nomor barisnya — fail-closed: data yang meragukan
// tidak pernah masuk ke perhitungan.
HasilBaca bacaCsv(std::istream& masuk) {
    HasilBaca hasil;
    std::string baris;
    int nomor = 0;
    while (std::getline(masuk, baris)) {
        ++nomor;
        if (!baris.empty() && baris.back() == '\r') {
            baris.pop_back();  // buang '\r' sisa berkas berformat CRLF
        }
        if (baris.empty()) {
            continue;
        }
        const std::vector<std::string> kolom = pisah(baris, ',');
        if (nomor == 1 && !kolom.empty() && kolom[0] == "tanggal") {
            continue;  // baris header
        }
        auto data = parseBaris(kolom);
        if (data) {
            hasil.valid.push_back(*data);
        } else {
            hasil.galat.push_back("baris " + std::to_string(nomor) + ": "
                                  + data.error());
        }
    }
    return hasil;
}

// ============================ Statistik ============================

struct StatistikProduk {
    int transaksi = 0;         // banyak baris untuk produk ini
    long long unit = 0;        // total barang terjual
    long long pendapatan = 0;  // total nilai transaksi (rupiah)
    long long hargaMin = 0;    // harga terendah yang tercatat
    long long hargaMaks = 0;   // harga tertinggi yang tercatat
};

// Mengagregasi baris valid per produk. std::map dipilih agar hasil
// selalu terurut menurut nama produk — output jadi deterministik.
std::map<std::string, StatistikProduk> agregasi(
    const std::vector<BarisPenjualan>& baris) {
    std::map<std::string, StatistikProduk> hasil;
    for (const BarisPenjualan& b : baris) {
        StatistikProduk& s = hasil[b.produk];
        if (s.transaksi == 0 || b.harga < s.hargaMin) {
            s.hargaMin = b.harga;
        }
        if (s.transaksi == 0 || b.harga > s.hargaMaks) {
            s.hargaMaks = b.harga;
        }
        ++s.transaksi;
        s.unit += b.jumlah;
        s.pendapatan += b.nilai();
    }
    return hasil;
}

// ============================ Laporan ============================

void cetakLaporan(const HasilBaca& baca) {
    const std::map<std::string, StatistikProduk> statistik = agregasi(baca.valid);

    std::cout << "==============================================\n";
    std::cout << " LAPORAN ANALISIS PENJUALAN (CSV)\n";
    std::cout << "==============================================\n";
    std::cout << "Baris valid   : " << baca.valid.size() << '\n';
    std::cout << "Baris ditolak : " << baca.galat.size() << '\n';
    for (const std::string& g : baca.galat) {
        std::cout << "  - " << g << '\n';
    }

    std::cout << '\n';
    std::cout << std::left << std::setw(14) << "Produk" << std::right
              << std::setw(11) << "Transaksi" << std::setw(8) << "Unit"
              << std::setw(13) << "Pendapatan" << std::setw(12) << "Rata-rata"
              << std::setw(9) << "HargaMin" << std::setw(10) << "HargaMaks"
              << '\n';
    std::cout << std::string(78, '-') << '\n';

    int totalTransaksi = 0;
    long long totalUnit = 0;
    long long totalPendapatan = 0;
    long long globalMin = 0;
    long long globalMaks = 0;
    bool pertama = true;

    for (const auto& [produk, s] : statistik) {
        const double rataRata =
            static_cast<double>(s.pendapatan) / s.transaksi;
        std::cout << std::left << std::setw(14) << produk << std::right
                  << std::setw(11) << s.transaksi << std::setw(8) << s.unit
                  << std::setw(13) << s.pendapatan << std::setw(12)
                  << std::fixed << std::setprecision(2) << rataRata
                  << std::setw(9) << s.hargaMin << std::setw(10) << s.hargaMaks
                  << '\n';
        totalTransaksi += s.transaksi;
        totalUnit += s.unit;
        totalPendapatan += s.pendapatan;
        if (pertama || s.hargaMin < globalMin) {
            globalMin = s.hargaMin;
        }
        if (pertama || s.hargaMaks > globalMaks) {
            globalMaks = s.hargaMaks;
        }
        pertama = false;
    }

    std::cout << std::string(78, '-') << '\n';
    const double rataTotal =
        static_cast<double>(totalPendapatan) / totalTransaksi;
    std::cout << std::left << std::setw(14) << "TOTAL" << std::right
              << std::setw(11) << totalTransaksi << std::setw(8) << totalUnit
              << std::setw(13) << totalPendapatan << std::setw(12)
              << std::fixed << std::setprecision(2) << rataTotal
              << std::setw(9) << globalMin << std::setw(10) << globalMaks
              << '\n';
}

// ============================ Data tertanam ============================

// Salinan data/penjualan.csv yang ditanam di dalam program. Bila berkas
// eksternal tidak tersedia, salinan ini dipakai sehingga laporan yang
// dicetak tetap identik di lingkungan mana pun.
constexpr std::string_view DATA_CSV =
    "tanggal,produk,jumlah,harga\n"
    "2025-11-01,Buku Tulis,10,3500\n"
    "2025-11-01,Pensil 2B,25,2000\n"
    "2025-11-02,Pulpen Gel,15,4500\n"
    "2025-11-03,Buku Tulis,7,3750\n"
    "2025-11-03,Penghapus,30,1500\n"
    "2025-11-04,Pensil 2B,20,2000\n";

// Menyiapkan stream data: bila berkas data/penjualan.csv bisa dibuka,
// isi berkas itulah yang dipakai; jika tidak, pakai salinan tertanam.
std::istringstream muatData() {
    std::string isi;
    if (std::ifstream berkas{"data/penjualan.csv"}) {
        std::ostringstream penampung;
        penampung << berkas.rdbuf();
        isi = penampung.str();
    } else {
        isi = DATA_CSV;
    }
    return std::istringstream{isi};
}

// ============================ Demo ============================

int main() {
    std::istringstream aliran = muatData();
    const HasilBaca baca = bacaCsv(aliran);
    cetakLaporan(baca);
    return 0;
}

Output yang Dihasilkan:

text
==============================================
 LAPORAN ANALISIS PENJUALAN (CSV)
==============================================
Baris valid   : 6
Baris ditolak : 0

Produk          Transaksi    Unit   Pendapatan   Rata-rata HargaMin HargaMaks
------------------------------------------------------------------------------
Buku Tulis              2      17        61250    30625.00     3500      3750
Penghapus               1      30        45000    45000.00     1500      1500
Pensil 2B               2      45        90000    45000.00     2000      2000
Pulpen Gel              1      15        67500    67500.00     4500      4500
------------------------------------------------------------------------------
TOTAL                   6     107       263750    43958.33     1500      4500

Laporan ini adalah gabungan semua bagian: bagian atasnya merangkum hasil pembacaan (6 valid, 0 ditolak), tabel per produk datang dari agregasi Bagian 3, dan baris TOTAL menutup dengan rata-rata global 43958.33. Mari kita bedah alurnya dari ujung ke ujung:

  1. main memanggil muatData() — function ini mencoba membuka data/penjualan.csv; bila berhasil, seluruh isi berkas disedot lewat rdbuf(), bila gagal salinan tertanam DATA_CSV dipakai. Keduanya berisi teks identik, jadi keluarannya deterministik di lingkungan mana pun.
  2. bacaCsv(aliran) membaca baris demi baris — std::getline menghasilkan satu baris per iterasi, penghitung nomor mencatat posisi fisiknya, karakter \r sisa CRLF dibuang, baris kosong dan header tanggal,... dilewati.
  3. pisah(baris, ',') memotong kolom — parser koma sederhana dari Bagian 1; kolom kosong tetap dihasilkan sehingga kolom yang hilang terlihat, bukan runtuh.
  4. parseBaris(kolom) menegakkan tujuh gerbang — jumlah kolom, tanggal, produk, dua pemeriksaan jumlah (angka dan lebih dari 0), dua pemeriksaan harga (angka dan non-negatif). Konversi angka memakai keAngka berbasis std::from_chars: seluruh teks harus terurai, tanpa spasi, tanpa tanda plus, tanpa exception.
  5. Hasilnya dipilah dua jalur — baris valid masuk hasil.valid, baris ditolak masuk hasil.galat dalam format baris N: alasan. Fail-closed penuh: tidak ada baris meragukan yang menyelinap ke perhitungan.
  6. agregasi(baca.valid) mengelompokkan per produk — std::map<std::string, StatistikProduk> mengakumulasi transaksi, unit, pendapatan, hargaMin, dan hargaMaks; sentinel transaksi == 0 menginisialisasi min/maks dari data sungguhan.
  7. cetakLaporan menyajikan dua bagian — dulu daftar galat (kosong pada dataset ini), lalu tabel tujuh kolom yang sejajar lewat setw, dengan rata-rata dihitung sebagai static_cast<double>(pendapatan) / transaksi.
  8. main mengembalikan 0 — perhatikan keputusan desainnya: meskipun ada baris yang ditolak, program tetap sukses, karena melaporkan baris rusak adalah tugasnya; daftar galat adalah keluaran sah, bukan kegagalan. Kontras dengan kalkulator proyek sebelumnya yang mengembalikan 1 saat ada baris galat — kontrak exit code mengikuti tujuan program, bukan kebiasaan.

Mengunci Perilaku dengan Test Harness #

Langkah penutup proyek: test harness. Harness ini mengikuti pola Chapter Unit Testing — macro MULAI_UJI, CHECK_TRUE, CHECK_EQ, penghitung lulus/gagal, dan kontrak exit code. Ia bisa menguji semua logika tanpa menyentuh stdin karena seluruh logika proyek ini berupa function murni yang menerima parameter dan mengembalikan hasil; harness menyalin function yang diuji ke dalam satu berkas agar bisa dikompilasi sendiri (pemecahan menjadi header dan source dibahas di Chapter separate compilation).

cpp
// uji-analisis.cpp — test harness untuk alat analisis CSV.
//
// Pola harness sama seperti Chapter Unit Testing: macro
// MULAI_UJI/CHECK_TRUE/CHECK_EQ, penghitung lulus-gagal, dan kontrak
// exit code (0 berarti semua test lulus). Bagian parser, validasi,
// pembacaan, dan statistik disalin apa adanya dari src/analisis-csv.cpp.
//
//     g++ -std=c++23 -Wall -Wextra uji-analisis.cpp -o uji-analisis
//     ./uji-analisis

#include <charconv>
#include <expected>
#include <iostream>
#include <map>
#include <optional>
#include <sstream>
#include <string>
#include <vector>

// ---------- mini test harness (pola Chapter Unit Testing) ----------

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)

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";
    }
}

// ============================ Parser ============================

// Memecah satu baris CSV berdasarkan tanda koma. Kolom kosong tetap
// dihasilkan, sehingga baris yang kehilangan kolom bisa terdeteksi.
//
// Batasan parser ini: tidak ada dukungan tanda kutip, jadi nilai kolom
// tidak boleh mengandung koma (pembahasannya ada di Bagian 1).
std::vector<std::string> pisah(const std::string& teks, char pemisah) {
    std::vector<std::string> hasil;
    std::string::size_type awal = 0;
    while (true) {
        const std::string::size_type posisi = teks.find(pemisah, awal);
        if (posisi == std::string::npos) {
            hasil.push_back(teks.substr(awal));
            return hasil;
        }
        hasil.push_back(teks.substr(awal, posisi - awal));
        awal = posisi + 1;
    }
}
// ============================ Validasi ============================

// Satu baris penjualan yang sudah lolos validasi.
struct BarisPenjualan {
    std::string tanggal;
    std::string produk;
    long long jumlah = 0;
    long long harga = 0;

    long long nilai() const { return jumlah * harga; }  // nilai transaksi (rupiah)
};

// Konversi teks ke bilangan bulat yang ketat memakai std::from_chars:
// seluruh teks harus terpakai, tanpa spasi, tanpa tanda '+'.
// Mengembalikan std::nullopt bila gagal — tanpa exception.
std::optional<long long> keAngka(const std::string& teks) {
    long long nilai = 0;
    const char* awal = teks.data();
    const char* akhir = awal + teks.size();
    auto [ptr, ec] = std::from_chars(awal, akhir, nilai);
    if (ec != std::errc{} || ptr != akhir) {
        return std::nullopt;
    }
    return nilai;
}

// Memvalidasi dan mengubah satu baris (sudah dipecah menjadi kolom)
// menjadi data penjualan. std::expected membawa pesan galat yang
// spesifik bila baris ditolak.
std::expected<BarisPenjualan, std::string> parseBaris(
    const std::vector<std::string>& kolom) {
    if (kolom.size() != 4) {
        return std::unexpected("harus 4 kolom, ditemukan "
                               + std::to_string(kolom.size()));
    }
    if (kolom[0].empty()) {
        return std::unexpected("tanggal tidak boleh kosong");
    }
    if (kolom[1].empty()) {
        return std::unexpected("produk tidak boleh kosong");
    }
    const auto jumlah = keAngka(kolom[2]);
    if (!jumlah) {
        return std::unexpected("jumlah bukan bilangan bulat: \"" + kolom[2] + "\"");
    }
    if (*jumlah <= 0) {
        return std::unexpected("jumlah harus lebih dari 0");
    }
    const auto harga = keAngka(kolom[3]);
    if (!harga) {
        return std::unexpected("harga bukan bilangan bulat: \"" + kolom[3] + "\"");
    }
    if (*harga < 0) {
        return std::unexpected("harga tidak boleh negatif");
    }
    return BarisPenjualan{kolom[0], kolom[1], *jumlah, *harga};
}
// ============================ Pembacaan berkas ============================

struct HasilBaca {
    std::vector<BarisPenjualan> valid;  // baris yang lolos validasi
    std::vector<std::string> galat;     // "baris N: alasan" untuk yang ditolak
};

// Membaca stream baris demi baris. Baris pertama yang berupa header
// ("tanggal,...") dilewati, baris kosong diabaikan. Baris tidak valid
// dicatat beserta nomor barisnya — fail-closed: data yang meragukan
// tidak pernah masuk ke perhitungan.
HasilBaca bacaCsv(std::istream& masuk) {
    HasilBaca hasil;
    std::string baris;
    int nomor = 0;
    while (std::getline(masuk, baris)) {
        ++nomor;
        if (!baris.empty() && baris.back() == '\r') {
            baris.pop_back();  // buang '\r' sisa berkas berformat CRLF
        }
        if (baris.empty()) {
            continue;
        }
        const std::vector<std::string> kolom = pisah(baris, ',');
        if (nomor == 1 && !kolom.empty() && kolom[0] == "tanggal") {
            continue;  // baris header
        }
        auto data = parseBaris(kolom);
        if (data) {
            hasil.valid.push_back(*data);
        } else {
            hasil.galat.push_back("baris " + std::to_string(nomor) + ": "
                                  + data.error());
        }
    }
    return hasil;
}
// ============================ Statistik ============================

struct StatistikProduk {
    int transaksi = 0;         // banyak baris untuk produk ini
    long long unit = 0;        // total barang terjual
    long long pendapatan = 0;  // total nilai transaksi (rupiah)
    long long hargaMin = 0;    // harga terendah yang tercatat
    long long hargaMaks = 0;   // harga tertinggi yang tercatat
};

// Mengagregasi baris valid per produk. std::map dipilih agar hasil
// selalu terurut menurut nama produk — output jadi deterministik.
std::map<std::string, StatistikProduk> agregasi(
    const std::vector<BarisPenjualan>& baris) {
    std::map<std::string, StatistikProduk> hasil;
    for (const BarisPenjualan& b : baris) {
        StatistikProduk& s = hasil[b.produk];
        if (s.transaksi == 0 || b.harga < s.hargaMin) {
            s.hargaMin = b.harga;
        }
        if (s.transaksi == 0 || b.harga > s.hargaMaks) {
            s.hargaMaks = b.harga;
        }
        ++s.transaksi;
        s.unit += b.jumlah;
        s.pendapatan += b.nilai();
    }
    return hasil;
}


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

void ujiPisah() {
    MULAI_UJI("pisah kolom CSV");
    // Arrange: satu baris CSV standar.
    // Act
    const std::vector<std::string> kolom = pisah("2025-11-01,Buku Tulis,10,3500", ',');
    // Assert
    CHECK_TRUE(kolom.size() == 4, "empat kolom");
    CHECK_EQ(kolom[0], std::string("2025-11-01"));
    CHECK_EQ(kolom[3], std::string("3500"));
    CHECK_TRUE(pisah("a,,c", ',').size() == 3, "kolom kosong tetap terhitung");
    CHECK_TRUE(pisah("a,b,c,d,e", ',').size() == 5, "kolom berlebih terdeteksi");
}

void ujiKeAngka() {
    MULAI_UJI("keAngka ketat dengan from_chars");
    CHECK_EQ(*keAngka("42"), 42);
    CHECK_EQ(*keAngka("-7"), -7);
    CHECK_TRUE(!keAngka("12ab").has_value(), "sisa karakter ditolak");
    CHECK_TRUE(!keAngka("").has_value(), "teks kosong ditolak");
    CHECK_TRUE(!keAngka("+7").has_value(), "tanda plus ditolak");
    CHECK_TRUE(!keAngka(" 7").has_value(), "spasi depan ditolak");
    CHECK_TRUE(!keAngka("dua").has_value(), "huruf ditolak");
}

void ujiParseBarisValid() {
    MULAI_UJI("parseBaris menerima baris valid");
    auto hasil = parseBaris(pisah("2025-11-01,Buku Tulis,10,3500", ','));
    CHECK_TRUE(hasil.has_value(), "baris valid diterima");
    CHECK_EQ(hasil->produk, std::string("Buku Tulis"));
    CHECK_EQ(hasil->jumlah, 10);
    CHECK_EQ(hasil->harga, 3500);
    CHECK_EQ(hasil->nilai(), 35000);
}

void ujiParseBarisDitolak() {
    MULAI_UJI("parseBaris menolak baris tidak valid");
    auto kolomKurang = parseBaris(pisah("2025-11-05,Penggaris", ','));
    CHECK_TRUE(!kolomKurang.has_value(), "dua kolom ditolak");
    CHECK_TRUE(kolomKurang.error().find("4 kolom") != std::string::npos,
               "pesan menyebut jumlah kolom");

    auto jumlahHuruf = parseBaris(pisah("2025-11-06,Stabilo,dua,9000", ','));
    CHECK_TRUE(!jumlahHuruf.has_value(), "jumlah berisi huruf ditolak");

    auto jumlahNol = parseBaris(pisah("2025-11-06,Tip-Ex,0,12000", ','));
    CHECK_TRUE(!jumlahNol.has_value(), "jumlah nol ditolak");
    CHECK_TRUE(jumlahNol.error().find("lebih dari 0") != std::string::npos,
               "pesan menyebut syarat jumlah");

    auto hargaSampah = parseBaris(pisah("2025-11-07,Penggaris,4,3000x", ','));
    CHECK_TRUE(!hargaSampah.has_value(), "harga dengan sisa karakter ditolak");

    auto produkKosong = parseBaris(pisah("2025-11-08,,3,1000", ','));
    CHECK_TRUE(!produkKosong.has_value(), "produk kosong ditolak");
}

void ujiBacaCsv() {
    MULAI_UJI("bacaCsv melaporkan nomor baris yang ditolak");
    // Arrange: stream dengan dua baris rusak di antara baris valid.
    std::istringstream aliran{
        "tanggal,produk,jumlah,harga\n"
        "2025-11-01,Buku Tulis,10,3500\n"
        "2025-11-05,Penggaris\n"
        "2025-11-01,Pensil 2B,25,2000\n"
        "2025-11-06,Stabilo,dua,9000\n"};
    // Act
    const HasilBaca baca = bacaCsv(aliran);
    // Assert
    CHECK_TRUE(baca.valid.size() == 2, "dua baris valid");
    CHECK_TRUE(baca.galat.size() == 2, "dua baris ditolak");
    CHECK_TRUE(baca.galat[0].find("baris 3") != std::string::npos,
               "galat pertama menyebut baris 3");
    CHECK_TRUE(baca.galat[1].find("baris 5") != std::string::npos,
               "galat kedua menyebut baris 5");
}

void ujiAgregasi() {
    MULAI_UJI("agregasi per produk");
    std::istringstream aliran{
        "tanggal,produk,jumlah,harga\n"
        "2025-11-01,Buku Tulis,10,3500\n"
        "2025-11-03,Buku Tulis,7,3750\n"
        "2025-11-01,Pensil 2B,25,2000\n"};
    const std::map<std::string, StatistikProduk> hasil =
        agregasi(bacaCsv(aliran).valid);
    CHECK_TRUE(hasil.size() == 2, "dua produk teragregasi");
    CHECK_EQ(hasil.at("Buku Tulis").transaksi, 2);
    CHECK_EQ(hasil.at("Buku Tulis").unit, 17);
    CHECK_EQ(hasil.at("Buku Tulis").pendapatan, 61250);
    CHECK_EQ(hasil.at("Buku Tulis").hargaMin, 3500);
    CHECK_EQ(hasil.at("Buku Tulis").hargaMaks, 3750);
    CHECK_EQ(hasil.at("Pensil 2B").pendapatan, 50000);
}

int main() {
    ujiPisah();
    ujiKeAngka();
    ujiParseBarisValid();
    ujiParseBarisDitolak();
    ujiBacaCsv();
    ujiAgregasi();

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

Output yang Dihasilkan:

text
[ LULUS ] pisah kolom CSV - empat kolom
[ LULUS ] pisah kolom CSV - kolom[0]
[ LULUS ] pisah kolom CSV - kolom[3]
[ LULUS ] pisah kolom CSV - kolom kosong tetap terhitung
[ LULUS ] pisah kolom CSV - kolom berlebih terdeteksi
[ LULUS ] keAngka ketat dengan from_chars - *keAngka("42")
[ LULUS ] keAngka ketat dengan from_chars - *keAngka("-7")
[ LULUS ] keAngka ketat dengan from_chars - sisa karakter ditolak
[ LULUS ] keAngka ketat dengan from_chars - teks kosong ditolak
[ LULUS ] keAngka ketat dengan from_chars - tanda plus ditolak
[ LULUS ] keAngka ketat dengan from_chars - spasi depan ditolak
[ LULUS ] keAngka ketat dengan from_chars - huruf ditolak
[ LULUS ] parseBaris menerima baris valid - baris valid diterima
[ LULUS ] parseBaris menerima baris valid - hasil->produk
[ LULUS ] parseBaris menerima baris valid - hasil->jumlah
[ LULUS ] parseBaris menerima baris valid - hasil->harga
[ LULUS ] parseBaris menerima baris valid - hasil->nilai()
[ LULUS ] parseBaris menolak baris tidak valid - dua kolom ditolak
[ LULUS ] parseBaris menolak baris tidak valid - pesan menyebut jumlah kolom
[ LULUS ] parseBaris menolak baris tidak valid - jumlah berisi huruf ditolak
[ LULUS ] parseBaris menolak baris tidak valid - jumlah nol ditolak
[ LULUS ] parseBaris menolak baris tidak valid - pesan menyebut syarat jumlah
[ LULUS ] parseBaris menolak baris tidak valid - harga dengan sisa karakter ditolak
[ LULUS ] parseBaris menolak baris tidak valid - produk kosong ditolak
[ LULUS ] bacaCsv melaporkan nomor baris yang ditolak - dua baris valid
[ LULUS ] bacaCsv melaporkan nomor baris yang ditolak - dua baris ditolak
[ LULUS ] bacaCsv melaporkan nomor baris yang ditolak - galat pertama menyebut baris 3
[ LULUS ] bacaCsv melaporkan nomor baris yang ditolak - galat kedua menyebut baris 5
[ LULUS ] agregasi per produk - dua produk teragregasi
[ LULUS ] agregasi per produk - hasil.at("Buku Tulis").transaksi
[ LULUS ] agregasi per produk - hasil.at("Buku Tulis").unit
[ LULUS ] agregasi per produk - hasil.at("Buku Tulis").pendapatan
[ LULUS ] agregasi per produk - hasil.at("Buku Tulis").hargaMin
[ LULUS ] agregasi per produk - hasil.at("Buku Tulis").hargaMaks
[ LULUS ] agregasi per produk - hasil.at("Pensil 2B").pendapatan

ringkasan: 35 lulus, 0 gagal

Enam kelompok uji dengan 35 pemeriksaan, semuanya lulus dan program keluar dengan exit code 0. Yang penting bukan angkanya, melainkan perilaku yang kini terkunci. ujiPisah menjamin kolom kosong tetap terhitung dan kolom berlebih terdeteksi. ujiKeAngka mengunci ketatnya from_chars — tujuh kasus, dari "42" yang sah sampai "+7" dan " 7" yang ditolak. ujiParseBarisDitolak memeriksa bukan sekadar kegagalan, melainkan pesan yang dihasilkan: error().find("4 kolom") memastikan pesan benar-benar menyebut penyebabnya, bukan string generik. ujiBacaCsv mengunci fitur andalan Bagian 2 — nomor baris: galat pertama wajib menyebut “baris 3” dan galat kedua “baris 5”. Dan ujiAgregasi menyimpan angka emas: Buku Tulis harus 2 transaksi, 17 unit, pendapatan 61250, harga 3500–3750; Pensil 2B pendapatan 50000. Perubahan sekecil apa pun pada parser, gerbang validasi, atau agregasi yang menggeser angka akan membuat pemeriksaan ini merah sebelum sempat merusak laporan.

Proyek analisis data CSV tuntas: parser koma dari Bagian 1, gerbang validasi fail-closed dari Bagian 2, agregasi std::map dari Bagian 3, dan laporan berformat dari bagian ini berkumpul dalam satu program yang perilakunya terdokumentasi oleh 35 pemeriksaan. Pola-pola yang baru saja kita pakai — function murni, tipe kembalian yang memaksa penanganan galat, container terurut untuk keluaran deterministik, dan angka emas di harness — langsung kita pakai lagi pada proyek berikutnya: simulasi sistem antrean, yang mengukur rata-rata waktu tunggu dan panjang antrean dari skenario yang sepenuhnya deterministik.

Rujukan resmi: Input/output manipulators (cppreference) , std::expected (cppreference) .