Files
bambuddy/frontend/src/__tests__/components/PrintLogModal.test.tsx
T
maziggy 856b849ffa fix(stats): per-event aggregation so reprints add to Quick Stats instead of overwriting (#1378)
Statistics now aggregate over PrintLogEntry (one row per print event,
  the same table backing the global Print Log) rather than PrintArchive
  (one row per file). A reprint creates a new PrintLogEntry instead of
  overwriting the source archive's runtime fields, so:

  - a 100 g successful print + a 10 g failed reprint correctly sums to
    110 g / 2 prints / 1 successful / 1 failed in Quick Stats and the
    Prometheus /metrics endpoint (previously the failed reprint silently
    replaced the source archive's data; totals dropped from 100 g to 10 g)
  - the archive's card cost/energy_kwh are preserved on reprints (only
    the first run writes them); per-run actuals live on PrintLogEntry
  - failed/cancelled/stopped reprints record partial-aware filament: sum
    of tracked spool deltas when inventory is set up, else estimate
    scaled to progress%, else None — prevents the full slicer estimate
    from inflating totals on a print that stopped at 10 % progress

  PrintLogEntry gains six columns: archive_id (nullable FK, ON DELETE
  SET NULL so log entries survive archive deletion preserving #1343
  soft-delete-vs-stats decoupling), cost, energy_kwh, energy_cost,
  failure_reason, created_by_id. Idempotent SQLite + Postgres migrations.

  New per-archive surface:

  - archive list response carries run_count / last_run_at /
    total_filament_actual_grams / successful_run_count / failed_run_count
    via a single batch JOIN, no N+1
  - new GET /archives/{id}/runs endpoint returns every PrintLogEntry for
    the archive (ARCHIVES_READ permission, newest-first ordering)
  - archive cards render an orange "N prints" badge for archives with
    more than one run; clicking the badge opens a dedicated PrintLogModal
    with date/status/duration/filament/cost columns plus failure_reason
    under failed runs. Also reachable via the context menu's new "Print
    Log" entry (works for single-run archives too), and embedded at the
    top of the Edit Archive modal for context.

  The purge_stats=true delete path now hard-deletes linked PrintLogEntry
  rows up front so the archive's contribution truly leaves the totals;
  without it, ON DELETE SET NULL would orphan the runs and leave them
  counting toward stats.
2026-05-16 11:34:40 +02:00

137 lines
4.3 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import { render } from '../utils';
import { PrintLogModal } from '../../components/PrintLogModal';
import { api } from '../../api/client';
vi.mock('../../api/client', () => ({
api: {
getArchiveRuns: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
},
}));
const sampleRuns = {
total: 2,
items: [
{
id: 99,
archive_id: 42,
print_name: 'Benchy',
printer_name: 'X1C-01',
printer_id: 3,
status: 'failed',
started_at: '2026-05-10T10:00:00Z',
completed_at: '2026-05-10T10:05:00Z',
duration_seconds: 300,
filament_type: 'PLA',
filament_color: '#FF0000',
filament_used_grams: 10.0,
cost: 0.25,
energy_kwh: 0.05,
energy_cost: 0.01,
failure_reason: 'Cancelled by user',
thumbnail_path: null,
created_by_id: 1,
created_by_username: 'admin',
created_at: '2026-05-10T10:05:00Z',
},
{
id: 50,
archive_id: 42,
print_name: 'Benchy',
printer_name: 'X1C-01',
printer_id: 3,
status: 'completed',
started_at: '2026-05-01T10:00:00Z',
completed_at: '2026-05-01T11:00:00Z',
duration_seconds: 3600,
filament_type: 'PLA',
filament_color: '#FF0000',
filament_used_grams: 100.0,
cost: 2.5,
energy_kwh: 0.2,
energy_cost: 0.05,
failure_reason: null,
thumbnail_path: null,
created_by_id: 1,
created_by_username: 'admin',
created_at: '2026-05-01T11:00:00Z',
},
],
};
describe('PrintLogModal', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(api.getArchiveRuns).mockResolvedValue(sampleRuns);
});
it('renders the archive name in the modal title', async () => {
render(<PrintLogModal archiveId={42} archiveName="Benchy" onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText(/Benchy/)).toBeInTheDocument();
});
});
it('falls back to "this archive" when name is null', async () => {
render(<PrintLogModal archiveId={42} archiveName={null} onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText(/this archive/i)).toBeInTheDocument();
});
});
it('lists every run with its filament + cost', async () => {
render(<PrintLogModal archiveId={42} archiveName="Benchy" onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText(/100\.0 g/)).toBeInTheDocument();
expect(screen.getByText(/10\.0 g/)).toBeInTheDocument();
});
});
it('shows failure_reason under failed runs', async () => {
render(<PrintLogModal archiveId={42} archiveName="Benchy" onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Cancelled by user')).toBeInTheDocument();
});
});
it('shows the empty state when there are no runs', async () => {
vi.mocked(api.getArchiveRuns).mockResolvedValue({ total: 0, items: [] });
render(<PrintLogModal archiveId={42} archiveName="Benchy" onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText(/no print events/i)).toBeInTheDocument();
});
});
it('calls onClose when Escape is pressed', async () => {
const onClose = vi.fn();
render(<PrintLogModal archiveId={42} archiveName="Benchy" onClose={onClose} />);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
it('calls onClose when backdrop is clicked', async () => {
const onClose = vi.fn();
const { container } = render(
<PrintLogModal archiveId={42} archiveName="Benchy" onClose={onClose} />
);
const backdrop = container.querySelector('.fixed.inset-0');
if (!backdrop) throw new Error('Backdrop not found');
fireEvent.click(backdrop);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('does not close when clicking inside the modal body', async () => {
const onClose = vi.fn();
render(<PrintLogModal archiveId={42} archiveName="Benchy" onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText(/Benchy/)).toBeInTheDocument();
});
// Click on the title text inside the modal
fireEvent.click(screen.getByText(/Benchy/));
expect(onClose).not.toHaveBeenCalled();
});
});