mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-22 01:52:17 -05:00
Merge remote-tracking branch 'origin/master' into fix/1181-import-vendor-by-name
# Conflicts: # client_v2/src/lib/api/spoolSource.test.ts
This commit is contained in:
commit
f7c3988fed
16 files changed
+2970
-2107
No files matched your search
+13
-13
@@ -6,7 +6,7 @@ on:
|
||||
# Every branch builds, so a fix can be handed to a bug reporter as a Docker
|
||||
# image before it merges (see publish-images). Weblate pushes locale-only
|
||||
# commits constantly and dependabot's branches arrive as PRs anyway, so
|
||||
# neither is worth a full multi-arch run.
|
||||
# bot branches skip push runs and are built by the pull_request run instead.
|
||||
- "**"
|
||||
- "!weblate*"
|
||||
- "!dependabot/**"
|
||||
@@ -36,10 +36,10 @@ jobs:
|
||||
#
|
||||
style:
|
||||
# Same-repo branches already got a full run from the push trigger, so skip the
|
||||
# duplicate pull_request run. Forks still need it: a fork's push fires no event
|
||||
# here. Skipping the three jobs that have no `needs` cascades to every other
|
||||
# job, since a skipped dependency skips its dependents.
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository }}
|
||||
# duplicate pull_request run. Bot branches excluded above still need it, as do
|
||||
# forks, whose pushes fire no event here. Skipping the three jobs with no `needs`
|
||||
# cascades to every other job, since a skipped dependency skips its dependents.
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || startsWith(github.head_ref, 'dependabot/') || startsWith(github.head_ref, 'weblate') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -83,10 +83,10 @@ jobs:
|
||||
#
|
||||
build-client:
|
||||
# Same-repo branches already got a full run from the push trigger, so skip the
|
||||
# duplicate pull_request run. Forks still need it: a fork's push fires no event
|
||||
# here. Skipping the three jobs that have no `needs` cascades to every other
|
||||
# job, since a skipped dependency skips its dependents.
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository }}
|
||||
# duplicate pull_request run. Bot branches excluded above still need it, as do
|
||||
# forks, whose pushes fire no event here. Skipping the three jobs with no `needs`
|
||||
# cascades to every other job, since a skipped dependency skips its dependents.
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || startsWith(github.head_ref, 'dependabot/') || startsWith(github.head_ref, 'weblate') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -235,10 +235,10 @@ jobs:
|
||||
#
|
||||
build-tester:
|
||||
# Same-repo branches already got a full run from the push trigger, so skip the
|
||||
# duplicate pull_request run. Forks still need it: a fork's push fires no event
|
||||
# here. Skipping the three jobs that have no `needs` cascades to every other
|
||||
# job, since a skipped dependency skips its dependents.
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository }}
|
||||
# duplicate pull_request run. Bot branches excluded above still need it, as do
|
||||
# forks, whose pushes fire no event here. Skipping the three jobs with no `needs`
|
||||
# cascades to every other job, since a skipped dependency skips its dependents.
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || startsWith(github.head_ref, 'dependabot/') || startsWith(github.head_ref, 'weblate') }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
|
||||
@@ -4,20 +4,18 @@ name: Auto-merge Weblate PRs
|
||||
# which opens a pull request for every sync back to this repo (see guard-translations.yml). Those PRs
|
||||
# are machine-generated, always scoped to a locale tree (client_v2/locales/ today), and gated by the
|
||||
# full CI suite just like any other PR. Rather than hand-merging each one, this workflow turns on GitHub's native
|
||||
# auto-merge for Weblate-authored PRs so they merge themselves once all required status checks pass.
|
||||
# auto-merge once a week for Weblate-authored PRs so they merge themselves once all required status checks pass.
|
||||
#
|
||||
# Why auto-merge instead of giving Weblate direct push access to master:
|
||||
# - Every sync still runs through style/tests/build before it can land — nothing merges unvalidated.
|
||||
# - Every sync still runs through style/tests/build before it can land; nothing merges unvalidated.
|
||||
# - No actor gains unrestricted, un-CI'd direct write to master. A compromised Weblate token can only
|
||||
# open a PR; auto-merge fires solely on green CI for Weblate-authored PRs.
|
||||
# - GitHub write/bypass can't be scoped to locale files, so direct write would grant all-file access.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
# contents+pull-requests write let the built-in GITHUB_TOKEN enable auto-merge and perform the merge.
|
||||
# The token is not a ruleset bypass actor and doesn't need to be: with 0 required approvals it
|
||||
@@ -30,65 +28,23 @@ jobs:
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
# Only ever act on Weblate's own translation-sync PRs. Human PRs are never touched.
|
||||
# pull_request_target runs in the base-repo context (so GITHUB_TOKEN has write access even for
|
||||
# branch PRs), and this job is safe under it because it never checks out or executes PR code —
|
||||
# it only calls the GitHub API to enable auto-merge.
|
||||
if: github.event.pull_request.user.login == 'weblate'
|
||||
# Scheduled runs use the default-branch workflow. This job never checks out or executes
|
||||
# PR code; it only calls the GitHub API to inspect PRs and enable auto-merge or merge them.
|
||||
steps:
|
||||
# Defence in depth: don't trust the author alone, trust the diff. Even a compromised Weblate
|
||||
# token can only ever get auto-merged if the PR is confined to locale JSON files. Anything
|
||||
# else (a code file, a workflow, a non-.json file, a file outside the two locale trees)
|
||||
# fails this step, auto-merge is never enabled, and the PR waits for a human. The file list is
|
||||
# read from the API — this step never checks out or runs PR code, so pull_request_target is safe.
|
||||
# fails the check, auto-merge is never enabled, and the PR waits for a human. The file list is
|
||||
# read from the API; this step never checks out or runs PR code.
|
||||
#
|
||||
# Both locale trees are allowed. client_v2/locales/ is where translations go now; the legacy
|
||||
# client/public/locales/ tree stays on the list only so that a sync opened before its Weblate
|
||||
# component was locked isn't stranded half-merged. Locking the component, not this allowlist,
|
||||
# is what stops new legacy translations (see guard-translations.yml).
|
||||
- name: Verify PR only changes locale JSON files
|
||||
- name: Verify and merge open Weblate PRs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
files="$(gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/files" --jq '.[].filename')"
|
||||
|
||||
if [ -z "$files" ]; then
|
||||
echo "::error title=Empty diff::Weblate PR reports no changed files; refusing to auto-merge."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Every changed path must be a .json file under client_v2/locales/<lang>/ (active) or
|
||||
# client/public/locales/<lang>/ (legacy, being wound down).
|
||||
offending="$(printf '%s\n' "$files" \
|
||||
| grep -vE '^(client_v2/locales|client/public/locales)/[^/]+/.+\.json$' || true)"
|
||||
|
||||
if [ -n "$offending" ]; then
|
||||
echo "::error title=Out-of-scope Weblate PR::PR touches files outside the locale trees; not auto-merging."
|
||||
{
|
||||
echo "## ❌ Auto-merge blocked: out-of-scope changes"
|
||||
echo ""
|
||||
echo "This Weblate PR changes files that are **not** locale JSON under"
|
||||
echo "\`client_v2/locales/\` or \`client/public/locales/\`:"
|
||||
echo ""
|
||||
echo '```'
|
||||
echo "$offending"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Weblate sync PRs are only auto-merged when every changed file is a translation JSON"
|
||||
echo "file. This PR isn't, so it has been left for a maintainer to review and merge by hand."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All $(printf '%s\n' "$files" | wc -l) changed file(s) are locale JSON. Safe to auto-merge."
|
||||
|
||||
- name: Enable auto-merge
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
# Merge commit, NOT squash. This is load-bearing for Weblate, not a style preference.
|
||||
#
|
||||
# Weblate keeps its own clone of this repo and rebases it onto master to pick up our changes.
|
||||
@@ -110,4 +66,71 @@ jobs:
|
||||
# sees them as already upstream and drops them silently. Nothing else forces squash here:
|
||||
# the repo has allow_merge_commit enabled and the master ruleset carries no linear-history
|
||||
# rule, and human PRs are merge-committed too.
|
||||
run: gh pr merge --auto --merge "$PR_URL"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
prs="$(gh pr list --repo "$REPO" --author weblate --state open --limit 10000 --json number,url)"
|
||||
rows="$(printf '%s\n' "$prs" | jq -r '.[] | [.number, .url] | @tsv')"
|
||||
failed=0
|
||||
while IFS=$'\t' read -r PR_NUMBER PR_URL; do
|
||||
[ -n "$PR_NUMBER" ] || continue
|
||||
if ! files="$(gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/files" --jq '.[].filename')"; then
|
||||
echo "::error::Could not read changed files for $PR_URL; refusing to auto-merge."
|
||||
echo "## ❌ Auto-merge blocked: could not read files for $PR_URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -z "$files" ]; then
|
||||
echo "::error title=Empty diff::Weblate PR $PR_URL reports no changed files; refusing to auto-merge."
|
||||
echo "## ❌ Auto-merge blocked: empty diff for $PR_URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
|
||||
# Every changed path must be a .json file under client_v2/locales/<lang>/ (active) or
|
||||
# client/public/locales/<lang>/ (legacy, being wound down).
|
||||
offending="$(printf '%s\n' "$files" \
|
||||
| grep -vE '^(client_v2/locales|client/public/locales)/[^/]+/.+\.json$' || true)"
|
||||
|
||||
if [ -n "$offending" ]; then
|
||||
echo "::error title=Out-of-scope Weblate PR::$PR_URL touches files outside the locale trees; not auto-merging."
|
||||
{
|
||||
echo "## ❌ Auto-merge blocked: out-of-scope changes"
|
||||
echo ""
|
||||
echo "Weblate PR: $PR_URL"
|
||||
echo ""
|
||||
echo "This Weblate PR changes files that are **not** locale JSON under"
|
||||
echo "\`client_v2/locales/\` or \`client/public/locales/\`:"
|
||||
echo ""
|
||||
echo '```'
|
||||
echo "$offending"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "Weblate sync PRs are only auto-merged when every changed file is a translation JSON"
|
||||
echo "file. This PR isn't, so it has been left for a maintainer to review and merge by hand."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "$PR_URL: all $(printf '%s\n' "$files" | wc -l) changed file(s) are locale JSON. Safe to auto-merge."
|
||||
if merge_output="$(gh pr merge --repo "$REPO" --auto --merge "$PR_URL" 2>&1)"; then
|
||||
echo "$merge_output"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "$merge_output"
|
||||
# GitHub can refuse auto-merge when the PR is already ready to merge.
|
||||
if [[ "$merge_output" == *"clean status"* || "$merge_output" == *"already mergeable"* ]]; then
|
||||
if gh pr merge --repo "$REPO" --merge "$PR_URL"; then
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "::error::Could not merge $PR_URL."
|
||||
echo "## ❌ Auto-merge blocked: merge failed for $PR_URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
failed=1
|
||||
done <<< "$rows"
|
||||
|
||||
exit "$failed"
|
||||
Generated
+1102
-744
File diff suppressed because it is too large.
Load diff
@@ -532,7 +532,7 @@
|
||||
"settings.scanner.docs": "How to set up a tag reader",
|
||||
"settings.scanner.lastSeen": "{{when}} ago",
|
||||
"settings.scanner.pair": "Pair by tapping",
|
||||
"settings.scanner.reader.anyDesc": "Scans from any reader open spools in this web browser. You can pair with a specific one if you want finer control. This is also the reader that will be used for adding new tags to spools and filaments.",
|
||||
"settings.scanner.reader.anyDesc": "Scans from any reader open spools or filaments in this web browser. You can pair with a specific one if you want finer control. This is also the reader that will be used for adding new tags to spools and filaments.",
|
||||
"settings.scanner.reader.label": "Tag Reader",
|
||||
"settings.scanner.reader.pairedDesc": "Only scans from this reader open spools in this web browser.",
|
||||
"settings.scanner.recent": "Recent readers",
|
||||
@@ -611,7 +611,7 @@
|
||||
"tags.none": "No tags linked.",
|
||||
"tags.scan.openedFilament": "Tag scanned: opened filament {{name}}.",
|
||||
"tags.scan.openedSpool": "Tag scanned: opened spool #{{id}}, {{name}}.",
|
||||
"tags.scan.unknown": "Unknown tag {{uid}}. Open the spool it belongs to and use Add tag to link it.",
|
||||
"tags.scan.unknown": "Unknown tag {{uid}}. Open the spool or filament it belongs to and use Add tag to link it.",
|
||||
"tags.section": "Tags",
|
||||
"tags.unlink": "Unlink",
|
||||
"tags.unlinkBody": "Tag {{uid}} will stop identifying this spool. Nothing is written to the tag itself, so you can link it again — to this spool or another one — whenever you like.",
|
||||
|
||||
Generated
+442
-414
File diff suppressed because it is too large.
Load diff
@@ -121,3 +121,30 @@ describe('importExternalFilament', () => {
|
||||
expect(new URL(fetch.mock.calls[0][0]).searchParams.get('external_id')).toBe('"Prusa_PLA"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFilament', () => {
|
||||
it.each([undefined, 0, 0.5])('sends optional numeric values unchanged: %s', async (value) => {
|
||||
const fetch = vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.spool_weight).toBe(value);
|
||||
expect(body.price).toBe(value);
|
||||
expect(body.settings_extruder_temp).toBe(value);
|
||||
expect(body.settings_bed_temp).toBe(value);
|
||||
return Response.json({ id: 1, ...body });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
await spoolSource.createFilament({
|
||||
name: 'Test',
|
||||
vendorName: '',
|
||||
material: 'PLA',
|
||||
density: 1.24,
|
||||
diameter: 1.75,
|
||||
colors: [],
|
||||
spoolWeight: value,
|
||||
price: value,
|
||||
nozzleTemp: value,
|
||||
bedTemp: value
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -338,10 +338,10 @@ class HttpSpoolSource {
|
||||
density: draft.density,
|
||||
diameter: draft.diameter,
|
||||
weight: draft.weight || undefined,
|
||||
spool_weight: draft.spoolWeight || undefined,
|
||||
settings_extruder_temp: draft.nozzleTemp || undefined,
|
||||
settings_bed_temp: draft.bedTemp || undefined,
|
||||
price: draft.price || undefined,
|
||||
spool_weight: draft.spoolWeight,
|
||||
settings_extruder_temp: draft.nozzleTemp,
|
||||
settings_bed_temp: draft.bedTemp,
|
||||
price: draft.price,
|
||||
article_number: draft.articleNumber || undefined,
|
||||
comment: draft.comment || undefined,
|
||||
extra: draft.extra && Object.keys(draft.extra).length ? draft.extra : undefined,
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { ExternalSearch } from '$lib/api/externalSearch.svelte';
|
||||
import { roundGrams, weightAuto } from '$lib/utils/format';
|
||||
import { numErr } from '$lib/utils/validate';
|
||||
import { parseDecimal } from '$lib/utils/numeric';
|
||||
import {
|
||||
FILAMENT_ADVANCED_KEYS,
|
||||
FILAMENT_FIELD_ORDER,
|
||||
@@ -277,9 +278,9 @@
|
||||
chosen = c;
|
||||
netWeight = String(cWeight(c) || 1000);
|
||||
const sw = cSpoolWeight(c);
|
||||
spoolWeight = sw ? String(sw) : '';
|
||||
spoolWeight = sw != null ? String(sw) : '';
|
||||
const p = cPrice(c);
|
||||
price = p ? String(p) : '';
|
||||
price = p != null ? String(p) : '';
|
||||
resetSpoolForm();
|
||||
step = 2;
|
||||
}
|
||||
@@ -382,24 +383,24 @@
|
||||
}
|
||||
|
||||
const n = countN;
|
||||
const net = Number(netWeight) || 0;
|
||||
const spool = Number(spoolWeight) || 0;
|
||||
const net = parseDecimal(netWeight) ?? 0;
|
||||
const spool = parseDecimal(spoolWeight) ?? 0;
|
||||
const body: Record<string, unknown> = {
|
||||
filament_id: filamentId,
|
||||
initial_weight: Number(netWeight) || undefined,
|
||||
spool_weight: Number(spoolWeight) || undefined,
|
||||
price: parseFloat(price) || undefined,
|
||||
initial_weight: parseDecimal(netWeight) ?? undefined,
|
||||
spool_weight: parseDecimal(spoolWeight) ?? undefined,
|
||||
price: parseDecimal(price) ?? undefined,
|
||||
location: location.trim() || undefined,
|
||||
lot_nr: lot.trim() || undefined,
|
||||
comment: comment.trim() || undefined
|
||||
};
|
||||
if (fillMode === 'used') body.used_weight = Number(fillWeight) || 0;
|
||||
else if (fillMode === 'remaining') body.remaining_weight = Number(fillWeight) || 0;
|
||||
else if (fillMode === 'measured')
|
||||
if (fillMode === 'used') body.used_weight = parseDecimal(fillWeight) ?? undefined;
|
||||
else if (fillMode === 'remaining') body.remaining_weight = parseDecimal(fillWeight) ?? undefined;
|
||||
else if (fillMode === 'measured' && parseDecimal(fillWeight) !== null)
|
||||
// Rounded, because this subtraction is where float dust gets born: weighing a full
|
||||
// 1000 g spool on a 128.11 g core gives 1000 + 128.11 − 1128.11 = 2.3e-13, and a
|
||||
// spool created with that much used is not an unused spool any more (#986).
|
||||
body.used_weight = roundGrams(Math.max(0, net + spool - (Number(fillWeight) || 0)));
|
||||
body.used_weight = roundGrams(Math.max(0, net + spool - (parseDecimal(fillWeight) ?? 0)));
|
||||
if (firstUsed) body.first_used = firstUsed;
|
||||
if (lastUsed) body.last_used = lastUsed;
|
||||
if (Object.keys(extraValues).length) body.extra = extraValues;
|
||||
@@ -440,9 +441,9 @@
|
||||
// Cross-check the fill amount against the net/empty-spool weights above.
|
||||
// Only applies the upper bound when those weights are themselves valid so
|
||||
// we don't cascade an unrelated error into this field.
|
||||
const netN = Number(netWeight);
|
||||
const netValid = netWeight.trim() !== '' && Number.isFinite(netN) && netN > 0;
|
||||
const spoolN = Number.isFinite(Number(spoolWeight)) ? Number(spoolWeight) : 0;
|
||||
const netN = parseDecimal(netWeight);
|
||||
const netValid = netN !== null && netN > 0;
|
||||
const spoolN = parseDecimal(spoolWeight) ?? 0;
|
||||
if (fillMode === 'measured') {
|
||||
// Weight on the scale = filament left + empty spool, so it can be at most
|
||||
// net+spool (full) and at least the empty-spool weight (nothing left).
|
||||
@@ -725,8 +726,8 @@
|
||||
<button
|
||||
type="button"
|
||||
class="preset"
|
||||
class:on={Number(netWeight) === preset}
|
||||
aria-pressed={Number(netWeight) === preset}
|
||||
class:on={parseDecimal(netWeight) === preset}
|
||||
aria-pressed={parseDecimal(netWeight) === preset}
|
||||
onclick={() => (netWeight = String(preset))}>{weightAuto(preset)}</button
|
||||
>
|
||||
{/each}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { emptyFilamentDraft, filamentDraftFrom, filamentWeightsFrom, toNewFilamentDraft } from './draft';
|
||||
import { mapFilament } from '$lib/api/map';
|
||||
|
||||
describe('filament numeric drafts', () => {
|
||||
it.each([
|
||||
['', undefined],
|
||||
['0', 0],
|
||||
['0,5', 0.5],
|
||||
['garbage', undefined]
|
||||
])('preserves optional numeric input %j as %s', (raw, expected) => {
|
||||
const draft = { ...emptyFilamentDraft(), density: '1,24', nozzleTemp: raw, bedTemp: raw };
|
||||
const result = toNewFilamentDraft(draft, { weight: '1000,5', spoolWeight: raw, price: raw }, {});
|
||||
expect(result).toMatchObject({
|
||||
density: 1.24,
|
||||
diameter: 1.75,
|
||||
weight: 1000.5,
|
||||
spoolWeight: expected,
|
||||
price: expected,
|
||||
nozzleTemp: expected,
|
||||
bedTemp: expected
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps zero values when duplicating a filament', () => {
|
||||
const filament = mapFilament({
|
||||
id: 1,
|
||||
weight: 1000,
|
||||
spool_weight: 0,
|
||||
price: 0,
|
||||
settings_extruder_temp: 0,
|
||||
settings_bed_temp: 0
|
||||
});
|
||||
expect(filamentWeightsFrom(filament)).toEqual({ weight: '1000', spoolWeight: '0', price: '0' });
|
||||
expect(filamentDraftFrom(filament, '')).toMatchObject({ nozzleTemp: '0', bedTemp: '0' });
|
||||
});
|
||||
});
|
||||
@@ -80,8 +80,8 @@ export function filamentDraftFrom(f: Filament, vendorName: string): FilamentDraf
|
||||
multiColorDirection: undefined,
|
||||
density: String(f.density),
|
||||
diameter: String(f.diameter),
|
||||
nozzleTemp: f.nozzleTemp ? String(f.nozzleTemp) : '',
|
||||
bedTemp: f.bedTemp ? String(f.bedTemp) : '',
|
||||
nozzleTemp: f.nozzleTemp != null ? String(f.nozzleTemp) : '',
|
||||
bedTemp: f.bedTemp != null ? String(f.bedTemp) : '',
|
||||
articleNumber: '',
|
||||
comment: f.comment
|
||||
};
|
||||
@@ -91,8 +91,8 @@ export function filamentDraftFrom(f: Filament, vendorName: string): FilamentDraf
|
||||
export function filamentWeightsFrom(f: Filament): FilamentWeights {
|
||||
return {
|
||||
weight: String(f.weight || 1000),
|
||||
spoolWeight: f.spoolWeight ? String(f.spoolWeight) : '',
|
||||
price: f.price ? String(f.price) : ''
|
||||
spoolWeight: f.spoolWeight != null ? String(f.spoolWeight) : '',
|
||||
price: f.price != null ? String(f.price) : ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,15 +178,6 @@ function num(v: string): number | undefined {
|
||||
return parseDecimal(v) ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same, but treating zero as "not given". The weight/price trio is optional on a
|
||||
* filament and a recorded zero says nothing a missing value doesn't, so a blank
|
||||
* and a typed 0 are sent identically rather than pinning the record to 0.
|
||||
*/
|
||||
function numPos(v: string): number | undefined {
|
||||
return num(v) || undefined;
|
||||
}
|
||||
|
||||
/** Turn a validated draft into the body {@link spoolSource.createFilament} expects. */
|
||||
export function toNewFilamentDraft(
|
||||
draft: FilamentDraft,
|
||||
@@ -200,14 +191,14 @@ export function toNewFilamentDraft(
|
||||
// Both are required and > 0 by the time this runs, so the fallbacks below are
|
||||
// only there to keep the types honest.
|
||||
density: num(draft.density) ?? 0,
|
||||
diameter: numPos(draft.diameter) ?? Number(DEFAULT_DIAMETER),
|
||||
weight: numPos(weights.weight),
|
||||
spoolWeight: numPos(weights.spoolWeight),
|
||||
diameter: num(draft.diameter) ?? Number(DEFAULT_DIAMETER),
|
||||
weight: num(weights.weight) || undefined,
|
||||
spoolWeight: num(weights.spoolWeight),
|
||||
colors: draft.colors,
|
||||
multiColorDirection: draft.multiColorDirection,
|
||||
nozzleTemp: numPos(draft.nozzleTemp),
|
||||
bedTemp: numPos(draft.bedTemp),
|
||||
price: numPos(weights.price),
|
||||
nozzleTemp: num(draft.nozzleTemp),
|
||||
bedTemp: num(draft.bedTemp),
|
||||
price: num(weights.price),
|
||||
articleNumber: draft.articleNumber.trim() || undefined,
|
||||
comment: draft.comment.trim() || undefined,
|
||||
extra
|
||||
|
||||
@@ -6,6 +6,16 @@ import { filterDecimalInsertion, isPartialDecimal, normalizeDecimal, parseDecima
|
||||
// number may reach the API.
|
||||
|
||||
describe('parseDecimal', () => {
|
||||
it.each([
|
||||
['', undefined],
|
||||
[' ', undefined],
|
||||
['0', 0],
|
||||
['0,5', 0.5],
|
||||
['garbage', undefined]
|
||||
])('converts optional API input %j to %s', (raw, expected) => {
|
||||
expect(parseDecimal(raw) ?? undefined).toBe(expected);
|
||||
});
|
||||
|
||||
it('reads a decimal point', () => {
|
||||
expect(parseDecimal('1.75')).toBe(1.75);
|
||||
});
|
||||
|
||||
+33
-32
@@ -2,51 +2,52 @@
|
||||
# uv export --no-dev -o requirements.txt --no-annotate --no-hashes
|
||||
aiomysql==0.3.2
|
||||
aiosqlite==0.22.1
|
||||
alembic==1.18.5
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.7.0
|
||||
anyio==4.14.1
|
||||
alembic==1.20.0
|
||||
annotated-doc==0.0.5
|
||||
annotated-types==0.8.0
|
||||
anyio==4.15.1
|
||||
anysqlite==0.0.5
|
||||
async-timeout==5.0.1 ; python_full_version < '3.11'
|
||||
asyncpg==0.31.0
|
||||
certifi==2026.6.17
|
||||
cffi==2.0.0 ; platform_python_implementation != 'PyPy'
|
||||
click==8.4.2
|
||||
colorama==0.4.6 ; sys_platform == 'win32'
|
||||
cryptography==50.0.0
|
||||
asyncpg==0.32.0
|
||||
certifi==2026.7.22
|
||||
cffi==2.1.1 ; platform_python_implementation != 'PyPy'
|
||||
click==8.5.0
|
||||
cryptography==50.0.2
|
||||
exceptiongroup==1.3.1 ; python_full_version < '3.11'
|
||||
fastapi==0.139.0
|
||||
greenlet==3.5.3
|
||||
fastapi==0.143.0
|
||||
greenlet==3.5.6
|
||||
h11==0.16.0
|
||||
hishel==0.1.5
|
||||
httpcore==1.0.9
|
||||
httptools==0.8.0 ; platform_machine != 'armv7l'
|
||||
httptools==0.9.0 ; platform_machine != 'armv7l'
|
||||
httpx==0.28.1
|
||||
idna==3.18
|
||||
idna==3.20
|
||||
influxdb-client==1.50.0
|
||||
mako==1.3.12
|
||||
markupsafe==3.0.3
|
||||
msgpack==1.2.1
|
||||
platformdirs==4.10.0
|
||||
prometheus-client==0.25.0
|
||||
psycopg2-binary==2.9.12
|
||||
pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
|
||||
pydantic==2.13.4
|
||||
pydantic-core==2.46.4
|
||||
pymysql==1.2.0
|
||||
mako==1.4.3
|
||||
markupsafe==3.0.4
|
||||
msgpack==1.2.3
|
||||
opentelemetry-api==1.45.1
|
||||
platformdirs==4.12.4 ; python_full_version < '3.11'
|
||||
platformdirs==4.13.0 ; python_full_version >= '3.11'
|
||||
prometheus-client==0.26.0
|
||||
psycopg2-binary==2.9.13
|
||||
pycparser==3.11 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
|
||||
pydantic==2.14.0
|
||||
pydantic-core==2.50.0
|
||||
pymysql==1.2.3
|
||||
python-dateutil==2.9.0.post0
|
||||
reactivex==5.1.0
|
||||
scheduler==0.8.11
|
||||
setuptools==83.0.0
|
||||
six==1.17.0
|
||||
sqlalchemy==2.0.51
|
||||
sqlalchemy==2.0.54
|
||||
sqlalchemy-cockroachdb==2.0.4
|
||||
starlette==1.3.1
|
||||
tomli==2.4.1 ; python_full_version < '3.11'
|
||||
typeguard==4.5.2
|
||||
starlette==1.7.0
|
||||
tomli==2.5.0 ; python_full_version < '3.11'
|
||||
typeguard==4.6.0
|
||||
typing-extensions==4.16.0
|
||||
typing-inspection==0.4.2
|
||||
urllib3==2.7.0
|
||||
uvicorn==0.50.1
|
||||
uvloop==0.22.1 ; platform_machine != 'armv7l' and platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
|
||||
typing-inspection==0.4.4
|
||||
urllib3==2.8.0
|
||||
uvicorn==0.54.0
|
||||
uvloop==0.23.0 ; platform_machine != 'armv7l' and platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
|
||||
websockets==15.0.1
|
||||
@@ -131,7 +131,7 @@ class RenameLocationBody(BaseModel):
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/location/{location}",
|
||||
"/location/{location:path}",
|
||||
name="Rename location",
|
||||
description="Rename a spool location. All spools in this location will be moved to the new location.",
|
||||
response_model_exclude_none=True,
|
||||
|
||||
@@ -169,3 +169,51 @@ test("a filament's extra fields can be filled in while creating it", async ({ pa
|
||||
await expect(page.getByText(fieldKey, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("a typed zero tare overrides the filament's spool weight", async ({ page, request }) => {
|
||||
const API_BASE =
|
||||
process.env.SPOOLMAN_API_URL ??
|
||||
`${process.env.SPOOLMAN_BASE_URL ?? "http://localhost:8001"}/api/v1`;
|
||||
const filamentName = unique("ZeroTare");
|
||||
const created = await request.post(`${API_BASE}/filament`, {
|
||||
data: {
|
||||
name: filamentName,
|
||||
material: "PLA",
|
||||
density: 1.24,
|
||||
diameter: 1.75,
|
||||
weight: 1000,
|
||||
spool_weight: 200,
|
||||
price: 20,
|
||||
},
|
||||
});
|
||||
expect(created.ok()).toBeTruthy();
|
||||
const filament = await created.json();
|
||||
let spoolId: number | undefined;
|
||||
|
||||
try {
|
||||
await openApp(page);
|
||||
const dialog = await openAddSpoolModal(page);
|
||||
await dialog.locator("input.search-big").fill(filamentName);
|
||||
await dialog.locator(".res-item").filter({ hasText: filamentName }).first().click();
|
||||
await numberField(dialog, "Spool Weight").fill("0");
|
||||
await numberField(dialog, "Price").fill("0");
|
||||
const saved = page.waitForResponse(
|
||||
(r) => r.request().method() === "POST" && new URL(r.url()).pathname.endsWith("/spool"),
|
||||
);
|
||||
await dialog.getByRole("button", { name: "Add 1 spool", exact: true }).click();
|
||||
const response = await saved;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
spoolId = (await response.json()).id;
|
||||
await expect(dialog).toBeHidden();
|
||||
|
||||
const stored = await request.get(`${API_BASE}/spool/${spoolId}`);
|
||||
expect(stored.ok()).toBeTruthy();
|
||||
expect(await stored.json()).toMatchObject({
|
||||
spool_weight: 0,
|
||||
price: 0,
|
||||
});
|
||||
} finally {
|
||||
if (spoolId !== undefined) await request.delete(`${API_BASE}/spool/${spoolId}`);
|
||||
await request.delete(`${API_BASE}/filament/${filament.id}`);
|
||||
}
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import uuid
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -177,3 +178,29 @@ def test_rename_location_rejects_too_long_a_value():
|
||||
"""The location column is 64 characters, so an over-long value is a 400 and not a crash."""
|
||||
result = httpx.patch(rename_url("location"), json={"value": "Bottom", "new_value": "x" * 65})
|
||||
assert result.status_code == 400
|
||||
|
||||
|
||||
def test_rename_location_with_slash(random_filament: dict[str, Any]):
|
||||
"""Renaming a slash-containing location moves every spool to the new location."""
|
||||
old_location = "Shelf 1/2"
|
||||
new_location = "Shelf 3/4"
|
||||
spool_ids = []
|
||||
for location in (old_location, old_location, "Other shelf"):
|
||||
result = httpx.post(
|
||||
f"{URL}/api/v1/spool",
|
||||
json={"filament_id": random_filament["id"], "location": location},
|
||||
)
|
||||
result.raise_for_status()
|
||||
spool_ids.append(result.json()["id"])
|
||||
|
||||
try:
|
||||
result = httpx.patch(
|
||||
f"{URL}/api/v1/location/{quote(old_location, safe='')}",
|
||||
json={"name": new_location},
|
||||
)
|
||||
result.raise_for_status()
|
||||
assert result.json() == new_location
|
||||
assert [_spool(spool_id)["location"] for spool_id in spool_ids] == [new_location, new_location, "Other shelf"]
|
||||
finally:
|
||||
for spool_id in spool_ids:
|
||||
httpx.delete(f"{URL}/api/v1/spool/{spool_id}").raise_for_status()
|
||||
Reference in new issue
Block a user