mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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.
137 lines
4.3 KiB
TypeScript
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();
|
|
});
|
|
});
|