2025-12-22 20:57:04 +00:00
|
|
|
import { expect } from "@esm-bundle/chai";
|
|
|
|
|
import sinon from "sinon";
|
|
|
|
|
import { Persistence } from "../../src/core/Persistence.js";
|
|
|
|
|
|
|
|
|
|
describe("Core: Persistence", () => {
|
|
|
|
|
let persistence;
|
|
|
|
|
let mockDB;
|
|
|
|
|
let mockStore;
|
|
|
|
|
let mockTransaction;
|
|
|
|
|
let mockRequest;
|
|
|
|
|
let globalObj;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
persistence = new Persistence();
|
|
|
|
|
|
|
|
|
|
// Mock IndexedDB
|
|
|
|
|
mockStore = {
|
|
|
|
|
put: sinon.stub(),
|
|
|
|
|
get: sinon.stub(),
|
|
|
|
|
delete: sinon.stub(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mockTransaction = {
|
|
|
|
|
objectStore: sinon.stub().returns(mockStore),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mockDB = {
|
|
|
|
|
objectStoreNames: {
|
|
|
|
|
contains: sinon.stub().returns(false),
|
|
|
|
|
},
|
|
|
|
|
createObjectStore: sinon.stub(),
|
|
|
|
|
transaction: sinon.stub().returns(mockTransaction),
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-22 22:25:32 +00:00
|
|
|
// Use window or self for browser environment
|
2025-12-22 22:34:43 +00:00
|
|
|
globalObj =
|
|
|
|
|
typeof window !== "undefined"
|
|
|
|
|
? window
|
|
|
|
|
: typeof self !== "undefined"
|
|
|
|
|
? self
|
|
|
|
|
: globalThis;
|
2025-12-22 22:25:32 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const createMockRequest = () => {
|
|
|
|
|
// Mock indexedDB.open - create a new request each time
|
2025-12-22 20:57:04 +00:00
|
|
|
mockRequest = {
|
|
|
|
|
onerror: null,
|
|
|
|
|
onsuccess: null,
|
|
|
|
|
onupgradeneeded: null,
|
|
|
|
|
result: mockDB,
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-22 22:25:32 +00:00
|
|
|
// Mock indexedDB using defineProperty since it's read-only
|
2025-12-22 22:34:43 +00:00
|
|
|
Object.defineProperty(globalObj, "indexedDB", {
|
2025-12-22 22:25:32 +00:00
|
|
|
value: {
|
|
|
|
|
open: sinon.stub().returns(mockRequest),
|
|
|
|
|
},
|
|
|
|
|
writable: true,
|
|
|
|
|
configurable: true,
|
|
|
|
|
});
|
2025-12-22 20:57:04 +00:00
|
|
|
|
2025-12-22 22:25:32 +00:00
|
|
|
return mockRequest;
|
2025-12-22 20:57:04 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
it("CoA 1: init should create database and object stores", async () => {
|
2025-12-22 22:25:32 +00:00
|
|
|
const request = createMockRequest();
|
2025-12-22 22:34:43 +00:00
|
|
|
|
2025-12-22 22:25:32 +00:00
|
|
|
// Start init, which will call indexedDB.open
|
|
|
|
|
const initPromise = persistence.init();
|
2025-12-22 22:34:43 +00:00
|
|
|
|
2025-12-22 22:25:32 +00:00
|
|
|
// Trigger upgrade first (happens synchronously during open)
|
|
|
|
|
if (request.onupgradeneeded) {
|
|
|
|
|
request.onupgradeneeded({ target: { result: mockDB } });
|
|
|
|
|
}
|
2025-12-22 22:34:43 +00:00
|
|
|
|
2025-12-22 22:25:32 +00:00
|
|
|
// Then trigger success
|
|
|
|
|
if (request.onsuccess) {
|
|
|
|
|
request.onsuccess({ target: { result: mockDB } });
|
|
|
|
|
}
|
2025-12-22 20:57:04 +00:00
|
|
|
|
2025-12-22 22:25:32 +00:00
|
|
|
await initPromise;
|
2025-12-22 20:57:04 +00:00
|
|
|
|
2026-01-01 04:48:12 +00:00
|
|
|
expect(globalObj.indexedDB.open.calledWith("AetherShardsDB", 6)).to.be.true;
|
2025-12-22 22:34:43 +00:00
|
|
|
expect(mockDB.createObjectStore.calledWith("Runs", { keyPath: "id" })).to.be
|
|
|
|
|
.true;
|
|
|
|
|
expect(mockDB.createObjectStore.calledWith("Roster", { keyPath: "id" })).to
|
|
|
|
|
.be.true;
|
2025-12-31 23:06:07 +00:00
|
|
|
expect(mockDB.createObjectStore.calledWith("Campaign", { keyPath: "id" }))
|
|
|
|
|
.to.be.true;
|
2025-12-22 20:57:04 +00:00
|
|
|
expect(persistence.db).to.equal(mockDB);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 2: saveRun should store run data with active_run id", async () => {
|
|
|
|
|
persistence.db = mockDB;
|
|
|
|
|
const runData = { seed: 12345, depth: 5, squad: [] };
|
|
|
|
|
|
|
|
|
|
const mockPutRequest = {
|
|
|
|
|
onsuccess: null,
|
|
|
|
|
onerror: null,
|
|
|
|
|
};
|
|
|
|
|
mockStore.put.returns(mockPutRequest);
|
|
|
|
|
|
|
|
|
|
const savePromise = persistence.saveRun(runData);
|
|
|
|
|
mockPutRequest.onsuccess();
|
|
|
|
|
|
|
|
|
|
await savePromise;
|
|
|
|
|
|
|
|
|
|
expect(mockDB.transaction.calledWith(["Runs"], "readwrite")).to.be.true;
|
|
|
|
|
expect(mockStore.put.calledOnce).to.be.true;
|
|
|
|
|
const savedData = mockStore.put.firstCall.args[0];
|
|
|
|
|
expect(savedData.id).to.equal("active_run");
|
|
|
|
|
expect(savedData.seed).to.equal(12345);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 3: loadRun should retrieve active_run data", async () => {
|
|
|
|
|
persistence.db = mockDB;
|
|
|
|
|
const savedData = { id: "active_run", seed: 12345, depth: 5 };
|
|
|
|
|
|
|
|
|
|
const mockGetRequest = {
|
|
|
|
|
onsuccess: null,
|
|
|
|
|
onerror: null,
|
|
|
|
|
result: savedData,
|
|
|
|
|
};
|
|
|
|
|
mockStore.get.returns(mockGetRequest);
|
|
|
|
|
|
|
|
|
|
const loadPromise = persistence.loadRun();
|
|
|
|
|
mockGetRequest.onsuccess();
|
|
|
|
|
|
|
|
|
|
const result = await loadPromise;
|
|
|
|
|
|
|
|
|
|
expect(mockDB.transaction.calledWith(["Runs"], "readonly")).to.be.true;
|
|
|
|
|
expect(mockStore.get.calledWith("active_run")).to.be.true;
|
|
|
|
|
expect(result).to.deep.equal(savedData);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 4: clearRun should delete active_run", async () => {
|
|
|
|
|
persistence.db = mockDB;
|
|
|
|
|
|
|
|
|
|
const mockDeleteRequest = {
|
|
|
|
|
onsuccess: null,
|
|
|
|
|
onerror: null,
|
|
|
|
|
};
|
|
|
|
|
mockStore.delete.returns(mockDeleteRequest);
|
|
|
|
|
|
|
|
|
|
const deletePromise = persistence.clearRun();
|
|
|
|
|
mockDeleteRequest.onsuccess();
|
|
|
|
|
|
|
|
|
|
await deletePromise;
|
|
|
|
|
|
|
|
|
|
expect(mockDB.transaction.calledWith(["Runs"], "readwrite")).to.be.true;
|
|
|
|
|
expect(mockStore.delete.calledWith("active_run")).to.be.true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 5: saveRoster should wrap roster data with id", async () => {
|
|
|
|
|
persistence.db = mockDB;
|
|
|
|
|
const rosterData = { roster: [], graveyard: [] };
|
|
|
|
|
|
|
|
|
|
const mockPutRequest = {
|
|
|
|
|
onsuccess: null,
|
|
|
|
|
onerror: null,
|
|
|
|
|
};
|
|
|
|
|
mockStore.put.returns(mockPutRequest);
|
|
|
|
|
|
|
|
|
|
const savePromise = persistence.saveRoster(rosterData);
|
|
|
|
|
mockPutRequest.onsuccess();
|
|
|
|
|
|
|
|
|
|
await savePromise;
|
|
|
|
|
|
|
|
|
|
expect(mockDB.transaction.calledWith(["Roster"], "readwrite")).to.be.true;
|
|
|
|
|
expect(mockStore.put.calledOnce).to.be.true;
|
|
|
|
|
const savedData = mockStore.put.firstCall.args[0];
|
|
|
|
|
expect(savedData.id).to.equal("player_roster");
|
|
|
|
|
expect(savedData.data).to.deep.equal(rosterData);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 6: loadRoster should extract data from stored object", async () => {
|
|
|
|
|
persistence.db = mockDB;
|
2025-12-22 22:34:43 +00:00
|
|
|
const storedData = {
|
|
|
|
|
id: "player_roster",
|
|
|
|
|
data: { roster: [], graveyard: [] },
|
|
|
|
|
};
|
2025-12-22 20:57:04 +00:00
|
|
|
|
|
|
|
|
const mockGetRequest = {
|
|
|
|
|
onsuccess: null,
|
|
|
|
|
onerror: null,
|
|
|
|
|
result: storedData,
|
|
|
|
|
};
|
|
|
|
|
mockStore.get.returns(mockGetRequest);
|
|
|
|
|
|
|
|
|
|
const loadPromise = persistence.loadRoster();
|
|
|
|
|
mockGetRequest.onsuccess();
|
|
|
|
|
|
|
|
|
|
const result = await loadPromise;
|
|
|
|
|
|
|
|
|
|
expect(mockDB.transaction.calledWith(["Roster"], "readonly")).to.be.true;
|
|
|
|
|
expect(mockStore.get.calledWith("player_roster")).to.be.true;
|
|
|
|
|
expect(result).to.deep.equal(storedData.data);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 7: loadRoster should return null if no data exists", async () => {
|
|
|
|
|
persistence.db = mockDB;
|
|
|
|
|
|
|
|
|
|
const mockGetRequest = {
|
|
|
|
|
onsuccess: null,
|
|
|
|
|
onerror: null,
|
|
|
|
|
result: undefined,
|
|
|
|
|
};
|
|
|
|
|
mockStore.get.returns(mockGetRequest);
|
|
|
|
|
|
|
|
|
|
const loadPromise = persistence.loadRoster();
|
|
|
|
|
mockGetRequest.onsuccess();
|
|
|
|
|
|
|
|
|
|
const result = await loadPromise;
|
|
|
|
|
|
|
|
|
|
expect(result).to.be.null;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 8: saveRun should auto-init if db not initialized", async () => {
|
2025-12-22 22:25:32 +00:00
|
|
|
const request = createMockRequest();
|
2025-12-22 22:34:43 +00:00
|
|
|
|
2025-12-22 20:57:04 +00:00
|
|
|
const runData = { seed: 12345 };
|
|
|
|
|
const mockPutRequest = {
|
2025-12-22 22:25:32 +00:00
|
|
|
onsuccess: sinon.stub(),
|
2025-12-22 20:57:04 +00:00
|
|
|
onerror: null,
|
|
|
|
|
};
|
|
|
|
|
mockStore.put.returns(mockPutRequest);
|
|
|
|
|
|
2025-12-22 22:25:32 +00:00
|
|
|
// Start saveRun, which will trigger init
|
2025-12-22 20:57:04 +00:00
|
|
|
const savePromise = persistence.saveRun(runData);
|
2025-12-22 22:34:43 +00:00
|
|
|
|
2025-12-22 22:25:32 +00:00
|
|
|
// Trigger upgrade and success for init
|
|
|
|
|
if (request.onupgradeneeded) {
|
|
|
|
|
request.onupgradeneeded({ target: { result: mockDB } });
|
|
|
|
|
}
|
|
|
|
|
if (request.onsuccess) {
|
|
|
|
|
request.onsuccess({ target: { result: mockDB } });
|
|
|
|
|
}
|
2025-12-22 22:34:43 +00:00
|
|
|
|
2025-12-22 22:25:32 +00:00
|
|
|
// Wait a bit for init to complete, then trigger put success
|
2025-12-22 22:34:43 +00:00
|
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
2025-12-22 20:57:04 +00:00
|
|
|
mockPutRequest.onsuccess();
|
|
|
|
|
|
|
|
|
|
await savePromise;
|
|
|
|
|
|
|
|
|
|
expect(persistence.db).to.equal(mockDB);
|
|
|
|
|
expect(mockStore.put.calledOnce).to.be.true;
|
|
|
|
|
});
|
2025-12-31 23:06:07 +00:00
|
|
|
|
|
|
|
|
it("CoA 9: saveCampaign should store campaign data with campaign_data id", async () => {
|
|
|
|
|
persistence.db = mockDB;
|
|
|
|
|
const campaignData = {
|
|
|
|
|
completedMissions: ["MISSION_TUTORIAL_01", "MISSION_TEST_01"],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const mockPutRequest = {
|
|
|
|
|
onsuccess: null,
|
|
|
|
|
onerror: null,
|
|
|
|
|
};
|
|
|
|
|
mockStore.put.returns(mockPutRequest);
|
|
|
|
|
|
|
|
|
|
const savePromise = persistence.saveCampaign(campaignData);
|
|
|
|
|
mockPutRequest.onsuccess();
|
|
|
|
|
|
|
|
|
|
await savePromise;
|
|
|
|
|
|
|
|
|
|
expect(mockDB.transaction.calledWith(["Campaign"], "readwrite")).to.be.true;
|
|
|
|
|
expect(mockStore.put.calledOnce).to.be.true;
|
|
|
|
|
const savedData = mockStore.put.firstCall.args[0];
|
|
|
|
|
expect(savedData.id).to.equal("campaign_data");
|
|
|
|
|
expect(savedData.data).to.deep.equal(campaignData);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 10: loadCampaign should extract data from stored object", async () => {
|
|
|
|
|
persistence.db = mockDB;
|
|
|
|
|
const storedData = {
|
|
|
|
|
id: "campaign_data",
|
|
|
|
|
data: {
|
|
|
|
|
completedMissions: ["MISSION_TUTORIAL_01", "MISSION_TEST_01"],
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const mockGetRequest = {
|
|
|
|
|
onsuccess: null,
|
|
|
|
|
onerror: null,
|
|
|
|
|
result: storedData,
|
|
|
|
|
};
|
|
|
|
|
mockStore.get.returns(mockGetRequest);
|
|
|
|
|
|
|
|
|
|
const loadPromise = persistence.loadCampaign();
|
|
|
|
|
mockGetRequest.onsuccess();
|
|
|
|
|
|
|
|
|
|
const result = await loadPromise;
|
|
|
|
|
|
|
|
|
|
expect(mockDB.transaction.calledWith(["Campaign"], "readonly")).to.be.true;
|
|
|
|
|
expect(mockStore.get.calledWith("campaign_data")).to.be.true;
|
|
|
|
|
expect(result).to.deep.equal(storedData.data);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("CoA 11: loadCampaign should return null if no data exists", async () => {
|
|
|
|
|
persistence.db = mockDB;
|
|
|
|
|
|
|
|
|
|
const mockGetRequest = {
|
|
|
|
|
onsuccess: null,
|
|
|
|
|
onerror: null,
|
|
|
|
|
result: undefined,
|
|
|
|
|
};
|
|
|
|
|
mockStore.get.returns(mockGetRequest);
|
|
|
|
|
|
|
|
|
|
const loadPromise = persistence.loadCampaign();
|
|
|
|
|
mockGetRequest.onsuccess();
|
|
|
|
|
|
|
|
|
|
const result = await loadPromise;
|
|
|
|
|
|
|
|
|
|
expect(result).to.be.null;
|
|
|
|
|
});
|
2025-12-22 20:57:04 +00:00
|
|
|
});
|