Merge branch 'dev' into feature/russian-localization

This commit is contained in:
pterodaktil02
2026-07-20 12:56:36 +03:00
committed by GitHub
27 changed files with 824 additions and 32 deletions
@@ -259,6 +259,79 @@ describe('SliceModal', () => {
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('offers "use the file\'s built-in settings" when the printer matches the design, and sends the flag (#2611)', async () => {
const onClose = vi.fn();
mockApi.sliceLibraryFile.mockResolvedValue({
job_id: 42,
status: 'pending',
status_url: '/api/v1/slice-jobs/42',
});
// A project 3MF whose embedded printer matches a listed preset — the
// printer pre-pick lands on it, so selectedPrinterName === embedded and
// the "slice as designed" toggle is offered.
mockApi.getLibraryFilePlates.mockResolvedValue({
file_id: 100,
filename: 'Designed.3mf',
plates: [],
is_multi_plate: false,
embedded_printer: 'Bambu Lab X1 Carbon 0.4 nozzle',
embedded_process: '0.20mm Standard',
});
renderWithTracker({
source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
onClose,
});
const user = userEvent.setup();
const toggle = (await screen.findByLabelText(
/Use the file's built-in settings/,
)) as HTMLInputElement;
expect(toggle.checked).toBe(false);
// All preset dropdowns are live until the toggle is on, then bypassed —
// the printer included, so changing it can't silently drop the mode.
const printerSelect = presetSelects()[0];
const processSelect = presetSelects()[1];
expect(printerSelect.disabled).toBe(false);
expect(processSelect.disabled).toBe(false);
await user.click(toggle);
expect(printerSelect.disabled).toBe(true);
expect(processSelect.disabled).toBe(true);
await user.click(screen.getByRole('button', { name: /^Slice$/ }));
await waitFor(() => {
expect(mockApi.sliceLibraryFile).toHaveBeenCalledWith(
100,
expect.objectContaining({ use_embedded_settings: true }),
);
});
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('hides the embedded-settings toggle when the picked printer differs from the design (#2611)', async () => {
// Embedded target is a model with no matching preset in the listing, so
// the printer pre-pick falls back to the local default (Imported X1C),
// which does not match — honouring embedded settings would risk the
// wrong bed, so the toggle stays hidden.
mockApi.getLibraryFilePlates.mockResolvedValue({
file_id: 100,
filename: 'Designed.3mf',
plates: [],
is_multi_plate: false,
embedded_printer: 'Bambu Lab P1S 0.4 nozzle',
embedded_process: '0.20mm Standard',
});
renderWithTracker({
source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
onClose: vi.fn(),
});
await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
expect(screen.queryByLabelText(/Use the file's built-in settings/)).toBeNull();
});
it('includes bed_type in the request when the user picks a non-auto plate (#1337)', async () => {
const onClose = vi.fn();
mockApi.sliceLibraryFile.mockResolvedValue({
@@ -104,14 +104,15 @@ describe('SpoolBuddyLayout', () => {
it('suppresses the global toast viewport while mounted', () => {
const { unmount } = renderLayout();
// Visible viewport gets `hidden` class while the kiosk is up.
const viewport = document.querySelector('div.fixed.bottom-4.right-20');
// Visible viewport gets `hidden` class while the kiosk is up. Position is
// set via safe-area calc() (#2612) so match the stable data-testid.
const viewport = document.querySelector('[data-testid="toast-viewport"]');
expect(viewport?.className).toContain('hidden');
// Cleanup restores the viewport when the kiosk unmounts (e.g. user
// navigates back to the main app).
unmount();
const viewportAfter = document.querySelector('div.fixed.bottom-4.right-20');
const viewportAfter = document.querySelector('[data-testid="toast-viewport"]');
// After unmount the toast container is gone with the provider; the
// important guarantee is the suppression flag was untoggled, which the
// ToastContext tests pin directly. Here we only assert no crash on
@@ -118,8 +118,9 @@ describe('ToastContext viewport suppression', () => {
</ToastProvider>
);
// Toast viewport is the fixed-position container with bottom-4 right-20.
const findViewport = () => container.querySelector('div.fixed.bottom-4.right-20');
// Toast viewport is the fixed-position container; position is set via
// safe-area calc() (#2612) so match the stable data-testid, not classes.
const findViewport = () => container.querySelector('[data-testid="toast-viewport"]');
expect(findViewport()?.className).not.toContain('hidden');
act(() => {
@@ -140,4 +141,25 @@ describe('ToastContext viewport suppression', () => {
});
expect(findViewport()?.className).not.toContain('hidden');
});
it('caps every toast to the viewport width so it cannot run off-screen (#2612)', () => {
const { container, getByTestId } = render(
<ToastProvider>
<ViewportProbe />
</ToastProvider>
);
act(() => {
getByTestId('show-toast').click();
});
// The fixed-width dispatch toast (420px) overflowed the left edge of a
// phone in an installed PWA. Every toast now carries a viewport-relative
// max-width so it stays on-screen; pin it here.
const viewport = container.querySelector('[data-testid="toast-viewport"]');
const toast = viewport?.querySelector<HTMLElement>('div[style]');
expect(toast?.style.maxWidth).toContain('100vw');
expect(toast?.style.maxWidth).toContain('safe-area-inset-left');
expect(toast?.style.maxWidth).toContain('safe-area-inset-right');
});
});
+5
View File
@@ -1543,6 +1543,11 @@ export interface SliceRequest {
// "Textured PEI Plate", "Smooth PEI Plate", "Cool Plate (SuperTack)",
// "Supertack Plate".
bed_type?: string | null;
// "Slice as designed" (#2611). 3MF only: slice using the file's embedded
// project_settings.config (the designer's own wall count, infill, etc.)
// instead of the picked profile triplet. The preset refs above are still
// required by the backend validator but go unused on this path.
use_embedded_settings?: boolean;
}
// GET /api/v1/slicer/presets — unified listing across cloud / local / standard.
+61 -4
View File
@@ -220,6 +220,13 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
// user had no way to switch plates without cloning the preset.
const [bedType, setBedType] = useState<string | null>(null);
// "Slice as designed" (#2611). When on, the backend honours the source
// 3MF's embedded project_settings.config (the designer's own wall count,
// infill, etc.) instead of the picked process/filament profiles. Only
// offered when the picked printer matches the design's target model —
// see canUseEmbedded below.
const [useEmbedded, setUseEmbedded] = useState(false);
// Slicer Pipelines (#1425) — apply a saved preset bundle to all four slots
// with one pick, or save the current selection as a new pipeline.
const pipelinesQuery = useQuery({
@@ -380,6 +387,24 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
const embeddedPrinter = platesQuery.data?.embedded_printer ?? null;
const embeddedProcess = platesQuery.data?.embedded_process ?? null;
// "Slice as designed" is offered only when the source carries embedded
// settings (a real project 3MF, not an STL) AND the picked printer matches
// the design's target model. The match gate is load-bearing: honouring
// embedded settings for a different model would place the model on the
// wrong bed. Names come from the same preset namespace, so a normalised
// (strip "# " prefix, case-fold) equality is enough.
const canUseEmbedded = useMemo<boolean>(() => {
if (!embeddedPrinter || !embeddedProcess || !selectedPrinterName) return false;
const norm = (s: string) => s.replace(/^#\s*/, '').trim().toLowerCase();
return norm(selectedPrinterName) === norm(embeddedPrinter);
}, [embeddedPrinter, embeddedProcess, selectedPrinterName]);
// Drop back to profile slicing whenever the toggle stops being offered
// (e.g. the user switches to a printer that doesn't match the design).
useEffect(() => {
if (!canUseEmbedded) setUseEmbedded(false);
}, [canUseEmbedded]);
// Printer pre-pick: defaults to the printer the 3MF was prepared for when
// that preset is available, else the first listed printer. Runs once when
// presets first arrive; later re-renders preserve any manual choice.
@@ -476,6 +501,10 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
filament_presets: filamentPresets as PresetRef[],
...(plate != null ? { plate } : {}),
...(bedType != null ? { bed_type: bedType } : {}),
// The preset refs above are still sent (the backend validator requires
// them) but go unused when this flag is set — the slicer falls back on
// the file's embedded project_settings.config instead.
...(useEmbedded && canUseEmbedded ? { use_embedded_settings: true } : {}),
};
}
@@ -710,25 +739,53 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
data={presetsQuery.data}
value={printerPreset}
onChange={setPrinterPreset}
disabled={isEnqueuing}
// Locked in embedded mode too: the picked printer is unused on
// the embedded-settings path, and changing it away from the
// design's target would drop canUseEmbedded and yank the toggle
// out from under the user (#2611).
disabled={isEnqueuing || useEmbedded}
/>
{/* "Slice as designed" (#2611): honour the file's embedded
settings instead of the picked process/filament. Offered
only when the picked printer matches the design's target. */}
{canUseEmbedded && (
<label className="flex items-start gap-2 text-sm text-bambu-gray cursor-pointer select-none">
<input
type="checkbox"
checked={useEmbedded}
onChange={(e) => setUseEmbedded(e.target.checked)}
disabled={isEnqueuing}
className="mt-0.5 cursor-pointer"
/>
<span>
{t('slice.useEmbedded')}
<span className="block text-xs text-bambu-gray/70">
{t('slice.useEmbeddedHint')}
</span>
</span>
</label>
)}
<PresetDropdown
label={t('slice.process')}
slot="process"
data={presetsQuery.data}
value={processPreset}
onChange={setProcessPreset}
disabled={isEnqueuing}
disabled={isEnqueuing || useEmbedded}
selectedPrinterName={selectedPrinterName}
compatIndex={compatIndex}
/>
{/* Bed-type override (#1337). Always visible, always enabled.
The backend patches curr_bed_type on the resolved process
JSON before forwarding to the sidecar. */}
{/* Bed-type patches curr_bed_type onto the resolved process
JSON, which the embedded-settings path never sends — so it
has no effect there and is disabled to avoid implying it
does. */}
<BedTypeDropdown
value={bedType}
onChange={setBedType}
disabled={isEnqueuing}
disabled={isEnqueuing || useEmbedded}
/>
{/* Filament reqs may need a server-side preview-slice for
unsliced project files (single-pass, then cached). Show a
@@ -775,7 +832,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
return next;
})
}
disabled={isEnqueuing || !isUsed}
disabled={isEnqueuing || !isUsed || useEmbedded}
swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
selectedPrinterName={selectedPrinterName}
compatIndex={compatIndex}
+25 -4
View File
@@ -316,14 +316,32 @@ export function ToastProvider({ children }: { children: ReactNode }) {
{/* Toast Container — to the left of the bug-report bubble (bottom-4 right-4 w-12).
The kiosk layout suppresses this entire viewport so SpoolBuddy displays stay
free of main-app notifications. */}
<div className={`fixed bottom-4 right-20 z-[60] flex flex-col items-end gap-2 ${viewportSuppressed ? 'hidden' : ''}`}>
free of main-app notifications.
Position is set via safe-area-aware calc() rather than bottom-4/right-20 so an
installed PWA on a notched phone clears the home indicator / landscape notch
(#2612): the 5rem right offset keeps clearance for the bug bubble. */}
<div
data-testid="toast-viewport"
className={`fixed z-[60] flex flex-col items-end gap-2 ${viewportSuppressed ? 'hidden' : ''}`}
style={{
bottom: 'calc(1rem + env(safe-area-inset-bottom))',
right: 'calc(5rem + env(safe-area-inset-right))',
}}
>
{toasts.map((toast) => (
<div
key={toast.id}
className={`rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]} ${
toast.dispatchData ? 'w-[420px] p-3' : 'flex items-center gap-3 px-4 py-3'
}`}
// Cap width to the viewport so the fixed-width dispatch toast (420px)
// can't run off the left edge on a phone (#2612). At the cap the toast
// sits 1rem + safe-area from the left; on desktop the 420px wins. The
// 6rem = the 5rem right offset above + a 1rem left gutter.
style={{
maxWidth:
'calc(100vw - 6rem - env(safe-area-inset-left) - env(safe-area-inset-right))',
}}
data-testid={toast.dispatchData ? 'dispatch-toast-wrapper' : undefined}
>
{toast.dispatchData ? (
@@ -389,11 +407,14 @@ export function ToastProvider({ children }: { children: ReactNode }) {
data-testid={`dispatch-toast-job-${job.jobId}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-white truncate" title={job.sourceName}>
{/* min-w-0 + flex-1 lets truncate actually kick in
when the toast is capped to a phone's width
(#2612); the status chip stays put with shrink-0. */}
<span className="text-xs text-white truncate min-w-0 flex-1" title={job.sourceName}>
{job.sourceName}
</span>
<span
className="text-[11px] uppercase tracking-wide text-bambu-gray"
className="text-[11px] uppercase tracking-wide text-bambu-gray shrink-0"
data-testid={`dispatch-toast-status-${job.jobId}`}
>
{t(`dispatchToast.status.${job.status}`)}
+2
View File
@@ -4032,6 +4032,8 @@ export default {
refreshPresets: 'Aktualisieren',
refreshPresetsTitle: 'Profile neu laden — die aktuellen Cloud- und Bundle-Listen abrufen (nach dem Löschen eines Profils in Bambu Studio oder Bambu Handy verwenden)',
allPresetsRequired: 'Alle Profile müssen ausgewählt sein',
useEmbedded: 'Eingebettete Einstellungen der Datei verwenden',
useEmbeddedHint: 'So slicen, wie der Ersteller es angelegt hat (Wände, Füllung, Filament), statt mit den obigen Profilen. Verfügbar, weil dein Drucker zur Datei passt.',
enqueuing: 'Slice-Auftrag wird übermittelt…',
queued: 'In Warteschlange…',
failed: 'Slicen fehlgeschlagen. Logs des Slicer-Sidecars prüfen.',
+2
View File
@@ -4066,6 +4066,8 @@ export default {
refreshPresets: 'Refresh',
refreshPresetsTitle: 'Refresh presets — fetch the latest cloud and bundled listings (use after deleting a preset in Bambu Studio or Bambu Handy)',
allPresetsRequired: 'All presets must be selected',
useEmbedded: "Use the file's built-in settings",
useEmbeddedHint: "Slice it the way the designer set it up (walls, infill, filament) instead of the profiles above. Offered because your printer matches the file's.",
enqueuing: 'Submitting slice job…',
queued: 'Queued…',
failed: 'Slicing failed. Check the slicer sidecar logs.',
+2
View File
@@ -4035,6 +4035,8 @@ export default {
refreshPresets: 'Actualizar',
refreshPresetsTitle: 'Actualizar preajustes — recuperar los listados más recientes de la nube y los paquetes (úselo tras eliminar un preajuste en Bambu Studio o Bambu Handy)',
allPresetsRequired: 'Deben seleccionarse todos los preajustes',
useEmbedded: 'Usar la configuración incorporada del archivo',
useEmbeddedHint: 'Laminar tal como lo configuró el diseñador (perímetros, relleno, filamento) en lugar de los perfiles de arriba. Disponible porque tu impresora coincide con la del archivo.',
enqueuing: 'Enviando el trabajo de laminado…',
queued: 'En cola…',
failed: 'Error al laminar. Consulte los registros del contenedor auxiliar del laminador.',
+2
View File
@@ -4021,6 +4021,8 @@ export default {
refreshPresets: 'Actualiser',
refreshPresetsTitle: 'Actualiser les préréglages — récupérer les dernières listes Cloud et bundle (à utiliser après avoir supprimé un préréglage dans Bambu Studio ou Bambu Handy)',
allPresetsRequired: 'Tous les préréglages doivent être sélectionnés',
useEmbedded: 'Utiliser les réglages intégrés du fichier',
useEmbeddedHint: "Slicer tel que le concepteur l'a configuré (parois, remplissage, filament) au lieu des profils ci-dessus. Proposé car votre imprimante correspond à celle du fichier.",
enqueuing: 'Envoi du travail de découpage…',
queued: 'En file d\'attente…',
failed: 'Échec du découpage. Vérifiez les journaux du sidecar.',
+2
View File
@@ -4020,6 +4020,8 @@ export default {
refreshPresets: 'Aggiorna',
refreshPresetsTitle: 'Aggiorna i preset — recupera gli elenchi più recenti dal cloud e dai bundle (da usare dopo aver eliminato un preset in Bambu Studio o Bambu Handy)',
allPresetsRequired: 'Tutti i preset devono essere selezionati',
useEmbedded: 'Usa le impostazioni integrate del file',
useEmbeddedHint: 'Slicia come impostato dal designer (pareti, riempimento, filamento) invece dei profili sopra. Disponibile perché la tua stampante corrisponde a quella del file.',
enqueuing: 'Invio lavoro di slicing…',
queued: 'In coda…',
failed: 'Slicing fallito. Controlla i log del sidecar.',
+2
View File
@@ -4032,6 +4032,8 @@ export default {
refreshPresets: '再読み込み',
refreshPresetsTitle: 'プリセットを再取得 — クラウドとバンドルの最新リストを取得します(Bambu Studio または Bambu Handy でプリセットを削除した後にお使いください)',
allPresetsRequired: 'すべてのプリセットを選択する必要があります',
useEmbedded: 'ファイルに埋め込まれた設定を使用',
useEmbeddedHint: '上のプロファイルではなく、設計者が設定したとおり(ウォール、インフィル、フィラメント)にスライスします。お使いのプリンターがファイルと一致するため利用できます。',
enqueuing: 'スライスジョブを送信中…',
queued: '待機中…',
failed: 'スライスに失敗。サイドカーのログを確認してください。',
+2
View File
@@ -3821,6 +3821,8 @@ export default {
refreshPresets: '새로 고침',
refreshPresetsTitle: '프리셋 새로 고침 — 최신 클라우드 및 번들 목록 가져오기 (Bambu Studio 또는 Bambu Handy에서 프리셋 삭제 후 사용)',
allPresetsRequired: '모든 프리셋을 선택해야 합니다',
useEmbedded: '파일에 포함된 설정 사용',
useEmbeddedHint: '위 프로필 대신 디자이너가 설정한 대로(벽, 내부 채움, 필라멘트) 슬라이싱합니다. 프린터가 파일과 일치하여 사용할 수 있습니다.',
enqueuing: '슬라이싱 작업 제출 중…',
queued: '대기 중…',
failed: '슬라이싱 실패. 슬라이서 사이드카 로그를 확인하세요.',
+2
View File
@@ -4020,6 +4020,8 @@ export default {
refreshPresets: 'Atualizar',
refreshPresetsTitle: 'Atualizar predefinições — buscar as listagens mais recentes da nuvem e dos pacotes (use após excluir uma predefinição no Bambu Studio ou Bambu Handy)',
allPresetsRequired: 'Todas as predefinições devem ser selecionadas',
useEmbedded: 'Usar as configurações incorporadas do arquivo',
useEmbeddedHint: 'Fatiar como o designer configurou (paredes, preenchimento, filamento) em vez dos perfis acima. Disponível porque sua impressora corresponde à do arquivo.',
enqueuing: 'Enviando trabalho de fatiamento…',
queued: 'Na fila…',
failed: 'Falha ao fatiar. Verifique os logs do sidecar.',
+2
View File
@@ -4022,6 +4022,8 @@ export default {
refreshPresets: 'Yenile',
refreshPresetsTitle: "Ön ayarları yenile — en güncel bulut ve paketli listeleri getir (Bambu Studio veya Bambu Handy'de bir ön ayar sildikten sonra kullanın)",
allPresetsRequired: 'Tüm ön ayarlar seçilmelidir',
useEmbedded: 'Dosyanın yerleşik ayarlarını kullan',
useEmbeddedHint: 'Yukarıdaki profiller yerine tasarımcının ayarladığı gibi (duvarlar, dolgu, filament) dilimle. Yazıcınız dosyayla eşleştiği için sunuluyor.',
enqueuing: 'Dilimleme işi gönderiliyor…',
queued: 'Kuyrukta…',
failed: 'Dilimleme başarısız. Dilimleyici yardımcı bileşen günlüklerini kontrol edin.',
+2
View File
@@ -4020,6 +4020,8 @@ export default {
refreshPresets: '刷新',
refreshPresetsTitle: '刷新预设 — 获取最新的云端和打包配置列表(在 Bambu Studio 或 Bambu Handy 中删除预设后使用)',
allPresetsRequired: '必须选择所有预设',
useEmbedded: '使用文件的内置设置',
useEmbeddedHint: '按设计者的设置(壁、填充、耗材)切片,而非上方的配置文件。因您的打印机与文件匹配而可用。',
enqueuing: '提交切片任务中…',
queued: '已排队…',
failed: '切片失败。请检查切片器 sidecar 日志。',
+2
View File
@@ -4020,6 +4020,8 @@ export default {
refreshPresets: '重新整理',
refreshPresetsTitle: '重新整理預設 — 擷取最新的雲端與打包設定清單(在 Bambu Studio 或 Bambu Handy 中刪除預設後使用)',
allPresetsRequired: '必須選擇所有預設',
useEmbedded: '使用檔案的內建設定',
useEmbeddedHint: '依設計者的設定(外牆、填充、耗材)切片,而非上方的設定檔。因您的印表機與檔案相符而可用。',
enqueuing: '提交切片任務中…',
queued: '已排隊…',
failed: '切片失敗。請檢查切片器 sidecar 日誌。',