Add tests for grouped stock list behavior and improve stock view mode UI and API enhancements
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -70,4 +70,59 @@ describe('stock mark-gone behavior', () => {
|
||||
message: 'Flour was marked gone and removed from the list.',
|
||||
});
|
||||
});
|
||||
|
||||
it('stock list grouped markGone removes item from grouped and flat collections', async () => {
|
||||
useStockItemMock.mockResolvedValueOnce({ status: 'used' });
|
||||
const addAlert = vi.fn();
|
||||
const data = stockListPageData({ addAlert, isConnected: false });
|
||||
|
||||
data.groupedEntries = [
|
||||
{
|
||||
id: 10,
|
||||
uuid_b64: 'group-10',
|
||||
name: 'Beans',
|
||||
stock_type: 'measured',
|
||||
location_initial_uuid_b64: null,
|
||||
date: '2026-04-12',
|
||||
expire_date: '2026-04-20',
|
||||
items: [
|
||||
{
|
||||
id: 11,
|
||||
uuid_b64: 'item-11',
|
||||
name: 'Beans',
|
||||
stock_type: 'measured',
|
||||
quantity: 1,
|
||||
location_initial_uuid_b64: null,
|
||||
date: '2026-04-12',
|
||||
expire_date: '2026-04-20',
|
||||
},
|
||||
],
|
||||
},
|
||||
].map((group) => data.indexGroup(group));
|
||||
data.entries = [
|
||||
data.indexEntry({
|
||||
id: 11,
|
||||
uuid_b64: 'item-11',
|
||||
name: 'Beans',
|
||||
stock_type: 'measured',
|
||||
quantity: 1,
|
||||
location_initial_uuid_b64: null,
|
||||
date: '2026-04-12',
|
||||
expire_date: '2026-04-20',
|
||||
}),
|
||||
];
|
||||
data.entriesVersion = 1;
|
||||
data.groupedVersion = 1;
|
||||
data.editForms = { 11: { level: 'plenty', quantity: 1 } };
|
||||
data.editErrors = {};
|
||||
|
||||
await data.markGoneFromGroup(data.groupedEntries[0].items[0], data.groupedEntries[0]);
|
||||
|
||||
expect(data.entries).toEqual([]);
|
||||
expect(data.groupedEntries).toEqual([]);
|
||||
expect(addAlert).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'Beans was marked gone and removed from the group.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const listStockEntriesMock = vi.fn();
|
||||
const listGroupedStockEntriesMock = vi.fn();
|
||||
const listKitchenChangesMock = vi.fn();
|
||||
const updateStockItemMock = vi.fn();
|
||||
const useStockItemMock = vi.fn();
|
||||
const fetchLocationsMock = vi.fn();
|
||||
|
||||
vi.mock('../../../src/api/stock.js', () => ({
|
||||
listStockEntries: (...args) => listStockEntriesMock(...args),
|
||||
listGroupedStockEntries: (...args) => listGroupedStockEntriesMock(...args),
|
||||
listKitchenChanges: (...args) => listKitchenChangesMock(...args),
|
||||
updateStockItem: (...args) => updateStockItemMock(...args),
|
||||
useStockItem: (...args) => useStockItemMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/api/locations.js', () => ({
|
||||
fetchLocations: (...args) => fetchLocationsMock(...args),
|
||||
}));
|
||||
|
||||
const { stockListPageData } = await import('../../../src/features/stock/stock-list-page.js');
|
||||
|
||||
function createGroupedSummary() {
|
||||
return [
|
||||
{
|
||||
id: 10,
|
||||
uuid_b64: 'group-10',
|
||||
name: 'Rice',
|
||||
description: 'Basmati',
|
||||
stock_type: 'measured',
|
||||
level: 'good',
|
||||
quantity: 1,
|
||||
uom_symbol: 'kg',
|
||||
location_initial_uuid_b64: 'loc-root',
|
||||
date: '2026-04-10',
|
||||
expire_date: '2026-04-25',
|
||||
first_expire_date: '2026-04-25',
|
||||
first_production_date: '2026-04-10',
|
||||
items_count: 1,
|
||||
items: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function createGroupedExpanded() {
|
||||
return [
|
||||
{
|
||||
...createGroupedSummary()[0],
|
||||
items: [
|
||||
{
|
||||
id: 100,
|
||||
uuid_b64: 'item-100',
|
||||
name: 'Rice',
|
||||
description: 'Open bag',
|
||||
stock_type: 'measured',
|
||||
level: 'good',
|
||||
quantity: 1,
|
||||
uom_symbol: 'kg',
|
||||
location_initial_uuid_b64: 'loc-root',
|
||||
date: '2026-04-10',
|
||||
expire_date: '2026-04-25',
|
||||
expire_in: 13,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function createWindowMock() {
|
||||
const intervals = new Map();
|
||||
let nextId = 1;
|
||||
|
||||
return {
|
||||
location: { hash: '#/stock' },
|
||||
setInterval: vi.fn((fn) => {
|
||||
const id = nextId;
|
||||
nextId += 1;
|
||||
intervals.set(id, fn);
|
||||
return id;
|
||||
}),
|
||||
clearInterval: vi.fn((id) => {
|
||||
intervals.delete(id);
|
||||
}),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
matchMedia: vi.fn(() => ({ matches: false })),
|
||||
__intervals: intervals,
|
||||
};
|
||||
}
|
||||
|
||||
describe('stock list grouped-first behavior', () => {
|
||||
beforeEach(() => {
|
||||
listStockEntriesMock.mockReset();
|
||||
listGroupedStockEntriesMock.mockReset();
|
||||
listKitchenChangesMock.mockReset();
|
||||
updateStockItemMock.mockReset();
|
||||
useStockItemMock.mockReset();
|
||||
fetchLocationsMock.mockReset();
|
||||
|
||||
globalThis.window = createWindowMock();
|
||||
globalThis.requestAnimationFrame = (callback) => callback();
|
||||
globalThis.HTMLDetailsElement = class MockDetailsElement {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete globalThis.window;
|
||||
delete globalThis.requestAnimationFrame;
|
||||
delete globalThis.HTMLDetailsElement;
|
||||
});
|
||||
|
||||
it('defaults to grouped mode and loads grouped summary before lazy item list', async () => {
|
||||
listGroupedStockEntriesMock
|
||||
.mockResolvedValueOnce(createGroupedSummary())
|
||||
.mockResolvedValueOnce(createGroupedExpanded());
|
||||
listStockEntriesMock.mockResolvedValueOnce([]);
|
||||
listKitchenChangesMock.mockResolvedValue({ since: null, nextCursor: null, changes: [] });
|
||||
fetchLocationsMock.mockResolvedValue({ flat: [], tree: [] });
|
||||
|
||||
const store = { isConnected: true, addAlert: vi.fn() };
|
||||
const data = stockListPageData(store);
|
||||
data.$nextTick = vi.fn(async () => {});
|
||||
|
||||
await data.init();
|
||||
|
||||
expect(data.viewMode).toBe('grouped');
|
||||
expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(1, store, { expanded: 0 });
|
||||
expect(listStockEntriesMock).not.toHaveBeenCalled();
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(2, store, { expanded: 1 });
|
||||
|
||||
await data.switchView('items');
|
||||
expect(listStockEntriesMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await data.switchView('grouped');
|
||||
await data.switchView('items');
|
||||
expect(listStockEntriesMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hydrates grouped children in background and merges into existing groups', async () => {
|
||||
listGroupedStockEntriesMock
|
||||
.mockResolvedValueOnce(createGroupedSummary())
|
||||
.mockResolvedValueOnce(createGroupedExpanded());
|
||||
|
||||
const data = stockListPageData({ isConnected: true, addAlert: vi.fn() });
|
||||
await data.loadGroupedEntries({ expanded: 0, resetVisible: true });
|
||||
|
||||
expect(data.groupedEntries[0].items).toEqual([]);
|
||||
|
||||
await data.hydrateGroupedEntriesInBackground();
|
||||
|
||||
expect(data.groupedHydrated).toBe(true);
|
||||
expect(data.groupedEntries[0].items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('memoizes filtered results and invalidates when filters change', () => {
|
||||
const data = stockListPageData({ isConnected: true, addAlert: vi.fn() });
|
||||
data.entries = [
|
||||
data.indexEntry({
|
||||
id: 1,
|
||||
uuid_b64: 'item-1',
|
||||
name: 'Milk',
|
||||
description: 'Fresh',
|
||||
location_initial_uuid_b64: null,
|
||||
stock_type: 'binary',
|
||||
level: 'good',
|
||||
}),
|
||||
];
|
||||
data.entriesVersion = 1;
|
||||
|
||||
const first = data.filteredEntries;
|
||||
const second = data.filteredEntries;
|
||||
|
||||
expect(second).toBe(first);
|
||||
|
||||
data.filters.search = 'milk';
|
||||
|
||||
const third = data.filteredEntries;
|
||||
expect(third).not.toBe(first);
|
||||
expect(third).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps grouped data visible while background summary refresh is in progress', async () => {
|
||||
let resolveRefresh;
|
||||
const refreshPromise = new Promise((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
});
|
||||
|
||||
listGroupedStockEntriesMock.mockImplementationOnce(() => refreshPromise);
|
||||
|
||||
const data = stockListPageData({ isConnected: true, addAlert: vi.fn() });
|
||||
data.groupedLoaded = true;
|
||||
data.groupedEntries = createGroupedSummary().map((group) => data.indexGroup(group));
|
||||
|
||||
const pending = data.loadGroupedEntries({ expanded: 0, background: true });
|
||||
|
||||
expect(data.state.isRefreshing).toBe(true);
|
||||
expect(data.groupedEntries).toHaveLength(1);
|
||||
|
||||
resolveRefresh(createGroupedSummary());
|
||||
await pending;
|
||||
|
||||
expect(data.state.isRefreshing).toBe(false);
|
||||
expect(data.groupedEntries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('poll refreshes view only when new changes exist', async () => {
|
||||
listKitchenChangesMock
|
||||
.mockResolvedValueOnce({ since: 'a', nextCursor: 'b', changes: [] })
|
||||
.mockResolvedValueOnce({
|
||||
since: 'b',
|
||||
nextCursor: 'c',
|
||||
changes: [{ type: 'stock', action: 'use', timestamp: '2026-04-12T08:00:00Z' }],
|
||||
});
|
||||
|
||||
const data = stockListPageData({ isConnected: true, addAlert: vi.fn() });
|
||||
const refreshSpy = vi.fn(async () => {});
|
||||
data.refreshCurrentView = refreshSpy;
|
||||
|
||||
await data.pollKitchenChanges();
|
||||
expect(refreshSpy).not.toHaveBeenCalled();
|
||||
|
||||
await data.pollKitchenChanges();
|
||||
expect(refreshSpy).toHaveBeenCalledTimes(1);
|
||||
expect(refreshSpy).toHaveBeenCalledWith({ background: true });
|
||||
});
|
||||
|
||||
it('tracks grouped card open state from details toggle events', () => {
|
||||
const data = stockListPageData({ isConnected: true, addAlert: vi.fn() });
|
||||
|
||||
class MockDetails extends HTMLDetailsElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.open = true;
|
||||
this.dataset = { groupId: '10' };
|
||||
}
|
||||
|
||||
querySelector() {
|
||||
return {
|
||||
scrollIntoView: vi.fn(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const details = new MockDetails();
|
||||
|
||||
data.handleGroupedToggle({ target: details });
|
||||
expect(data.isGroupedCardOpen(10)).toBe(true);
|
||||
|
||||
details.open = false;
|
||||
data.handleGroupedToggle({ target: details });
|
||||
expect(data.isGroupedCardOpen(10)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user