import { describe, it, expect } from 'vitest'; import { buildCompatibilityIndex, matchesPrinterModelSuffix, presetCompatibility, EMPTY_COMPATIBILITY_INDEX, } from '../../utils/slicerPrinterMatch'; const X1C = 'Bambu Lab X1 Carbon 0.4 nozzle'; const P2S = 'Bambu Lab P2S 0.4 nozzle'; // Mirror of backend/app/utils/printer_models.py PRINTER_MODEL_MAP, fetched // from /slicer/printer-models at runtime (#1325 follow-up). Listed in tests // to exercise the @BBL name fallback against the same registry the real // app sees. const PRINTER_MODELS: Record = { 'Bambu Lab X1 Carbon': 'X1C', 'Bambu Lab X1': 'X1', 'Bambu Lab X1E': 'X1E', 'Bambu Lab P1S': 'P1S', 'Bambu Lab P1P': 'P1P', 'Bambu Lab P2S': 'P2S', 'Bambu Lab A1': 'A1', 'Bambu Lab A1 Mini': 'A1 Mini', 'Bambu Lab A1 mini': 'A1 Mini', 'Bambu Lab H2D': 'H2D', 'Bambu Lab H2D Pro': 'H2D Pro', 'Bambu Lab H2C': 'H2C', 'Bambu Lab H2S': 'H2S', 'Bambu Lab X2D': 'X2D', }; describe('buildCompatibilityIndex', () => { it('inverts the printer-model registry into short-code → display fragment', () => { const index = buildCompatibilityIndex(PRINTER_MODELS); expect(index.bambuModelByShortCode.X1C).toBe('X1 Carbon'); expect(index.bambuModelByShortCode.P2S).toBe('P2S'); expect(index.bambuModelByShortCode['A1 Mini']).toBe('A1 Mini'); expect(index.bambuModelByShortCode['H2D Pro']).toBe('H2D Pro'); }); it('tolerates an empty printer-model registry (model fetch hasn\'t resolved yet)', () => { const index = buildCompatibilityIndex(); expect(index.bambuModelByShortCode).toEqual({}); }); }); describe('presetCompatibility', () => { const index = buildCompatibilityIndex(PRINTER_MODELS); it('uses compatible_printers exactly when present (imported / local tier)', () => { const preset = { name: 'My Process', compatible_printers: [X1C] }; expect(presetCompatibility(preset, 'process', X1C, EMPTY_COMPATIBILITY_INDEX)).toBe('match'); expect(presetCompatibility(preset, 'process', P2S, EMPTY_COMPATIBILITY_INDEX)).toBe('mismatch'); }); it('is unknown when compatible_printers is set but no printer is selected', () => { expect( presetCompatibility({ name: 'P', compatible_printers: [X1C] }, 'process', null, index), ).toBe('unknown'); }); it('is unknown when no printer is selected', () => { expect( presetCompatibility({ name: '0.20mm Standard @BBL X1C' }, 'process', null, index), ).toBe('unknown'); }); it('compatible_printers wins over @BBL even when the name suggests a different printer', () => { // Authoritative slicer declaration: this @BBL P2S preset has been // manually reassigned to X1C. The compatible_printers list must win. expect( presetCompatibility( { name: '0.20mm Standard @BBL P2S', compatible_printers: [X1C] }, 'process', X1C, index, ), ).toBe('match'); expect( presetCompatibility( { name: '0.20mm Standard @BBL P2S', compatible_printers: [X1C] }, 'process', P2S, index, ), ).toBe('mismatch'); }); }); // ─── #1325 follow-up: @BBL name fallback ────────────────────────────────── describe('presetCompatibility — @BBL name fallback', () => { const idx = buildCompatibilityIndex(PRINTER_MODELS); // Bambu's short codes vs the long forms in printer-preset names: the // entire reason the fallback needs a registry to consult. it.each<[string, string, 'match' | 'mismatch']>([ // @BBL X1C → "X1 Carbon" (the case the old hardcoded list got right) ['0.20mm Standard @BBL X1C', X1C, 'match'], ['0.20mm Standard @BBL X1C', 'Bambu Lab P1S 0.4 nozzle', 'mismatch'], // @BBL X1 must NOT match X1 Carbon (X1 and X1C are physically different printers) ['0.20mm Standard @BBL X1', 'Bambu Lab X1 0.4 nozzle', 'match'], ['0.20mm Standard @BBL X1', X1C, 'mismatch'], // @BBL A1 must NOT match A1 mini (case the original hardcoded list got wrong) ['0.20mm Standard @BBL A1', 'Bambu Lab A1 0.4 nozzle', 'match'], ['0.20mm Standard @BBL A1', 'Bambu Lab A1 mini 0.4 nozzle', 'mismatch'], // @BBL "A1 Mini" — multi-word token ['0.20mm Standard @BBL A1 Mini', 'Bambu Lab A1 mini 0.4 nozzle', 'match'], // @BBL H2D vs H2D Pro disambiguation ['0.20mm Standard @BBL H2D', 'Bambu Lab H2D 0.4 nozzle', 'match'], ['0.20mm Standard @BBL H2D', 'Bambu Lab H2D Pro 0.4 nozzle', 'mismatch'], ['0.20mm Standard @BBL H2D Pro', 'Bambu Lab H2D Pro 0.4 nozzle', 'match'], // Models the original hardcoded list missed, now resolved via the // backend registry. ['Bambu PLA Basic @BBL P2S', P2S, 'match'], ['Bambu PLA Basic @BBL P2S', X1C, 'mismatch'], ['0.20mm Standard @BBL X2D', 'Bambu Lab X2D 0.4 nozzle', 'match'], ['0.20mm Standard @BBL H2C', 'Bambu Lab H2C 0.4 nozzle', 'match'], ['0.20mm Standard @BBL H2S', 'Bambu Lab H2S 0.4 nozzle', 'match'], ])('classifies %s against %s as %s', (presetName, printerName, expected) => { expect(presetCompatibility({ name: presetName }, 'process', printerName, idx)).toBe(expected); }); it('handles a trailing nozzle-size suffix on the @BBL tag', () => { // An explicit "0.4 nozzle" suffix matches the 0.4 printer (Bambu's // convention is to omit it for 0.4, but some cloud presets write it). expect( presetCompatibility( { name: '0.20mm Standard @BBL X1C 0.4 nozzle' }, 'process', X1C, idx, ), ).toBe('match'); // #1325 follow-up #2 (IndividualGhost1905, 2026-05-23): a different // nozzle size IS a mismatch — a 0.6-nozzle process is unusable on a // 0.4-nozzle printer. The dedicated "nozzle filtering" describe // block below covers the full matrix; this case stays here as the // counterpart to the matching-suffix case above. expect( presetCompatibility( { name: '0.20mm Standard @BBL X1C 0.6 nozzle' }, 'process', X1C, idx, ), ).toBe('mismatch'); }); it('is unknown when the preset has no @BBL tag at all (custom name, no other signal)', () => { expect(presetCompatibility({ name: 'My Custom Process' }, 'process', X1C, idx)).toBe('unknown'); }); it('is unknown for a Bambu preset against a non-Bambu printer (can\'t parse the printer name)', () => { expect( presetCompatibility({ name: '0.20mm Standard @BBL X1C' }, 'process', 'CustomBuild 0.4', idx), ).toBe('unknown'); }); it('falls back to raw-token comparison for a model not yet in the registry', () => { // A future "Q1" printer with cloud presets named "@BBL Q1" should // match without any code change to the registry — both names resolve // to "Q1" directly. expect( presetCompatibility( { name: '0.20mm Standard @BBL Q1' }, 'process', 'Bambu Lab Q1 0.4 nozzle', idx, ), ).toBe('match'); // And mismatch against a different printer. expect( presetCompatibility( { name: '0.20mm Standard @BBL Q1' }, 'process', X1C, idx, ), ).toBe('mismatch'); }); it('still resolves @BBL when the registry has not loaded yet (raw-token only)', () => { // EMPTY_COMPATIBILITY_INDEX = no models — first paint of the SliceModal // before the /slicer/printer-models fetch resolves. Short codes that // match their printer-name fragment directly (P2S, H2D, etc.) still // work; codes that differ in form (X1C vs "X1 Carbon") gracefully // fall through to 'mismatch'. expect( presetCompatibility( { name: '0.20mm Standard @BBL P2S' }, 'process', P2S, EMPTY_COMPATIBILITY_INDEX, ), ).toBe('match'); expect( presetCompatibility( { name: '0.20mm Standard @BBL X1C' }, 'process', X1C, EMPTY_COMPATIBILITY_INDEX, ), ).toBe('mismatch'); // X1C ≠ "X1 Carbon" without the registry }); }); // #1325 follow-up #2 (IndividualGhost1905, 2026-05-23): the @BBL name // fallback must also filter by nozzle diameter. Bambu ships per-nozzle // variants of process / filament presets — 0.2 / 0.4 / 0.6 / 0.8 — // and a 0.6-nozzle process is unusable on a 0.4-nozzle printer. Bambu's // naming convention: 0.4 is the default and DROPS the suffix; 0.2 / 0.6 // / 0.8 carry an explicit " nozzle" segment. So an empty suffix // means 0.4, not "any nozzle". describe('presetCompatibility — nozzle filtering on @BBL name fallback', () => { const X1C_04 = 'Bambu Lab X1 Carbon 0.4 nozzle'; const X1C_06 = 'Bambu Lab X1 Carbon 0.6 nozzle'; const X1C_08 = 'Bambu Lab X1 Carbon 0.8 nozzle'; const index = buildCompatibilityIndex(PRINTER_MODELS); it('treats a no-suffix process as 0.4 (Bambu default) and matches a 0.4 printer', () => { expect( presetCompatibility({ name: '0.20mm Standard @BBL X1C' }, 'process', X1C_04, index), ).toBe('match'); }); it('flags a 0.6-nozzle process as mismatch against a 0.4 printer', () => { expect( presetCompatibility({ name: '0.30mm @BBL X1C 0.6 nozzle' }, 'process', X1C_04, index), ).toBe('mismatch'); }); it('flags an 0.8-nozzle process as mismatch against a 0.4 printer', () => { expect( presetCompatibility({ name: '0.40mm Strength @BBL X1C 0.8 nozzle' }, 'process', X1C_04, index), ).toBe('mismatch'); }); it('matches a 0.6-nozzle process against a 0.6 printer', () => { expect( presetCompatibility({ name: '0.30mm @BBL X1C 0.6 nozzle' }, 'process', X1C_06, index), ).toBe('match'); }); it('flags a no-suffix process (=0.4) as mismatch against a 0.6 printer', () => { expect( presetCompatibility({ name: '0.20mm Standard @BBL X1C' }, 'process', X1C_06, index), ).toBe('mismatch'); }); it('applies the same rule to filament presets', () => { // Bambu's bundled filament presets follow the same per-nozzle naming. expect( presetCompatibility( { name: 'Bambu PLA Basic @BBL X1C 0.6 nozzle' }, 'filament', X1C_04, index, ), ).toBe('mismatch'); expect( presetCompatibility({ name: 'Bambu PLA Basic @BBL X1C' }, 'filament', X1C_04, index), ).toBe('match'); }); it('keeps a 0.4 process matching a 0.4 printer when the preset DOES carry an explicit "0.4 nozzle" suffix', () => { // Some cloud presets write the 0.4 suffix explicitly even though // Bambu's bundled convention omits it. Both forms must compare equal. expect( presetCompatibility( { name: '0.20mm Standard @BBL X1C 0.4 nozzle' }, 'process', X1C_04, index, ), ).toBe('match'); }); it('still flags a wrong-MODEL process even when the nozzle matches', () => { // The model filter must continue to dominate over the nozzle filter: // a 0.4 A1 process isn't usable on an X1C 0.4 just because both are 0.4. expect( presetCompatibility({ name: '0.20mm Standard @BBL A1' }, 'process', X1C_04, index), ).toBe('mismatch'); }); it('falls back to model-only when the selected printer name has no parseable nozzle', () => { // Defensive degrade for non-Bambu / hand-typed names that happen to // match the model. Real Bambu printer presets always carry a nozzle, // so this path is rare; the assertion pins the intentional behaviour. const noNozzle = 'Bambu Lab X1 Carbon'; // no "0.4 nozzle" suffix expect( presetCompatibility({ name: '0.30mm @BBL X1C 0.6 nozzle' }, 'process', noNozzle, index), ).toBe('match'); }); it('flags 0.4 process on 0.8 printer (sanity check across the third common size)', () => { expect( presetCompatibility({ name: '0.20mm Standard @BBL X1C' }, 'process', X1C_08, index), ).toBe('mismatch'); }); }); describe('matchesPrinterModelSuffix (#1649)', () => { it('matches the canonical short code against itself', () => { expect(matchesPrinterModelSuffix('X1C', 'X1C')).toBe(true); }); it('is case-insensitive on both sides', () => { expect(matchesPrinterModelSuffix('x1c', 'X1C')).toBe(true); expect(matchesPrinterModelSuffix('a1 mini', 'A1 Mini')).toBe(true); }); it('matches Bambu cloud rename A1M against the long form A1 Mini', () => { expect(matchesPrinterModelSuffix('A1M', 'A1 Mini')).toBe(true); }); it('matches the long form A1 Mini against the short Bambu cloud code A1M', () => { expect(matchesPrinterModelSuffix('A1 Mini', 'A1M')).toBe(true); }); it('does NOT match A1M against A1 (different printer, must not collapse)', () => { expect(matchesPrinterModelSuffix('A1M', 'A1')).toBe(false); }); it('does NOT match A1 against A1 Mini', () => { expect(matchesPrinterModelSuffix('A1', 'A1 Mini')).toBe(false); }); it('does NOT match unrelated models', () => { expect(matchesPrinterModelSuffix('X1C', 'P1S')).toBe(false); }); }); describe('presetCompatibility with Bambu cloud A1M rename (#1649)', () => { const A1_MINI = 'Bambu Lab A1 mini 0.4 nozzle'; const A1 = 'Bambu Lab A1 0.4 nozzle'; const idx = buildCompatibilityIndex(PRINTER_MODELS); it('matches a cloud preset using the new @BBL A1M suffix against an A1 Mini printer', () => { // The slicer-mirrored case: technopaw's report — A1 Mini cloud presets // newly ship as "Bambu PLA Basic @BBL A1M ..." and used to be filtered // out by the model check that compared "A1M" vs "A1 mini" verbatim. expect( presetCompatibility({ name: 'Bambu PLA Basic @BBL A1M' }, 'filament', A1_MINI, idx), ).toBe('match'); }); it('matches a 0.4-nozzle process with @BBL A1M against an A1 Mini printer', () => { expect( presetCompatibility({ name: '0.20mm Standard @BBL A1M' }, 'process', A1_MINI, idx), ).toBe('match'); }); it('does NOT match @BBL A1M against an A1 (non-mini) printer', () => { // The alias must not collapse two physically different printers — A1 // and A1 Mini ship distinct profile sets. expect( presetCompatibility({ name: '0.20mm Standard @BBL A1M' }, 'process', A1, idx), ).toBe('mismatch'); }); }); describe('presetCompatibility — long-form @Bambu Lab printer tag (#2628)', () => { const A1 = 'Bambu Lab A1 0.4 nozzle'; const A1_MINI = 'Bambu Lab A1 mini 0.4 nozzle'; const H2D = 'Bambu Lab H2D 0.4 nozzle'; const idx = buildCompatibilityIndex(PRINTER_MODELS); it('flags a user-saved H2D filament preset as a mismatch on an A1', () => { // michaelklos's report: this exact preset sat in an unused filament slot // of a multi-plate 3MF. Classified 'unknown' it was treated as usable, // auto-picked, and the CLI rejected the slice with "filament preset // (slot 1) is not compatible with printer Bambu Lab A1 0.4 nozzle". expect( presetCompatibility({ name: 'SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle' }, 'filament', A1, idx), ).toBe('mismatch'); }); it('matches the same preset against its own printer', () => { expect( presetCompatibility({ name: 'SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle' }, 'filament', H2D, idx), ).toBe('match'); }); it('resolves a long-form model whose display name differs from its short code', () => { const name = 'My PETG @Bambu Lab X1 Carbon 0.4 nozzle'; expect(presetCompatibility({ name }, 'filament', X1C, idx)).toBe('match'); expect(presetCompatibility({ name }, 'filament', A1, idx)).toBe('mismatch'); }); it('applies the nozzle filter to the long form too', () => { expect( presetCompatibility({ name: 'My PETG @Bambu Lab A1 0.6 nozzle' }, 'filament', A1, idx), ).toBe('mismatch'); }); it('ignores a trailing "(Custom)" suffix rather than mangling the model token', () => { // The slicer appends this to user-saved presets. Parsed naively the tag // becomes "H2D 0.4 nozzle (Custom)" — which would brand the preset a // mismatch against its own printer and hide it from the dropdown. const name = 'Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)'; expect(presetCompatibility({ name }, 'filament', H2D, idx)).toBe('match'); expect(presetCompatibility({ name }, 'filament', A1, idx)).toBe('mismatch'); }); it('keeps the A1M alias working through the long form', () => { expect( presetCompatibility({ name: 'My PLA @Bambu Lab A1 mini 0.4 nozzle' }, 'filament', A1_MINI, idx), ).toBe('match'); expect( presetCompatibility({ name: 'My PLA @Bambu Lab A1 mini 0.4 nozzle' }, 'filament', A1, idx), ).toBe('mismatch'); }); it('stays unknown for an @-tag that names no recognisable printer', () => { // "@Voron 0.4 nozzle" is not a Bambu printer preset — the matcher must // not guess, so the preset keeps its place in the main dropdown list. expect( presetCompatibility({ name: 'My PLA @Voron 0.4 nozzle' }, 'filament', A1, idx), ).toBe('unknown'); }); it('reads the tag from the last @ so a stray earlier one cannot swallow it', () => { expect( presetCompatibility({ name: 'My @work PLA @Bambu Lab H2D 0.4 nozzle' }, 'filament', A1, idx), ).toBe('mismatch'); expect( presetCompatibility({ name: 'My @work PLA @Bambu Lab H2D 0.4 nozzle' }, 'filament', H2D, idx), ).toBe('match'); }); }); describe('presetCompatibility — nozzle-only @ tag (#2628 follow-up)', () => { const P2S_04 = 'Bambu Lab P2S 0.4 nozzle'; const X1C_02 = 'Bambu Lab X1 Carbon 0.2 nozzle'; const idx = buildCompatibilityIndex(PRINTER_MODELS); it('rules out a 0.2-nozzle profile on a 0.4-nozzle printer', () => { // The live case: "Overture PLA Matte @0.2" inherits from the X1C 0.2 // nozzle system profile, so the slicer rejected a P2S 0.4 slice with // "not compatible with printer Bambu Lab P2S 0.4 nozzle". The name // carries no model, so this size is the only signal available. expect( presetCompatibility({ name: 'Overture PLA Matte @0.2' }, 'filament', P2S_04, idx), ).toBe('mismatch'); }); it('stays unknown when the size agrees — a size is not a model', () => { // The profile might belong to a different printer with the same nozzle; // promoting this to 'match' would claim knowledge we don't have. expect( presetCompatibility({ name: 'Overture PLA Matte @0.2' }, 'filament', X1C_02, idx), ).toBe('unknown'); }); it('accepts the "0.2 nozzle" and "0.2mm" spellings of the same tag', () => { for (const name of ['My PLA @0.2 nozzle', 'My PLA @0.2mm']) { expect(presetCompatibility({ name }, 'filament', P2S_04, idx)).toBe('mismatch'); } }); it('compares sizes numerically so 0.20 and 0.2 are one size', () => { expect( presetCompatibility({ name: 'My PLA @0.20' }, 'filament', X1C_02, idx), ).toBe('unknown'); }); it('ignores a numeric tag that cannot be a nozzle', () => { // "@2026" is a year, not a 2026 mm nozzle — guessing here would brand // the profile incompatible with every printer that exists. expect(presetCompatibility({ name: 'My PLA @2026' }, 'filament', P2S_04, idx)).toBe('unknown'); expect(presetCompatibility({ name: 'My PLA @0.05' }, 'filament', P2S_04, idx)).toBe('unknown'); }); it('leaves a model-bearing tag on the model path', () => { // Regression guard: the nozzle-only branch must not swallow the forms // that carry a model — those still resolve to match/mismatch. expect( presetCompatibility({ name: 'Bambu PLA Basic @BBL P2S' }, 'filament', P2S_04, idx), ).toBe('match'); expect( presetCompatibility({ name: 'My PLA @Bambu Lab X1 Carbon 0.4 nozzle' }, 'filament', P2S_04, idx), ).toBe('mismatch'); }); }); describe("presetCompatibility — BambuStudio's \"# \" user-clone prefix", () => { const index = buildCompatibilityIndex(PRINTER_MODELS); // Editing a system preset saves a copy under this name; .bbscfg bundle // exports use the same convention. The backend already normalises it in // _canonical_printer_model. const CLONED_X1C = '# Bambu Lab X1 Carbon 0.4 nozzle'; it('still matches a printer-tagged preset when the printer is a clone', () => { // Regression: the prefix failed the "Bambu Lab …" test, so every preset // came back 'unknown' and the dropdown filter silently did nothing. expect( presetCompatibility({ name: '0.20mm Standard @BBL X1C' }, 'process', CLONED_X1C, index), ).toBe('match'); }); it('still rules out another printer when the selected printer is a clone', () => { expect( presetCompatibility({ name: '0.20mm Standard @BBL H2D' }, 'process', CLONED_X1C, index), ).toBe('mismatch'); }); it('matches a cloned preset against an unprefixed printer', () => { expect( presetCompatibility({ name: '# 0.20mm Standard @BBL X1C' }, 'process', X1C, index), ).toBe('match'); }); it('still compares the nozzle size through the prefix', () => { expect( presetCompatibility({ name: '0.20mm Standard @BBL X1C 0.6 nozzle' }, 'process', CLONED_X1C, index), ).toBe('mismatch'); }); it('matches compatible_printers with the prefix on either side', () => { // A preset cloned from a system printer lists the *unprefixed* name; a raw // comparison against the "# " form reads as a mismatch, which now hides // the preset rather than merely demoting it. expect( presetCompatibility({ name: 'My Process', compatible_printers: [X1C] }, 'process', CLONED_X1C, index), ).toBe('match'); expect( presetCompatibility({ name: 'My Process', compatible_printers: [CLONED_X1C] }, 'process', X1C, index), ).toBe('match'); }); it('does not let the prefix turn a genuine mismatch into a match', () => { expect( presetCompatibility({ name: 'My Process', compatible_printers: [P2S] }, 'process', CLONED_X1C, index), ).toBe('mismatch'); }); it('leaves an untagged clone unknown rather than guessing', () => { expect( presetCompatibility({ name: '# My own profile' }, 'process', CLONED_X1C, index), ).toBe('unknown'); }); });