/** * Tests for the Inventory Manager database layer (db.js). * * Sets DB_PATH to a temp file BEFORE importing the module so all * operations hit a throwaway SQLite database. The underlying * better-sqlite3 `Database` instance is also imported so we can * DELETE rows between tests without reopening the file. */ import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; // DB_PATH is set via vitest.config.js env before this module loads. const TMP_DB = process.env.DB_PATH; import db from '../db.js'; import { saveItems, loadItems, saveState, loadState, saveFurnaces, loadFurnaces, saveAlerts, loadAlerts, recordItemHistory, getHistory, getHistorySummary, saveFullState, flushPendingSave, loadFullState, closeDb, } from '../db.js'; // ── helpers ──────────────────────────────────────────────────────────── /** Wipe all rows from every table so each test starts fresh */ function clearAllTables() { db.exec(` DELETE FROM items; DELETE FROM furnaces; DELETE FROM alerts; DELETE FROM state; DELETE FROM item_history; `); } // ── lifecycle ────────────────────────────────────────────────────────── beforeEach(() => { clearAllTables(); }); afterAll(() => { try { closeDb(); } catch { /* already closed */ } try { fs.unlinkSync(TMP_DB); } catch { /* ignore */ } try { fs.unlinkSync(TMP_DB + '-wal'); } catch { /* ignore */ } try { fs.unlinkSync(TMP_DB + '-shm'); } catch { /* ignore */ } }); // ── tests ────────────────────────────────────────────────────────────── describe('db – item persistence', () => { it('saveItems + loadItems round-trips item data', () => { const items = [ { name: 'minecraft:diamond', count: 64, displayName: 'Diamond' }, { name: 'minecraft:iron_ingot', count: 128, displayName: 'Iron Ingot' }, ]; saveItems(items); const loaded = loadItems(); expect(loaded).toHaveLength(2); // loadItems returns ORDER BY count DESC expect(loaded[0].name).toBe('minecraft:iron_ingot'); expect(loaded[0].count).toBe(128); expect(loaded[1].name).toBe('minecraft:diamond'); expect(loaded[1].count).toBe(64); }); it('saveItems upserts — updates existing rows instead of duplicating', () => { saveItems([{ name: 'minecraft:diamond', count: 10, displayName: 'Diamond' }]); saveItems([{ name: 'minecraft:diamond', count: 99, displayName: 'Diamond' }]); const loaded = loadItems(); expect(loaded).toHaveLength(1); expect(loaded[0].count).toBe(99); }); }); describe('db – state key-value store', () => { it('saveState + loadState round-trips JSON', () => { saveState('smeltingPaused', true); expect(loadState('smeltingPaused')).toBe(true); saveState('disabledRecipes', { 'minecraft:iron_ingot': true }); expect(loadState('disabledRecipes')).toEqual({ 'minecraft:iron_ingot': true }); }); it('loadState returns defaultValue when key missing', () => { expect(loadState('nonexistent', 42)).toBe(42); expect(loadState('nonexistent')).toBeNull(); }); }); describe('db – furnace persistence', () => { it('saveFurnaces + loadFurnaces round-trips furnace status', () => { const furnaces = { 'minecraft:furnace_0': { active: true, type: 'minecraft:furnace', input: { name: 'minecraft:raw_iron', count: 8 }, fuel: { name: 'minecraft:coal', count: 3 }, output: null, }, }; saveFurnaces(furnaces); const loaded = loadFurnaces(); expect(loaded['minecraft:furnace_0']).toBeDefined(); expect(loaded['minecraft:furnace_0'].active).toBe(true); expect(loaded['minecraft:furnace_0'].input.name).toBe('minecraft:raw_iron'); }); }); describe('db – alert persistence', () => { it('saveAlerts + loadAlerts round-trips triggered alerts', () => { saveAlerts([ { item: 'minecraft:diamond', triggered: true, current: 5, threshold: 10 }, { item: 'minecraft:coal', triggered: false, current: 200, threshold: 50 }, ]); const loaded = loadAlerts(); // loadAlerts only returns triggered=1 expect(loaded).toHaveLength(1); expect(loaded[0].item).toBe('minecraft:diamond'); expect(loaded[0].triggered).toBe(true); expect(loaded[0].current).toBe(5); expect(loaded[0].threshold).toBe(10); }); }); describe('db – item history', () => { it('getHistory returns recorded snapshots ordered newest-first', () => { // recordItemHistory is throttled (5-min internal cooldown). // First call after module load will go through. recordItemHistory([ { name: 'minecraft:diamond', count: 64 }, { name: 'minecraft:iron_ingot', count: 128 }, ]); const history = getHistory('minecraft:diamond', 10); expect(history.length).toBeGreaterThanOrEqual(1); expect(history[0].count).toBe(64); }); it('getHistorySummary groups by timestamp', () => { // May be throttled if previous test already recorded in this module load. // Insert directly via the underlying db for determinism. const now = Date.now(); db.prepare('INSERT INTO item_history (name, count, recorded_at) VALUES (?, ?, ?)').run('a', 10, now); db.prepare('INSERT INTO item_history (name, count, recorded_at) VALUES (?, ?, ?)').run('b', 20, now); const summary = getHistorySummary(); expect(summary.length).toBeGreaterThanOrEqual(1); expect(summary[0].total).toBe(30); // 10 + 20 }); }); describe('db – full state round-trip', () => { it('saveFullState + flushPendingSave + loadFullState restores everything', () => { const state = { inventoryState: { itemList: [ { name: 'minecraft:diamond', count: 64, displayName: 'Diamond' }, ], grandTotal: 64, chestCount: 2, totalSlots: 54, usedSlots: 1, freeSlots: 53, usedRatio: 0.02, dropperOk: true, barrelOk: false, furnaceCount: 1, furnaceStatus: { 'furnace_0': { active: false, type: 'minecraft:furnace', input: null, fuel: null, output: null }, }, droppers: ['dropper_0'], }, activityState: { lastScan: 12345 }, alertsState: [{ item: 'minecraft:coal', triggered: true, current: 3, threshold: 10 }], smeltingPaused: false, disabledRecipes: { 'minecraft:charcoal': true }, smeltableRecipes: { 'minecraft:raw_iron': 'minecraft:iron_ingot' }, craftableRecipes: [{ output: 'minecraft:chest', count: 1, slots: { 1: 'minecraft:planks' } }], craftTurtleOk: true, }; saveFullState(state); flushPendingSave(); // force the debounced write const restored = loadFullState(); expect(restored.inventoryState.itemList).toHaveLength(1); expect(restored.inventoryState.itemList[0].name).toBe('minecraft:diamond'); expect(restored.inventoryState.grandTotal).toBe(64); expect(restored.smeltingPaused).toBe(false); expect(restored.disabledRecipes).toEqual({ 'minecraft:charcoal': true }); expect(restored.craftTurtleOk).toBe(true); expect(restored.alertsState).toHaveLength(1); expect(restored.alertsState[0].item).toBe('minecraft:coal'); }); });