Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
This commit is contained in:
@@ -359,8 +359,11 @@ async def export_measurements_csv(
|
|||||||
# Format decimals with configured separator
|
# Format decimals with configured separator
|
||||||
# #118: quota non misurata dalla visione -> NaN
|
# #118: quota non misurata dalla visione -> NaN
|
||||||
value_str = "NaN" if m.value is None else str(m.value).replace(".", decimal_separator)
|
value_str = "NaN" if m.value is None else str(m.value).replace(".", decimal_separator)
|
||||||
|
# #124: quota NaN (value None) -> deviazione NaN, non vuota
|
||||||
deviation_str = (
|
deviation_str = (
|
||||||
str(m.deviation).replace(".", decimal_separator)
|
"NaN"
|
||||||
|
if m.value is None
|
||||||
|
else str(m.deviation).replace(".", decimal_separator)
|
||||||
if m.deviation is not None
|
if m.deviation is not None
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
|
|||||||
neutralise_csv_cell(subtask.description) if subtask else "",
|
neutralise_csv_cell(subtask.description) if subtask else "",
|
||||||
number(subtask.nominal) if subtask else "",
|
number(subtask.nominal) if subtask else "",
|
||||||
number(measurement.value) if measurement.value is not None else "NaN", # #118
|
number(measurement.value) if measurement.value is not None else "NaN", # #118
|
||||||
number(measurement.deviation),
|
"NaN" if measurement.value is None else number(measurement.deviation), # #124: quota NaN -> deviazione NaN
|
||||||
measurement.pass_fail,
|
measurement.pass_fail,
|
||||||
number(subtask.ltl) if subtask else "",
|
number(subtask.ltl) if subtask else "",
|
||||||
number(subtask.lwl) if subtask else "",
|
number(subtask.lwl) if subtask else "",
|
||||||
|
|||||||
@@ -379,3 +379,55 @@ class TestMeasurementValueNullableMigration:
|
|||||||
assert value_nullable() is False
|
assert value_nullable() is False
|
||||||
command.upgrade(cfg, "head") # e di nuovo su
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
assert value_nullable() is True
|
assert value_nullable() is True
|
||||||
|
|
||||||
|
def test_022_keeps_rows(self, sqlite_database_url):
|
||||||
|
"""#125: la ricostruzione della tabella conserva righe, somma dei value e indici."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
command.upgrade(cfg, "021_task_acquisition")
|
||||||
|
|
||||||
|
def indexes(con):
|
||||||
|
return sorted(r[0] for r in con.execute(
|
||||||
|
"select name from sqlite_master where type='index' and tbl_name='measurements'"
|
||||||
|
))
|
||||||
|
|
||||||
|
con = sqlite3.connect(db_path)
|
||||||
|
# Colonne NOT NULL senza default riempite per tipo (stessa idea di mig_check.py)
|
||||||
|
vals = {}
|
||||||
|
for _cid, name, typ, notnull, dflt, pk in con.execute("pragma table_info(measurements)").fetchall():
|
||||||
|
if pk or not notnull or dflt is not None:
|
||||||
|
continue
|
||||||
|
t = typ.upper()
|
||||||
|
vals[name] = (
|
||||||
|
1 if "INT" in t
|
||||||
|
else 1.5 if ("DEC" in t or "FLOAT" in t or "NUM" in t)
|
||||||
|
else "2026-01-01 00:00:00" if ("DATE" in t or "TIME" in t)
|
||||||
|
else "x"
|
||||||
|
)
|
||||||
|
vals["pass_fail"] = "pass"
|
||||||
|
vals["value"] = 12.5
|
||||||
|
sql = f"insert into measurements ({','.join(vals)}) values ({','.join('?' * len(vals))})"
|
||||||
|
for _ in range(3):
|
||||||
|
con.execute(sql, list(vals.values()))
|
||||||
|
con.commit()
|
||||||
|
before_idx = indexes(con)
|
||||||
|
before = con.execute("select count(*), sum(value) from measurements").fetchone()
|
||||||
|
con.close()
|
||||||
|
assert before == (3, 37.5)
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
|
||||||
|
con = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
assert con.execute("select count(*), sum(value) from measurements").fetchone() == before
|
||||||
|
assert indexes(con) == before_idx
|
||||||
|
assert con.execute("pragma integrity_check").fetchone()[0] == "ok"
|
||||||
|
vals["value"] = None
|
||||||
|
vals["pass_fail"] = "fail"
|
||||||
|
con.execute(sql, list(vals.values())) # una riga NaN (value NULL) ora e' accettata
|
||||||
|
con.commit()
|
||||||
|
assert con.execute("select count(*) from measurements where value is null").fetchone()[0] == 1
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
|||||||
@@ -111,4 +111,4 @@ async def test_csv_export_writes_nan(
|
|||||||
row = by_id[str(nan.id)]
|
row = by_id[str(nan.id)]
|
||||||
assert row[header.index("value")] == "NaN"
|
assert row[header.index("value")] == "NaN"
|
||||||
assert row[header.index("pass_fail")] == "fail"
|
assert row[header.index("pass_fail")] == "fail"
|
||||||
assert row[header.index("deviation")] == ""
|
assert row[header.index("deviation")] == "NaN" # #124
|
||||||
|
|||||||
@@ -836,3 +836,31 @@ async def test_close_without_measurements_writes_no_file(
|
|||||||
)).json()
|
)).json()
|
||||||
assert closed["statistics_path"] is None
|
assert closed["statistics_path"] is None
|
||||||
assert not (tmp_path / "statistics").exists()
|
assert not (tmp_path / "statistics").exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_statistics_csv_writes_nan_value_and_deviation(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
"""#124: una quota NaN (value NULL) esce NaN sia nel valore sia nella deviazione."""
|
||||||
|
from src.backend.services import measurement_service, production_export_service
|
||||||
|
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-NAN")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-NAN")
|
||||||
|
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||||||
|
subtask = await _first_subtask(db_session, recipe.id)
|
||||||
|
await measurement_service.save_measurement(
|
||||||
|
db_session, subtask_id=subtask.id, version_id=opened["version_id"],
|
||||||
|
measured_by=measurement_tec_user.id, value=None, input_method="camera",
|
||||||
|
production_run_id=opened["id"], enforce_tolerance_gate=False,
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
run = (await db_session.execute(
|
||||||
|
select(ProductionRun).where(ProductionRun.id == opened["id"])
|
||||||
|
)).scalar_one()
|
||||||
|
|
||||||
|
text = await production_export_service.build_statistics_csv(db_session, run)
|
||||||
|
lines = [line for line in text.lstrip("").splitlines() if line.strip()]
|
||||||
|
header = lines[0].split(",")
|
||||||
|
row = lines[1].split(",")
|
||||||
|
assert row[header.index("value")] == "NaN"
|
||||||
|
assert row[header.index("deviation")] == "NaN"
|
||||||
|
|||||||
@@ -216,10 +216,11 @@ async def test_a_non_finite_output_is_saved_as_nan(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_a_nan_quote_still_blocks_the_next_acquisition(
|
async def test_a_nan_quote_needs_supervisor_authorisation(
|
||||||
client, db_session, measurement_tec_user, worker_says,
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
):
|
):
|
||||||
"""#118: la quota NaN e' un fail come gli altri, il blocco di tolleranza resta."""
|
"""#125: la quota NaN e' un fail come gli altri e chiede il capoturno (pending_authorisation);
|
||||||
|
la prossima acquisizione non e' bloccata."""
|
||||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
worker_says({"t1.radius": GOOD})
|
worker_says({"t1.radius": GOOD})
|
||||||
@@ -230,6 +231,15 @@ async def test_a_nan_quote_still_blocks_the_next_acquisition(
|
|||||||
)
|
)
|
||||||
assert blocking is not None and blocking.subtask_id == first.id and blocking.value is None
|
assert blocking is not None and blocking.subtask_id == first.id and blocking.value is None
|
||||||
|
|
||||||
|
# #125: una seconda acquisizione sullo stesso task risponde 200, non e' bloccata
|
||||||
|
again = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
assert again.status_code == 200, again.text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_check_lists_a_missing_quote_as_nan(
|
async def test_check_lists_a_missing_quote_as_nan(
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ function visionExecute() {
|
|||||||
preview: false, // result on screen is an analysis only, not saved
|
preview: false, // result on screen is an analysis only, not saved
|
||||||
saved: false, // «Registra» done: the saved result replaces the preview
|
saved: false, // «Registra» done: the saved result replaces the preview
|
||||||
lastBlob: null, // last frame shown, the one «Registra» saves
|
lastBlob: null, // last frame shown, the one «Registra» saves
|
||||||
|
recordedBlob: null, // #123: frame saved by the last successful «Registra»: not recordable twice
|
||||||
paused: false, // operator paused the in-line loop
|
paused: false, // operator paused the in-line loop
|
||||||
registering: false, // «Registra» in progress: the loop stands still
|
registering: false, // «Registra» in progress: the loop stands still
|
||||||
leaving: false, // page is being left: no more requests
|
leaving: false, // page is being left: no more requests
|
||||||
@@ -43,8 +44,10 @@ function visionExecute() {
|
|||||||
// #120: «Rimisura»/nuovo ciclo (task-result-reset): il pezzo va registrato di nuovo, anche senza camera
|
// #120: «Rimisura»/nuovo ciclo (task-result-reset): il pezzo va registrato di nuovo, anche senza camera
|
||||||
window.addEventListener('task-result-reset', () => {
|
window.addEventListener('task-result-reset', () => {
|
||||||
this.saved = false;
|
this.saved = false;
|
||||||
this.preview = !!this.result;
|
// #123: il frame gia' registrato non e' piu' un'anteprima ne' si registra due volte
|
||||||
this.kick();
|
this.preview = !!this.result && this.lastBlob !== this.recordedBlob;
|
||||||
|
// #123: camera impulsiva = frame nuovo subito (come all'apertura); in linea riparte il ciclo
|
||||||
|
if (this.hasCamera && !this.inLinea) this.grab(); else this.kick();
|
||||||
});
|
});
|
||||||
if (!this.hasCamera) return;
|
if (!this.hasCamera) return;
|
||||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); });
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); });
|
||||||
@@ -58,7 +61,7 @@ function visionExecute() {
|
|||||||
get hasCamera() { return !!(window.__visionCfg || {}).cameraAvailable; },
|
get hasCamera() { return !!(window.__visionCfg || {}).cameraAvailable; },
|
||||||
get inLinea() { return this.hasCamera && (window.__visionCfg || {}).modo === 'in_linea'; },
|
get inLinea() { return this.hasCamera && (window.__visionCfg || {}).modo === 'in_linea'; },
|
||||||
// «Registra» needs a frame on screen that is not saved yet
|
// «Registra» needs a frame on screen that is not saved yet
|
||||||
get canRecord() { return !!this.lastBlob && !this.saved && !this.registering; },
|
get canRecord() { return !!this.lastBlob && this.lastBlob !== this.recordedBlob && !this.saved && !this.registering; }, // #123: non sul frame gia' registrato
|
||||||
|
|
||||||
shouldRun() {
|
shouldRun() {
|
||||||
return this.inLinea && !this.paused && !this.saved && !this.registering
|
return this.inLinea && !this.paused && !this.saved && !this.registering
|
||||||
@@ -145,6 +148,7 @@ function visionExecute() {
|
|||||||
if (!this.error && this.result && this.result.vision_result_id) {
|
if (!this.error && this.result && this.result.vision_result_id) {
|
||||||
this.preview = false;
|
this.preview = false;
|
||||||
this.saved = true;
|
this.saved = true;
|
||||||
|
this.recordedBlob = blob; // #123: questo frame e' registrato, Registra resta spento finche' non ne arriva uno nuovo
|
||||||
this._emitRecorded(); // #120: la pagina conta le quote registrate
|
this._emitRecorded(); // #120: la pagina conta le quote registrate
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -249,15 +249,15 @@ var VisionOverlay = (function () {
|
|||||||
var pt = null;
|
var pt = null;
|
||||||
for (var i = 0; i < order.length && !pt; i++) pt = anchor(order[i]);
|
for (var i = 0; i < order.length && !pt; i++) pt = anchor(order[i]);
|
||||||
if (!pt) return;
|
if (!pt) return;
|
||||||
// #118: valore null/undefined/vuoto = quota non misurata (Number(null) e' 0): nessuna etichetta
|
// #118: valore null/undefined/vuoto/non finito = quota non misurata (Number(null) e' 0): etichetta NaN, status della riga (fail)
|
||||||
if (r.value === null || r.value === undefined || r.value === '' || typeof r.value === 'boolean') return;
|
var v = (r.value === null || r.value === undefined || r.value === '' || typeof r.value === 'boolean')
|
||||||
var v = Number(r.value);
|
? NaN : Number(r.value);
|
||||||
if (!isFinite(v)) return;
|
var txt = isFinite(v) ? v.toFixed(3) : 'NaN';
|
||||||
out.push({
|
out.push({
|
||||||
key: r.key,
|
key: r.key,
|
||||||
x: pt.x,
|
x: pt.x,
|
||||||
y: pt.y,
|
y: pt.y,
|
||||||
text: r.name + ' ' + v.toFixed(3) + ' ' + (r.unit || ''),
|
text: r.name + ' ' + txt + ' ' + (r.unit || ''),
|
||||||
status: r.status,
|
status: r.status,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -224,17 +224,18 @@ const rows = fixture.measurements.map((m) => {
|
|||||||
check('labels: testo name + valore a 3 decimali + unit', f[0].text === 'T 1.500 mm', f[0].text);
|
check('labels: testo name + valore a 3 decimali + unit', f[0].text === 'T 1.500 mm', f[0].text);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- #118: labels, valore null/undefined/non finito -> nessuna etichetta ---
|
// --- #118: labels, valore null/undefined/non finito -> etichetta NaN con status della riga ---
|
||||||
{
|
{
|
||||||
const ov = [{ type: 'segment', role: 'found', tool_id: 't', row1: 10, col1: 10, row2: 10, col2: 20 }];
|
const ov = [{ type: 'segment', role: 'found', tool_id: 't', row1: 10, col1: 10, row2: 10, col2: 20 }];
|
||||||
const mk = (value) => ({ key: 1, name: 'T', value, unit: 'mm', status: 'fail', vision_output: 't.value' });
|
const mk = (value) => ({ key: 1, name: 'T', value, unit: 'mm', status: 'fail', vision_output: 't.value' });
|
||||||
check('labels: value null -> nessuna etichetta (Number(null) e\' 0)', VisionOverlay.labels([mk(null)], ov).length === 0);
|
const nan = (v) => { const l = VisionOverlay.labels([mk(v)], ov); return l.length === 1 && l[0].text === 'T NaN mm' && l[0].status === 'fail'; };
|
||||||
check('labels: value undefined -> nessuna', VisionOverlay.labels([mk(undefined)], ov).length === 0);
|
check('labels: value null -> etichetta NaN, status fail (Number(null) e 0)', nan(null));
|
||||||
check('labels: value NaN/Infinity -> nessuna',
|
check('labels: value undefined / stringa vuota -> etichetta NaN', nan(undefined) && nan(''));
|
||||||
VisionOverlay.labels([mk(NaN), mk(Infinity), mk(-Infinity)], ov).length === 0);
|
check('labels: value NaN/Infinity -> etichetta NaN', nan(NaN) && nan(Infinity) && nan(-Infinity));
|
||||||
check('labels: value 0 resta un valore valido', VisionOverlay.labels([mk(0)], ov).length === 1);
|
check('labels: value 0 resta un valore valido', VisionOverlay.labels([mk(0)], ov)[0].text === 'T 0.000 mm');
|
||||||
const misto = VisionOverlay.labels([{ ...mk(null), key: 1 }, { ...mk(2.5), key: 2 }], ov);
|
const misto = VisionOverlay.labels([{ ...mk(null), key: 1 }, { ...mk(2.5), key: 2 }], ov);
|
||||||
check('labels: null saltata, le altre righe restano', misto.length === 1 && misto[0].key === 2);
|
check('labels: null e valida entrambe presenti', misto.length === 2 && misto[0].text === 'T NaN mm' && misto[1].key === 2);
|
||||||
|
check('labels: riga NaN senza ancoraggio -> nessuna etichetta', VisionOverlay.labels([mk(null)], []).length === 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- #121: svg(overlay, opts) ---
|
// --- #121: svg(overlay, opts) ---
|
||||||
|
|||||||
@@ -175,6 +175,26 @@ c5.acquire = async function () { this.result = { vision_result_id: 7, measuremen
|
|||||||
h();
|
h();
|
||||||
check('#120 task-result-reset: saved = false', c5.saved === false);
|
check('#120 task-result-reset: saved = false', c5.saved === false);
|
||||||
|
|
||||||
|
// #123: dopo Registra + task-result-reset Registra e' spento sul frame registrato, anteprima spenta, impulsiva = grab
|
||||||
|
win.__visionCfg = { cameraAvailable: true, modo: 'impulsiva', subtasks: [] };
|
||||||
|
const c9 = ctx.visionExecute();
|
||||||
|
let grabs = 0;
|
||||||
|
c9.grab = async function () { grabs++; };
|
||||||
|
c9.lastBlob = { name: 'f1' };
|
||||||
|
c9.acquire = async function () { this.result = { vision_result_id: 11, measurements: okMs }; };
|
||||||
|
check('#123 prima di Registra: canRecord', c9.canRecord === true);
|
||||||
|
await c9.record();
|
||||||
|
check('#123 dopo Registra: canRecord falso (saved)', c9.canRecord === false && c9.recordedBlob === c9.lastBlob);
|
||||||
|
c9.$nextTick = () => {}; // #123: nel test nessun grab di apertura
|
||||||
|
c9.init(); // registra l'ascolto di task-result-reset
|
||||||
|
grabs = 0;
|
||||||
|
(listeners['task-result-reset'] || []).slice(-1)[0]();
|
||||||
|
check('#123 reset sul frame registrato: canRecord falso', c9.canRecord === false && c9.saved === false);
|
||||||
|
check('#123 reset sul frame registrato: niente badge anteprima', c9.preview === false);
|
||||||
|
check('#123 reset in impulsiva: un solo grab', grabs === 1, String(grabs));
|
||||||
|
c9.lastBlob = { name: 'f2' };
|
||||||
|
check('#123 frame nuovo: canRecord vero', c9.canRecord === true);
|
||||||
|
|
||||||
// #119 #122: saveName = titolo del task (nome proposto da <tmf-viewer save-name>), 'immagine' se manca
|
// #119 #122: saveName = titolo del task (nome proposto da <tmf-viewer save-name>), 'immagine' se manca
|
||||||
win.__visionCfg = { taskName: 'Roto3', subtasks: [] };
|
win.__visionCfg = { taskName: 'Roto3', subtasks: [] };
|
||||||
check('#122 saveName = nome del task', ctx.visionExecute().saveName === 'Roto3');
|
check('#122 saveName = nome del task', ctx.visionExecute().saveName === 'Roto3');
|
||||||
|
|||||||
Reference in New Issue
Block a user