Explorar el Código

Split multi-material types on the comma alone in statistics and the material filter (#3262)

maziggy hace 2 semanas
padre
commit
7a29feb6c8

+ 94 - 0
frontend/src/__tests__/pages/ArchivesMaterialFilter.test.tsx

@@ -0,0 +1,94 @@
+/**
+ * The Archives material filter offers each material once and matches a
+ * multi-material archive under every one of its materials, whichever separator
+ * its `filament_type` was written with (#3262).
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import { ArchivesPage } from '../../pages/ArchivesPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { setAuthToken } from '../../api/client';
+
+const base = {
+  printer_id: 1,
+  printer_name: 'H2S',
+  print_time_seconds: 3600,
+  filament_used_grams: 9,
+  thumbnail_path: null,
+  notes: null,
+  rating: null,
+  project_id: null,
+  project_name: null,
+  project_color: null,
+  print_count: 1,
+  tags: '',
+  has_f3d: false,
+  status: 'completed',
+  started_at: '2026-10-07T10:00:00Z',
+  completed_at: '2026-10-07T11:00:00Z',
+  created_at: '2026-10-07T10:00:00Z',
+  updated_at: '2026-10-07T11:00:00Z',
+};
+
+const archives = [
+  { ...base, id: 1, filename: 'a.gcode.3mf', print_name: 'FromSpools', filament_type: 'PLA Basic,PLA' },
+  { ...base, id: 2, filename: 'b.gcode.3mf', print_name: 'FromSlicer', filament_type: 'PLA, PLA-S' },
+  { ...base, id: 3, filename: 'c.gcode.3mf', print_name: 'PetgOnly', filament_type: 'PETG' },
+];
+
+const materialSelect = () =>
+  screen.getAllByRole('combobox').find((el) => el.querySelector('option[value=""]')?.textContent === 'All Materials')!;
+
+describe('Archives material filter (#3262)', () => {
+  beforeEach(() => {
+    localStorage.removeItem('archiveFilterMaterial');
+    setAuthToken(null);
+    server.use(
+      http.get('/api/v1/archives/', () => HttpResponse.json(archives)),
+      http.get('/api/v1/archives/stats', () =>
+        HttpResponse.json({
+          total_archives: 3,
+          total_print_time_seconds: 10800,
+          total_filament_grams: 27,
+          prints_this_week: 3,
+          prints_this_month: 3,
+        })
+      ),
+      http.get('/api/v1/printers/', () => HttpResponse.json([{ id: 1, name: 'H2S' }])),
+      http.get('/api/v1/projects/', () => HttpResponse.json([])),
+      http.get('/api/v1/archives/tags', () => HttpResponse.json([]))
+    );
+  });
+
+  it('lists each material once, not the joined string', async () => {
+    render(<ArchivesPage />);
+    await waitFor(() => expect(screen.getByText('PetgOnly')).toBeInTheDocument());
+
+    const options = Array.from(materialSelect().querySelectorAll('option')).map((o) => o.getAttribute('value'));
+    expect(options).toEqual(['', 'PETG', 'PLA', 'PLA Basic', 'PLA-S']);
+  });
+
+  it('matches archives under each of their materials, whatever the separator', async () => {
+    render(<ArchivesPage />);
+    await waitFor(() => expect(screen.getByText('PetgOnly')).toBeInTheDocument());
+
+    fireEvent.change(materialSelect(), { target: { value: 'PLA' } });
+
+    await waitFor(() => expect(screen.queryByText('PetgOnly')).not.toBeInTheDocument());
+    expect(screen.getByText('FromSpools')).toBeInTheDocument();
+    expect(screen.getByText('FromSlicer')).toBeInTheDocument();
+  });
+
+  it('drops a saved joined material from before the fix instead of hiding everything', async () => {
+    localStorage.setItem('archiveFilterMaterial', 'PLA Basic,PLA');
+
+    render(<ArchivesPage />);
+
+    await waitFor(() => expect(screen.getByText('PetgOnly')).toBeInTheDocument());
+    expect(screen.getByText('FromSpools')).toBeInTheDocument();
+    expect((materialSelect() as HTMLSelectElement).value).toBe('');
+  });
+});

+ 32 - 0
frontend/src/__tests__/utils/filamentTypes.test.ts

@@ -0,0 +1,32 @@
+/**
+ * Multi-material `filament_type` arrives joined two ways: `", "` from the 3MF
+ * parser and `","` from the spool-based rewrites. Both must split the same way
+ * the backend's /archives/stats does (#3262).
+ */
+
+import { describe, it, expect } from 'vitest';
+import { splitFilamentTypes } from '../../utils/filamentTypes';
+
+describe('splitFilamentTypes (#3262)', () => {
+  it('splits a value written from spools (no space)', () => {
+    expect(splitFilamentTypes('PLA Basic,PLA')).toEqual(['PLA Basic', 'PLA']);
+  });
+
+  it('splits a value written by the 3MF parser (comma + space)', () => {
+    expect(splitFilamentTypes('PLA, PLA-S')).toEqual(['PLA', 'PLA-S']);
+  });
+
+  it('keeps a single material, including one with a space in its name', () => {
+    expect(splitFilamentTypes('PLA Basic')).toEqual(['PLA Basic']);
+  });
+
+  it('drops empty parts and repeats', () => {
+    expect(splitFilamentTypes(' PLA ,, PETG, PLA,')).toEqual(['PLA', 'PETG']);
+  });
+
+  it('returns nothing for a missing value', () => {
+    expect(splitFilamentTypes(null)).toEqual([]);
+    expect(splitFilamentTypes(undefined)).toEqual([]);
+    expect(splitFilamentTypes('')).toEqual([]);
+  });
+});

+ 12 - 8
frontend/src/components/FilamentTrends.tsx

@@ -16,6 +16,7 @@ import type { ArchiveSlim } from '../api/client';
 import { MetricToggle, type Metric } from './MetricToggle';
 import { parseUTCDate } from '../utils/date';
 import { formatWeight } from '../utils/weight';
+import { splitFilamentTypes } from '../utils/filamentTypes';
 
 interface FilamentTrendsProps {
   archives: ArchiveSlim[];
@@ -24,6 +25,12 @@ interface FilamentTrendsProps {
   dateTo?: string;
 }
 
+/** An archive's materials, or 'Unknown' when it has none (#3262). */
+function archiveFilamentTypes(filamentType: string | null | undefined): string[] {
+  const types = splitFilamentTypes(filamentType);
+  return types.length ? types : ['Unknown'];
+}
+
 const COLORS = ['#00ae42', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316'];
 
 const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
@@ -139,9 +146,8 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
     const dataMap = new Map<string, number>();
 
     archives.forEach(archive => {
-      const type = archive.filament_type || 'Unknown';
-      // Handle multiple types (e.g., "PLA, PETG")
-      const types = type.split(', ');
+      // Multiple types (e.g. "PLA, PETG" or "PLA Basic,PLA") share the print
+      const types = archiveFilamentTypes(archive.filament_type);
       types.forEach(t => {
         const grams = (archive.filament_used_grams || 0) / types.length;
         dataMap.set(t, (dataMap.get(t) || 0) + grams);
@@ -157,8 +163,7 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
   const filamentTypePrintData = useMemo(() => {
     const dataMap = new Map<string, number>();
     archives.forEach(archive => {
-      const type = archive.filament_type || 'Unknown';
-      const types = type.split(', ');
+      const types = archiveFilamentTypes(archive.filament_type);
       types.forEach(t => {
         dataMap.set(t, (dataMap.get(t) || 0) + 1);
       });
@@ -172,8 +177,7 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
   const filamentTypeTimeData = useMemo(() => {
     const dataMap = new Map<string, number>();
     archives.forEach(archive => {
-      const type = archive.filament_type || 'Unknown';
-      const types = type.split(', ');
+      const types = archiveFilamentTypes(archive.filament_type);
       const seconds = (archive.actual_time_seconds || archive.print_time_seconds || 0) / types.length;
       types.forEach(t => {
         dataMap.set(t, (dataMap.get(t) || 0) + seconds);
@@ -189,7 +193,7 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
     const map = new Map<string, { completed: number; failed: number }>();
     archives.forEach(a => {
       if (a.status !== 'completed' && a.status !== 'failed') return;
-      const types = (a.filament_type || 'Unknown').split(', ');
+      const types = archiveFilamentTypes(a.filament_type);
       types.forEach(type => {
         const entry = map.get(type) || { completed: 0, failed: 0 };
         if (a.status === 'completed') entry.completed++;

+ 9 - 5
frontend/src/pages/ArchivesPage.tsx

@@ -71,6 +71,7 @@ import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/sl
 import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date';
 import { getCurrencySymbol } from '../utils/currency';
 import { getBedTypeInfo } from '../utils/bedType';
+import { splitFilamentTypes } from '../utils/filamentTypes';
 import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
 import { assignableProjects } from '../utils/projectTree';
 import { verdictSourceKey } from '../utils/verdictSource';
@@ -2947,9 +2948,12 @@ export function ArchivesPage() {
     const saved = localStorage.getItem('archiveFilterPrinter');
     return saved ? Number(saved) : null;
   });
-  const [filterMaterial, setFilterMaterial] = useState<string | null>(() =>
-    localStorage.getItem('archiveFilterMaterial')
-  );
+  const [filterMaterial, setFilterMaterial] = useState<string | null>(() => {
+    // A saved joined value ("PLA Basic,PLA") was once offered as a material of
+    // its own (#3262); it matches nothing now, and would hide every archive.
+    const saved = localStorage.getItem('archiveFilterMaterial');
+    return saved && !saved.includes(',') ? saved : null;
+  });
   const [filterColors, setFilterColors] = useState<Set<string>>(() => {
     const saved = localStorage.getItem('archiveFilterColors');
     return saved ? new Set(JSON.parse(saved)) : new Set();
@@ -3535,7 +3539,7 @@ export function ArchivesPage() {
 
   // Extract unique materials and colors from archives
   const uniqueMaterials = [...new Set(
-    archives?.flatMap(a => a.filament_type?.split(', ') || []).filter(Boolean) || []
+    archives?.flatMap(a => splitFilamentTypes(a.filament_type)) || []
   )].sort();
 
   const uniqueColors = [...new Set(
@@ -3585,7 +3589,7 @@ export function ArchivesPage() {
 
       // Material filter
       const matchesMaterial = !filterMaterial ||
-        (a.filament_type?.split(', ').includes(filterMaterial));
+        splitFilamentTypes(a.filament_type).includes(filterMaterial);
 
       // Color filter (AND: must have all selected colors, OR: must have any selected color)
       const archiveColors = a.filament_color?.split(',') || [];

+ 19 - 0
frontend/src/utils/filamentTypes.ts

@@ -0,0 +1,19 @@
+/**
+ * The materials in an archive's `filament_type`.
+ *
+ * A multi-material print stores them comma-joined, but with two separators:
+ * the 3MF parser writes `", "` and the spool-based rewrites (#2563) write
+ * `","`. Splitting on `", "` alone left `"PLA Basic,PLA"` as one material in
+ * the statistics and the Archives material filter (#3262). Splits on the
+ * comma and trims, like the backend's `/archives/stats`, and drops empty and
+ * repeated entries so a print is never counted twice for one material.
+ */
+export function splitFilamentTypes(value: string | null | undefined): string[] {
+  if (!value) return [];
+  const types: string[] = [];
+  for (const part of value.split(',')) {
+    const type = part.trim();
+    if (type && !types.includes(type)) types.push(type);
+  }
+  return types;
+}