Kita telah belajar banyak hal tentang C: struct, file I/O, modularitas, dan manajemen memori. Proyek ini menggabungkan semuanya menjadi satu aplikasi terminal yang utuh: pengelola inventaris untuk toko kecil. Aplikasi ini membaca dan menulis data produk ke file CSV, melakukan operasi CRUD (Create, Read, Update, Delete), mencari produk berdasarkan id atau nama, mengurutkan berdasarkan nama atau harga, dan menyimpan seluruh inventaris ke file persisten.

Program yang akan kita bangun bekerja dalam satu sesi terminal:

  • menampilkan menu sepuluh pilihan termasuk CRUD, pencarian, pengurutan, dan persistensi,
  • menerima input id, nama produk, harga, dan stok dengan validasi field,
  • menyimpan produk ke dalam array dengan id otomatis berurutan,
  • mencari produk berdasarkan id (exact) atau nama (substring, case-insensitive),
  • mengurutkan inventaris berdasarkan nama (A-Z) atau harga (termurah lebih dulu),
  • menyimpan dan memuat seluruh data ke file CSV inventory.csv,
  • menjalankan rangkaian unit test yang membuktikan setiap fitur bekerja.

Tampilan Program Akhir #

Sesi pemakaian berikut menunjukkan hasil akhir proyek. Teks yang diketik pengguna muncul setelah prompt:

text
Memuat 2 produk dari inventory.csv

=== Pengelola Inventaris ===
1. Tambah produk
2. Daftar semua produk
3. Cari produk (id)
4. Cari produk (nama)
5. Update harga/stok
6. Hapus produk
7. Urutkan berdasarkan nama
8. Urutkan berdasarkan harga
9. Simpan ke file
0. Muat dari file
Q. Keluar
Pilihan: 2
Total produk: 2/256
[000001] Beras 5kg                        Rp  65000.00  stok=  10
[000002] Gula Pasir                       Rp  15000.00  stok=   5

=== Pengelola Inventaris ===
...
Pilihan: 4
Kata kunci nama: ber
Ditemukan 1 produk:
[000001] Beras 5kg                        Rp  65000.00  stok=  10
...
Pilihan: q
Terima kasih telah menggunakan Pengelola Inventaris.

Tampilan sederhana, tetapi di baliknya tersimpan pemisahan modul yang menjadi inti proyek ini: model Product terpisah dari operasi koleksi Inventory, dan persistensi file dienkapsulasi di modul storage.

Tujuan Pembelajaran #

Setelah menyelesaikan proyek ini, kita mampu:

  • merancang model data dengan struct lengkap dengan validasi field internal,
  • memisahkan tanggung jawab ke dalam tiga modul: model, koleksi, dan storage,
  • mengimplementasikan operasi CRUD di atas array statis dengan manajemen next_id,
  • menulis parser CSV sederhana dengan sscanf yang aman,
  • menguji setiap modul dengan unit test tanpa framework eksternal,
  • mengotomatisasi build dan test dengan Makefile.

Prasyarat #

Proyek ini tidak memperkenalkan materi baru. Bab yang sering dirujuk:

  • struct, union, dan enum — model Product dan field-field-nya,
  • pointer dan array — operasi pada Inventory.items[],
  • file I/O dengan stdio — modul storage.c yang membaca dan menulis CSV,
  • fungsi dan modularitas — pemisahan ke .h/.c dan Makefile multi-file,
  • string dan string.h — strncpy, strcmp, strstr, memmove,
  • manajemen memori — penggunaan stack array vs heap allocation,
  • debugging dengan gdb — menelusuri bug saat test gagal.

Tahapan Pengerjaan #

Proyek dikerjakan dalam tujuh bagian berurutan. Setiap bagian membangun satu lapisan tambahan:

  1. Bagian 1: Model Inventaris — definisikan struct Product dengan id, nama, harga, stok, dan fungsi validasinya.
  2. Bagian 2: Operasi CRUD — bangun Inventory dengan add, find, update, delete.
  3. Bagian 3: Pencarian dan Pengurutan — tambahkan filter nama (case-insensitive) dan bubble sort.
  4. Bagian 4: Persistensi File — simpan dan muat inventaris ke CSV dengan fopen/fprintf.
  5. Bagian 5: Validasi dan Error Handling — kode error eksplisit dan pencegahan input invalid.
  6. Bagian 6: Pemisahan Modul — pisahkan kode ke .h/.c dan susun Makefile.
  7. Bagian 7: Testing dan Build — rangkaian unit test dan integrasi make test.

Mulailah dari Bagian 1 dan ikuti urutannya karena setiap bagian dibangun di atas bagian sebelumnya. Berkas sumber tiap bagian tersedia di bagian Berkas Sumber di bawah halaman masing-masing.

Berkas Sumber

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

inventory.c

Unduh berkas
c
/* inventory.c - Implementasi operasi CRUD Inventory */
#include "inventory.h"

#include <stdio.h>
#include <string.h>
#include <ctype.h>

void inventory_init(Inventory *inv) {
    if (inv == NULL) {
        return;
    }
    inv->count = 0;
    inv->next_id = 1;
    /* Tidak perlu zero-initialize array items karena count = 0. */
}

int inventory_add(Inventory *inv, const char *name,
                  double price, int stock) {
    if (inv == NULL || name == NULL) {
        return -1;
    }
    if (inv->count >= INVENTORY_CAPACITY) {
        return -1;
    }

    Product *slot = &inv->items[inv->count];
    int id = inv->next_id;
    int rc = product_create(slot, id, name, price, stock);
    if (rc != 0) {
        return -1;
    }
    inv->next_id++;
    inv->count++;
    return id;
}

Product *inventory_find_by_id(Inventory *inv, int id) {
    if (inv == NULL) {
        return NULL;
    }
    for (int i = 0; i < inv->count; i++) {
        if (inv->items[i].id == id) {
            return &inv->items[i];
        }
    }
    return NULL;
}

/* Helper: case-insensitive substring match.
 * Mengembalikan 1 bila `needle` muncul di `haystack`. */
static int contains_ci(const char *haystack, const char *needle) {
    if (needle == NULL || needle[0] == '\0') {
        return 1; /* keyword kosong = match semua */
    }
    if (haystack == NULL) {
        return 0;
    }
    /* Iterasi setiap posisi awal di haystack. */
    for (const char *h = haystack; *h != '\0'; h++) {
        const char *hi = h;
        const char *ni = needle;
        while (*hi != '\0' && *ni != '\0' &&
               tolower((unsigned char)*hi) == tolower((unsigned char)*ni)) {
            hi++;
            ni++;
        }
        if (*ni == '\0') {
            return 1;
        }
    }
    return 0;
}

int inventory_find_by_name(Inventory *inv, const char *keyword,
                           Product *out, int out_cap) {
    if (inv == NULL || out == NULL || out_cap <= 0) {
        return 0;
    }
    int found = 0;
    for (int i = 0; i < inv->count && found < out_cap; i++) {
        if (contains_ci(inv->items[i].name, keyword)) {
            out[found++] = inv->items[i];
        }
    }
    return found;
}

int inventory_update(Inventory *inv, int id,
                     double new_price, int new_stock) {
    Product *p = inventory_find_by_id(inv, id);
    if (p == NULL) {
        return -1;
    }
    if (new_price < 0.0 || new_stock < 0) {
        return -1;
    }
    p->price = new_price;
    p->stock = new_stock;
    return 0;
}

int inventory_delete(Inventory *inv, int id) {
    if (inv == NULL) {
        return -1;
    }
    for (int i = 0; i < inv->count; i++) {
        if (inv->items[i].id == id) {
            /* Geser elemen setelahnya ke kiri (swap dengan elemen terakhir
             * bisa mengacaukan urutan, jadi kita pakai memmove). */
            memmove(&inv->items[i], &inv->items[i + 1],
                    (size_t)(inv->count - i - 1) * sizeof(Product));
            inv->count--;
            return 0;
        }
    }
    return -1;
}

/* Helper: tukar dua Product. */
static void swap_product(Product *a, Product *b) {
    Product tmp = *a;
    *a = *b;
    *b = tmp;
}

void inventory_sort_by_name(Inventory *inv) {
    if (inv == NULL || inv->count < 2) {
        return;
    }
    /* Bubble sort: sederhana, cukup untuk kapasitas kecil. */
    for (int i = 0; i < inv->count - 1; i++) {
        for (int j = 0; j < inv->count - i - 1; j++) {
            if (strcmp(inv->items[j].name,
                       inv->items[j + 1].name) > 0) {
                swap_product(&inv->items[j], &inv->items[j + 1]);
            }
        }
    }
}

void inventory_sort_by_price(Inventory *inv) {
    if (inv == NULL || inv->count < 2) {
        return;
    }
    for (int i = 0; i < inv->count - 1; i++) {
        for (int j = 0; j < inv->count - i - 1; j++) {
            if (inv->items[j].price > inv->items[j + 1].price) {
                swap_product(&inv->items[j], &inv->items[j + 1]);
            }
        }
    }
}

void inventory_print_all(const Inventory *inv) {
    if (inv == NULL) {
        return;
    }
    printf("Total produk: %d/%d\n", inv->count, INVENTORY_CAPACITY);
    for (int i = 0; i < inv->count; i++) {
        product_print(&inv->items[i]);
    }
}

main.c

Unduh berkas
c
/* main.c - Program utama Pengelola Inventaris */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>

#include "inventory.h"
#include "storage.h"

#define DB_PATH "inventory.csv"

/* Membersihkan stdin dari karakter sisa setelah scanf. */
static void flush_stdin(void) {
    int c;
    while ((c = getchar()) != '\n' && c != EOF) {
        /* loop sampai newline */
    }
}

/* Membaca satu baris teks dari stdin dengan aman. */
static int read_line(char *buf, size_t cap) {
    if (fgets(buf, (int)cap, stdin) == NULL) {
        return -1;
    }
    size_t len = strlen(buf);
    while (len > 0 && (buf[len - 1] == '\n' || buf[len - 1] == '\r')) {
        buf[--len] = '\0';
    }
    return 0;
}

/* Menampilkan menu utama. */
static void print_menu(void) {
    puts("");
    puts("=== Pengelola Inventaris ===");
    puts("1. Tambah produk");
    puts("2. Daftar semua produk");
    puts("3. Cari produk (id)");
    puts("4. Cari produk (nama)");
    puts("5. Update harga/stok");
    puts("6. Hapus produk");
    puts("7. Urutkan berdasarkan nama");
    puts("8. Urutkan berdasarkan harga");
    puts("9. Simpan ke file");
    puts("0. Muat dari file");
    puts("Q. Keluar");
    printf("Pilihan: ");
}

static void do_add(Inventory *inv) {
    char name[PRODUCT_NAME_MAX];
    char price_buf[32];
    char stock_buf[16];

    printf("Nama produk: ");
    if (read_line(name, sizeof(name)) != 0) {
        return;
    }
    printf("Harga (Rp): ");
    if (read_line(price_buf, sizeof(price_buf)) != 0) {
        return;
    }
    printf("Stok: ");
    if (read_line(stock_buf, sizeof(stock_buf)) != 0) {
        return;
    }

    double price = strtod(price_buf, NULL);
    int stock = (int)strtol(stock_buf, NULL, 10);
    int id = inventory_add(inv, name, price, stock);
    if (id < 0) {
        printf("Gagal menambah produk. Periksa kembali input Anda.\n");
        return;
    }
    printf("Produk ditambahkan dengan id=%d\n", id);
}

static void do_list(const Inventory *inv) {
    inventory_print_all(inv);
}

static void do_find_id(Inventory *inv) {
    int id;
    printf("Masukkan id: ");
    if (scanf("%d", &id) != 1) {
        flush_stdin();
        printf("Input tidak valid.\n");
        return;
    }
    flush_stdin();
    Product *p = inventory_find_by_id(inv, id);
    if (p == NULL) {
        printf("Produk dengan id %d tidak ditemukan.\n", id);
        return;
    }
    product_print(p);
}

static void do_find_name(Inventory *inv) {
    char keyword[PRODUCT_NAME_MAX];
    Product results[16];
    printf("Kata kunci nama: ");
    if (read_line(keyword, sizeof(keyword)) != 0) {
        return;
    }
    int n = inventory_find_by_name(inv, keyword, results, 16);
    printf("Ditemukan %d produk:\n", n);
    for (int i = 0; i < n; i++) {
        product_print(&results[i]);
    }
}

static void do_update(Inventory *inv) {
    int id;
    printf("Masukkan id: ");
    if (scanf("%d", &id) != 1) {
        flush_stdin();
        printf("Input tidak valid.\n");
        return;
    }
    flush_stdin();
    char price_buf[32];
    char stock_buf[16];
    printf("Harga baru (Rp): ");
    read_line(price_buf, sizeof(price_buf));
    printf("Stok baru: ");
    read_line(stock_buf, sizeof(stock_buf));
    double price = strtod(price_buf, NULL);
    int stock = (int)strtol(stock_buf, NULL, 10);
    if (inventory_update(inv, id, price, stock) != 0) {
        printf("Gagal memperbarui produk id %d.\n", id);
        return;
    }
    printf("Produk id %d diperbarui.\n", id);
}

static void do_delete(Inventory *inv) {
    int id;
    printf("Masukkan id: ");
    if (scanf("%d", &id) != 1) {
        flush_stdin();
        printf("Input tidak valid.\n");
        return;
    }
    flush_stdin();
    if (inventory_delete(inv, id) != 0) {
        printf("Produk id %d tidak ditemukan.\n", id);
        return;
    }
    printf("Produk id %d dihapus.\n", id);
}

int main(void) {
    Inventory inv;
    inventory_init(&inv);

    /* Auto-load bila database sudah ada. */
    if (storage_load(&inv, DB_PATH) > 0) {
        printf("Memuat %d produk dari %s\n", inv.count, DB_PATH);
    }

    char choice[8];
    int running = 1;
    while (running) {
        print_menu();
        if (read_line(choice, sizeof(choice)) != 0) {
            break;
        }
        char c = (char)tolower((unsigned char)choice[0]);
        switch (c) {
            case '1': do_add(&inv); break;
            case '2': do_list(&inv); break;
            case '3': do_find_id(&inv); break;
            case '4': do_find_name(&inv); break;
            case '5': do_update(&inv); break;
            case '6': do_delete(&inv); break;
            case '7': inventory_sort_by_name(&inv);
                      puts("Diurutkan berdasarkan nama."); break;
            case '8': inventory_sort_by_price(&inv);
                      puts("Diurutkan berdasarkan harga."); break;
            case '9': {
                int n = storage_save(&inv, DB_PATH);
                if (n >= 0) {
                    printf("Menyimpan %d produk ke %s\n", n, DB_PATH);
                } else {
                    printf("Gagal menyimpan ke %s\n", DB_PATH);
                }
                break;
            }
            case '0': {
                int n = storage_load(&inv, DB_PATH);
                if (n >= 0) {
                    printf("Memuat %d produk dari %s\n", n, DB_PATH);
                } else {
                    printf("Gagal memuat dari %s\n", DB_PATH);
                }
                break;
            }
            case 'q': running = 0; break;
            default:  printf("Pilihan tidak dikenal: %c\n", c); break;
        }
    }
    puts("Terima kasih telah menggunakan Pengelola Inventaris.");
    return 0;
}

product.c

Unduh berkas
c
/* product.c - Implementasi model Product */
#include "product.h"

#include <stdio.h>
#include <string.h>
#include <math.h>

/* Kode error yang dipakai product_create.
 * Nilai negatif agar mudah dibedakan dari id positif. */
#define PRODUCT_ERR_NAME   (-1)
#define PRODUCT_ERR_PRICE  (-2)
#define PRODUCT_ERR_STOCK  (-3)
#define PRODUCT_ERR_ID     (-4)

int product_is_valid(const Product *p) {
    if (p == NULL) {
        return 0;
    }
    if (p->id <= 0 || p->id > PRODUCT_ID_MAX) {
        return 0;
    }
    if (p->name[0] == '\0') {
        return 0;
    }
    if (p->price < 0.0 || isnan(p->price) || isinf(p->price)) {
        return 0;
    }
    if (p->stock < 0) {
        return 0;
    }
    return 1;
}

int product_create(Product *p, int id, const char *name,
                   double price, int stock) {
    if (p == NULL || name == NULL) {
        return PRODUCT_ERR_NAME;
    }
    if (id <= 0 || id > PRODUCT_ID_MAX) {
        return PRODUCT_ERR_ID;
    }
    if (price < 0.0 || isnan(price) || isinf(price)) {
        return PRODUCT_ERR_PRICE;
    }
    if (stock < 0) {
        return PRODUCT_ERR_STOCK;
    }

    p->id = id;
    /* Salin nama dengan aman: selalu tutup dengan terminator. */
    strncpy(p->name, name, PRODUCT_NAME_MAX - 1);
    p->name[PRODUCT_NAME_MAX - 1] = '\0';
    p->price = price;
    p->stock = stock;

    if (!product_is_valid(p)) {
        return PRODUCT_ERR_NAME;
    }
    return 0;
}

void product_print(const Product *p) {
    if (p == NULL) {
        printf("(null product)\n");
        return;
    }
    printf("[%06d] %-30s  Rp%10.2f  stok=%4d\n",
           p->id, p->name, p->price, p->stock);
}

storage.c

Unduh berkas
c
/* storage.c - Implementasi persistensi CSV */
#include "storage.h"

#include <stdio.h>
#include <string.h>

/* Helper: trim newline dari akhir string hasil fgets. */
static void strip_newline(char *s) {
    size_t len = strlen(s);
    while (len > 0 && (s[len - 1] == '\n' || s[len - 1] == '\r')) {
        s[--len] = '\0';
    }
}

/* Helper: parse satu baris CSV "id,name,price,stock".
 * Mengembalikan 1 bila sukses, 0 bila format tidak valid. */
static int parse_line(const char *line, int *id, char *name,
                      size_t name_cap, double *price, int *stock) {
    /* %n menyimpan jumlah karakter yang sudah diproses.
     * Kita pisahkan field dengan pemisah koma. */
    int consumed = 0;
    int items = sscanf(line, "%d,%63[^,],%lf,%d%n",
                       id, name, price, stock, &consumed);
    if (items != 4) {
        return 0;
    }
    /* Pastikan tidak ada karakter asing di akhir baris. */
    char trailing = line[consumed];
    if (trailing != '\0' && trailing != '\n' && trailing != '\r') {
        return 0;
    }
    if (name_cap < PRODUCT_NAME_MAX) {
        return 0;
    }
    return 1;
}

int storage_save(const Inventory *inv, const char *path) {
    if (inv == NULL || path == NULL) {
        return -1;
    }
    FILE *fp = fopen(path, "w");
    if (fp == NULL) {
        return -1;
    }

    /* Tulis header komentar agar mudah diperiksa manusia. */
    fprintf(fp, "# id,name,price,stock\n");
    int written = 0;
    for (int i = 0; i < inv->count; i++) {
        const Product *p = &inv->items[i];
        /* Gunakan format yang stabil: id 6 digit, harga 2 desimal. */
        int n = fprintf(fp, "%d,%s,%.2f,%d\n",
                        p->id, p->name, p->price, p->stock);
        if (n < 0) {
            fclose(fp);
            return -1;
        }
        written++;
    }
    fclose(fp);
    return written;
}

int storage_load(Inventory *inv, const char *path) {
    if (inv == NULL || path == NULL) {
        return -1;
    }
    FILE *fp = fopen(path, "r");
    if (fp == NULL) {
        return -1;
    }

    inventory_init(inv);

    char line[256];
    int read = 0;
    int max_id_seen = 0;
    while (fgets(line, sizeof(line), fp) != NULL) {
        /* Lewati baris kosong dan komentar. */
        if (line[0] == '#' || line[0] == '\n' || line[0] == '\r') {
            continue;
        }
        strip_newline(line);

        int id;
        char name[PRODUCT_NAME_MAX];
        double price;
        int stock;
        if (!parse_line(line, &id, name, sizeof(name), &price, &stock)) {
            continue;
        }
        if (inv->count >= INVENTORY_CAPACITY) {
            break;
        }
        Product *slot = &inv->items[inv->count];
        if (product_create(slot, id, name, price, stock) != 0) {
            continue;
        }
        if (id > max_id_seen) {
            max_id_seen = id;
        }
        inv->count++;
        read++;
    }
    inv->next_id = max_id_seen + 1;
    fclose(fp);
    return read;
}

tests.c

Unduh berkas
c
/* tests.c - Unit test sederhana tanpa framework eksternal */
#include <stdio.h>
#include <string.h>

#include "inventory.h"
#include "storage.h"

static int tests_run = 0;
static int tests_pass = 0;

#define ASSERT(cond, msg) \
    do { \
        tests_run++; \
        if (cond) { \
            tests_pass++; \
        } else { \
            printf("FAIL: %s (line %d)\n", msg, __LINE__); \
        } \
    } while (0)

static void test_product_create_valid(void) {
    Product p;
    int rc = product_create(&p, 1, "Beras 5kg", 65000.0, 20);
    ASSERT(rc == 0, "product_create valid harus sukses");
    ASSERT(p.id == 1, "id harus 1");
    ASSERT(strcmp(p.name, "Beras 5kg") == 0, "nama harus sesuai");
    ASSERT(p.price == 65000.0, "harga harus sesuai");
    ASSERT(p.stock == 20, "stok harus 20");
    ASSERT(product_is_valid(&p), "produk harus valid");
}

static void test_product_create_invalid(void) {
    Product p;
    ASSERT(product_create(&p, 0, "X", 1.0, 1) != 0, "id=0 harus ditolak");
    ASSERT(product_create(&p, 1, "", 1.0, 1) != 0, "nama kosong ditolak");
    ASSERT(product_create(&p, 1, "X", -1.0, 1) != 0, "harga negatif ditolak");
    ASSERT(product_create(&p, 1, "X", 1.0, -5) != 0, "stok negatif ditolak");
}

static void test_inventory_add_and_find(void) {
    Inventory inv;
    inventory_init(&inv);
    int id1 = inventory_add(&inv, "Beras", 65000.0, 10);
    int id2 = inventory_add(&inv, "Gula", 15000.0, 5);
    ASSERT(id1 == 1, "id produk pertama harus 1");
    ASSERT(id2 == 2, "id produk kedua harus 2");
    ASSERT(inv.count == 2, "jumlah harus 2");

    Product *p = inventory_find_by_id(&inv, 2);
    ASSERT(p != NULL, "id=2 harus ditemukan");
    ASSERT(strcmp(p->name, "Gula") == 0, "nama produk id=2 harus Gula");

    ASSERT(inventory_find_by_id(&inv, 999) == NULL, "id tidak ada = NULL");
}

static void test_inventory_find_name(void) {
    Inventory inv;
    inventory_init(&inv);
    inventory_add(&inv, "Beras Premium", 80000.0, 5);
    inventory_add(&inv, "Gula Pasir", 15000.0, 5);
    inventory_add(&inv, "Beras Merah", 75000.0, 3);

    Product out[16];
    int n = inventory_find_by_name(&inv, "beras", out, 16);
    ASSERT(n == 2, "pencarian 'beras' case-insensitive harus 2");

    n = inventory_find_by_name(&inv, "X", out, 16);
    ASSERT(n == 0, "pencarian tanpa match harus 0");
}

static void test_inventory_update_delete(void) {
    Inventory inv;
    inventory_init(&inv);
    int id = inventory_add(&inv, "Teh", 12000.0, 8);
    ASSERT(inventory_update(&inv, id, 13000.0, 10) == 0, "update harus sukses");
    Product *p = inventory_find_by_id(&inv, id);
    ASSERT(p->price == 13000.0, "harga harus 13000");
    ASSERT(p->stock == 10, "stok harus 10");

    ASSERT(inventory_update(&inv, id, -1.0, 0) != 0, "harga negatif ditolak");
    ASSERT(inventory_delete(&inv, id) == 0, "delete harus sukses");
    ASSERT(inv.count == 0, "jumlah harus 0 setelah delete");
    ASSERT(inventory_delete(&inv, id) != 0, "delete ulang harus gagal");
}

static void test_inventory_sort(void) {
    Inventory inv;
    inventory_init(&inv);
    inventory_add(&inv, "Chiki", 5000.0, 10);
    inventory_add(&inv, "Apel", 8000.0, 5);
    inventory_add(&inv, "Bola", 3000.0, 7);

    inventory_sort_by_name(&inv);
    ASSERT(strcmp(inv.items[0].name, "Apel") == 0, "sort nama [0] = Apel");
    ASSERT(strcmp(inv.items[1].name, "Bola") == 0, "sort nama [1] = Bola");
    ASSERT(strcmp(inv.items[2].name, "Chiki") == 0, "sort nama [2] = Chiki");

    inventory_sort_by_price(&inv);
    ASSERT(inv.items[0].price == 3000.0, "sort harga [0] = 3000");
    ASSERT(inv.items[2].price == 8000.0, "sort harga [2] = 8000");
}

static void test_storage_roundtrip(void) {
    Inventory inv;
    inventory_init(&inv);
    inventory_add(&inv, "Beras", 65000.0, 10);
    inventory_add(&inv, "Gula", 15000.0, 5);

    const char *path = "/tmp/test_inventory.csv";
    int written = storage_save(&inv, path);
    ASSERT(written == 2, "harus menulis 2 baris");

    Inventory loaded;
    int read = storage_load(&loaded, path);
    ASSERT(read == 2, "harus membaca 2 baris");
    ASSERT(loaded.count == 2, "count harus 2 setelah load");
    ASSERT(strcmp(loaded.items[0].name, "Beras") == 0, "nama[0] = Beras");
    ASSERT(loaded.next_id == 3, "next_id harus 3 setelah load");
}

int main(void) {
    test_product_create_valid();
    test_product_create_invalid();
    test_inventory_add_and_find();
    test_inventory_find_name();
    test_inventory_update_delete();
    test_inventory_sort();
    test_storage_roundtrip();

    printf("\n=== Hasil Test ===\n");
    printf("Total : %d\n", tests_run);
    printf("Lulus : %d\n", tests_pass);
    printf("Gagal : %d\n", tests_run - tests_pass);

    return (tests_run == tests_pass) ? 0 : 1;
}