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:
Donkie committed 2026-10-11 11:39:55 +02:00
commit f7c3988fed
16 files changed
+2970 -2107

No files matched your search

+13 -13
View File
@@ -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
+78 -55
View File
@@ -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"
+1102 -744
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -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.",
+442 -414
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -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();
});
});
+4 -4
View File
@@ -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}
+37
View File
@@ -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' });
});
});
+10 -19
View File
@@ -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
+10
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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,
+48
View File
@@ -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()
Generated
+1119 -807
View File
File diff suppressed because it is too large. Load diff