Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
// Behaviour test for static/js/task-results.js (#53 #54 #55), plain node, no framework.
|
||||
// Run: node tests/js/test_task_results.js (exit code != 0 on failure)
|
||||
const assert = require('assert');
|
||||
const { taskResultForm } = require('../../static/js/task-results.js');
|
||||
|
||||
const calls = [];
|
||||
let responses = [];
|
||||
global.window = {
|
||||
__taskResultsCfg: {
|
||||
saveUrl: '/s', listUrl: '/l', uploadUrl: '/u', taskUrlTpl: '/t/0',
|
||||
labels: { nokNeedsNote: 'Per un NOK serve la nota' },
|
||||
},
|
||||
sessionStorage: { _d: {}, getItem(k) { return this._d[k] || null; }, setItem(k, v) { this._d[k] = v; } },
|
||||
};
|
||||
global.FormData = class { constructor() { this.d = []; } append(k, v, n) { this.d.push([k, v, n]); } };
|
||||
global.fetch = async (url, opts) => {
|
||||
calls.push({ url, opts });
|
||||
const r = responses.shift() || { status: 200, body: {} };
|
||||
return { ok: r.status < 400, status: r.status, json: async () => r.body };
|
||||
};
|
||||
|
||||
function comp(runId) {
|
||||
const c = taskResultForm();
|
||||
c.ctx = () => ({ runId, done() { c._done = (c._done || 0) + 1; } });
|
||||
return c;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// check: NOK without note is refused before any request
|
||||
let c = comp(4);
|
||||
c.initForTask({ id: 30, task_type: 'check' });
|
||||
responses = [{ status: 200, body: [] }]; // loadLast
|
||||
await c.submit('nok');
|
||||
assert.strictEqual(c.forms[0].error, 'Per un NOK serve la nota');
|
||||
assert.ok(!calls.some((x) => x.url === '/s'), 'no POST for a NOK without note');
|
||||
|
||||
// check: OK goes to the server with the run id, then hands over
|
||||
calls.length = 0;
|
||||
responses = [{ status: 201, body: { id: 1, outcome: 'ok' } }];
|
||||
await c.submit('ok');
|
||||
const post = calls.find((x) => x.url === '/s');
|
||||
const body = JSON.parse(post.opts.body);
|
||||
assert.deepStrictEqual([body.task_id, body.production_run_id, body.outcome, body.values], [30, 4, 'ok', null]);
|
||||
assert.strictEqual(c._done, 1);
|
||||
assert.strictEqual(c.forms[0].result.outcome, 'ok');
|
||||
|
||||
// server 422: the detail is shown, nothing is handed over
|
||||
c = comp(4);
|
||||
responses = [{ status: 200, body: [] }, { status: 422, body: { detail: 'Per un NOK serve la nota' } }];
|
||||
c.initForTask({ id: 30, task_type: 'check' }); // consumes the first one (loadLast)
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
c.forms[0].note = 'x';
|
||||
await c.submit('nok');
|
||||
assert.strictEqual(c.forms[0].error, 'Per un NOK serve la nota');
|
||||
assert.ok(!c._done);
|
||||
|
||||
// note with fields: defaults, upload of the attachment, values + attachments in the result
|
||||
calls.length = 0;
|
||||
c = comp(null);
|
||||
const task = { id: 31, task_type: 'note', fields: [
|
||||
{ key: 'peso', label: 'Peso', type: 'number', default: '1,5', attachment: false },
|
||||
{ key: 'foto', label: 'Foto', type: 'text', default: null, attachment: true },
|
||||
] };
|
||||
c.initForTask(task);
|
||||
assert.strictEqual(c.forms[0].values.peso, '1,5');
|
||||
c.forms[0].files.foto = { name: 'a.png' };
|
||||
responses = [
|
||||
{ status: 201, body: { file_path: 'general/a.png' } },
|
||||
{ status: 201, body: { id: 2, outcome: 'done' } },
|
||||
];
|
||||
await c.submit('done');
|
||||
const saved = JSON.parse(calls.find((x) => x.url === '/s').opts.body);
|
||||
assert.deepStrictEqual(saved.values, { peso: '1.5' });
|
||||
assert.deepStrictEqual(saved.attachments, [{ key: 'foto', path: 'general/a.png', name: 'a.png' }]);
|
||||
assert.strictEqual(saved.outcome, 'done');
|
||||
assert.strictEqual(saved.production_run_id, null);
|
||||
|
||||
// non numeric value in a number field is refused locally
|
||||
c = comp(null);
|
||||
c.initForTask(task);
|
||||
c.forms[0].values.peso = 'abc';
|
||||
await c.submit('done');
|
||||
assert.ok(c.forms[0].error.indexOf('Peso') !== -1);
|
||||
|
||||
// a note without fields has no panel
|
||||
c = comp(null);
|
||||
c.initForTask({ id: 33, task_type: 'note', fields: [] });
|
||||
assert.strictEqual(c.forms.length, 0);
|
||||
|
||||
// end of production: tasks already recorded in the run are skipped; the rest recorded then handed over
|
||||
calls.length = 0;
|
||||
c = comp(4);
|
||||
responses = [
|
||||
{ status: 200, body: [{ task_id: 40 }] }, // results of the run
|
||||
{ status: 200, body: { id: 40, title: 'A', task_type: 'note', fields: [] } },
|
||||
{ status: 200, body: { id: 41, title: 'B', task_type: 'note', fields: [{ key: 'k', label: 'K', type: 'text', default: null, attachment: false }] } },
|
||||
];
|
||||
await c.openClose([40, 41]);
|
||||
assert.strictEqual(c.forms[0].recorded, true);
|
||||
assert.strictEqual(c.forms[1].recorded, false);
|
||||
assert.ok(c.open && !c._done);
|
||||
c.forms[1].values.k = 'ok';
|
||||
responses = [{ status: 201, body: { id: 5, outcome: 'done' } }];
|
||||
await c.submitClose();
|
||||
assert.strictEqual(c._done, 1);
|
||||
assert.strictEqual(c.open, false);
|
||||
assert.strictEqual(calls.filter((x) => x.url === '/s').length, 1, 'only the pending task is posted');
|
||||
|
||||
console.log('ok');
|
||||
})().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,205 @@
|
||||
"""gitea:Adriano/TieMeasureFlow#53 #54 #55 - operator side of the check / note results.
|
||||
|
||||
The backend (task-results, in_loop, close_task_ids) lives in another unit: api_client
|
||||
is mocked and the tests work against the contract written in the issues.
|
||||
"""
|
||||
import io
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import api_get_router
|
||||
from tests.test_template_js_syntax import (
|
||||
_INLINE_SCRIPT_RX, _check_alpine_attributes, _force_italian, _node_check,
|
||||
)
|
||||
|
||||
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
RECIPE = {
|
||||
"id": 3, "code": "REC-1", "name": "Ricetta",
|
||||
"measurement_interval_minutes": 30, "allow_manual_input": True,
|
||||
"current_version": {"id": 5, "version_number": 1},
|
||||
}
|
||||
|
||||
|
||||
def _task(task_id, task_type, **extra):
|
||||
task = {
|
||||
"id": task_id, "recipe_id": 3, "version_id": 5, "task_type": task_type,
|
||||
"title": "Task d'esempio", "directive": None, "description": None,
|
||||
"file_path": None, "file_type": None, "annotations_json": None,
|
||||
"subtasks": [], "fields": None,
|
||||
}
|
||||
task.update(extra)
|
||||
return task
|
||||
|
||||
|
||||
def _render(client, mock_api_client, monkeypatch, task):
|
||||
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||
mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]}
|
||||
mock_api_client.get.side_effect = api_get_router({
|
||||
f"/api/tasks/{task['id']}": task,
|
||||
"/api/recipes/3/tasks": [{"id": task["id"], "order_index": 0}],
|
||||
"/api/recipes/3": RECIPE,
|
||||
})
|
||||
return client.get(f"/measure/execute/{task['id']}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_check_task_has_ok_nok_buttons(logged_in_client, mock_api_client, monkeypatch):
|
||||
resp = _render(logged_in_client, mock_api_client, monkeypatch, _task(30, "check"))
|
||||
html = resp.get_data(as_text=True)
|
||||
assert resp.status_code == 200
|
||||
assert "submit('ok')" in html and "submit('nok')" in html
|
||||
assert "taskResultForm()" in html
|
||||
assert "/measure/api/task-results" in html
|
||||
assert "js/task-results.js" in html
|
||||
|
||||
|
||||
def test_note_with_fields_shows_field_inputs(logged_in_client, mock_api_client, monkeypatch):
|
||||
fields = [
|
||||
{"key": "peso", "label": "Peso", "type": "number", "default": "1.5", "attachment": False},
|
||||
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
|
||||
]
|
||||
task = _task(31, "note", fields=fields)
|
||||
html = _render(logged_in_client, mock_api_client, monkeypatch, task).get_data(as_text=True)
|
||||
# the fields travel to the page in the task data, the inputs are drawn from them
|
||||
assert '"key": "peso"' in html or '"key":"peso"' in html
|
||||
assert 'x-model="f.values[fld.key]"' in html
|
||||
assert "pickFile(f, fld.key, $event)" in html
|
||||
assert "submit('done')" in html
|
||||
|
||||
|
||||
def test_close_panel_and_modal_hook_present(logged_in_client, mock_api_client, monkeypatch):
|
||||
html = _render(logged_in_client, mock_api_client, monkeypatch, _task(32, "check")).get_data(as_text=True)
|
||||
assert "task-close-open" in html
|
||||
assert "closeTasksRecorded()" in html
|
||||
assert "close_task_ids" in html
|
||||
assert "/measure/api/tasks/0" in html
|
||||
|
||||
|
||||
@pytest.mark.parametrize("task", [
|
||||
_task(30, "check"),
|
||||
_task(31, "note", fields=[{"key": "n", "label": "L'etichetta", "type": "text",
|
||||
"default": None, "attachment": True}]),
|
||||
])
|
||||
def test_inline_js_and_alpine_attributes_valid(logged_in_client, mock_api_client, monkeypatch, task):
|
||||
_force_italian(logged_in_client)
|
||||
html = _render(logged_in_client, mock_api_client, monkeypatch, task).get_data(as_text=True)
|
||||
scripts = _INLINE_SCRIPT_RX.findall(html)
|
||||
assert scripts
|
||||
for i, body in enumerate(scripts):
|
||||
_node_check(body, f"/measure/execute check-note script[{i}]")
|
||||
_check_alpine_attributes(html, "/measure/execute check-note")
|
||||
|
||||
|
||||
def test_plain_note_keeps_old_footer(logged_in_client, mock_api_client, monkeypatch):
|
||||
"""A note without fields has no result panel: it is still the plain «Completato»."""
|
||||
html = _render(logged_in_client, mock_api_client, monkeypatch, _task(33, "note")).get_data(as_text=True)
|
||||
assert "goToNextTask()" in html
|
||||
# the panel markup is in the page either way; hasResultPanel (false here) decides
|
||||
assert "hasResultPanel" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Proxies
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_task_result_post_is_forwarded(logged_in_client, mock_api_client):
|
||||
mock_api_client.post.return_value = {"id": 9, "outcome": "nok"}
|
||||
body = {
|
||||
"task_id": 30, "production_run_id": 4, "outcome": "nok", "note": "graffio",
|
||||
"values": None, "attachments": None, "ignored": "x",
|
||||
}
|
||||
resp = logged_in_client.post("/measure/api/task-results", json=body)
|
||||
assert resp.status_code == 201
|
||||
assert resp.get_json()["id"] == 9
|
||||
args, kwargs = mock_api_client.post.call_args
|
||||
assert args[0] == "/api/task-results"
|
||||
assert kwargs["data"] == {
|
||||
"task_id": 30, "production_run_id": 4, "outcome": "nok", "note": "graffio",
|
||||
"values": None, "attachments": None,
|
||||
}
|
||||
|
||||
|
||||
def test_task_result_422_detail_is_passed_through(logged_in_client, mock_api_client):
|
||||
mock_api_client.post.return_value = {
|
||||
"error": True, "status_code": 422, "detail": "Per un NOK serve la nota",
|
||||
}
|
||||
resp = logged_in_client.post(
|
||||
"/measure/api/task-results", json={"task_id": 30, "outcome": "nok"})
|
||||
assert resp.status_code == 422
|
||||
assert resp.get_json()["detail"] == "Per un NOK serve la nota"
|
||||
|
||||
|
||||
def test_task_result_requires_task_id(logged_in_client, mock_api_client):
|
||||
resp = logged_in_client.post("/measure/api/task-results", json={"outcome": "ok"})
|
||||
assert resp.status_code == 400
|
||||
mock_api_client.post.assert_not_called()
|
||||
|
||||
|
||||
def test_task_results_list_forwards_filters(logged_in_client, mock_api_client):
|
||||
mock_api_client.get.return_value = [{"id": 1, "task_id": 30}]
|
||||
resp = logged_in_client.get("/measure/api/task-results?production_run_id=4&task_id=30")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == [{"id": 1, "task_id": 30}]
|
||||
args, kwargs = mock_api_client.get.call_args
|
||||
assert args[0] == "/api/task-results"
|
||||
assert kwargs["params"] == {"production_run_id": "4", "task_id": "30"}
|
||||
|
||||
|
||||
def test_attachment_upload_forwards_multipart(logged_in_client, mock_api_client):
|
||||
mock_api_client.post.return_value = {"file_path": "general/foto.png", "file_type": "image/png"}
|
||||
resp = logged_in_client.post(
|
||||
"/measure/api/attachments",
|
||||
data={"file": (io.BytesIO(b"\x89PNG fake"), "foto.png", "image/png")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.get_json()["file_path"] == "general/foto.png"
|
||||
args, kwargs = mock_api_client.post.call_args
|
||||
assert args[0] == "/api/files/upload"
|
||||
name, content, mime = kwargs["files"]["file"]
|
||||
assert name == "foto.png" and mime == "image/png" and content.startswith(b"\x89PNG")
|
||||
|
||||
|
||||
def test_attachment_upload_requires_file(logged_in_client, mock_api_client):
|
||||
resp = logged_in_client.post("/measure/api/attachments", data={})
|
||||
assert resp.status_code == 400
|
||||
mock_api_client.post.assert_not_called()
|
||||
|
||||
|
||||
def test_attachment_upload_error_detail_is_passed_through(logged_in_client, mock_api_client):
|
||||
mock_api_client.post.return_value = {"error": True, "status_code": 400, "detail": "File type not allowed"}
|
||||
resp = logged_in_client.post(
|
||||
"/measure/api/attachments",
|
||||
data={"file": (io.BytesIO(b"x"), "a.exe", "application/octet-stream")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json()["detail"] == "File type not allowed"
|
||||
|
||||
|
||||
def test_get_task_proxy(logged_in_client, mock_api_client):
|
||||
mock_api_client.get.return_value = {"id": 40, "task_type": "note", "fields": []}
|
||||
resp = logged_in_client.get("/measure/api/tasks/40")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["id"] == 40
|
||||
assert mock_api_client.get.call_args[0][0] == "/api/tasks/40"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Component logic (plain node, stubbed fetch)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_task_results_component_js():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node non installato: test JS di task-results.js saltato")
|
||||
script = Path(__file__).resolve().parent / "js" / "test_task_results.js"
|
||||
result = subprocess.run(
|
||||
[node, str(script)], cwd=str(FLASK_APP_DIR),
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
Reference in New Issue
Block a user