update readme and add icons
This commit is contained in:
167
docs/building.md
Normal file
167
docs/building.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Building
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**Python** ≥ 3.10
|
||||
**Node.js** ≥ 18, **npm** ≥ 9
|
||||
|
||||
### First-time setup
|
||||
|
||||
```bash
|
||||
# Install Python dependencies
|
||||
pip install -e .
|
||||
|
||||
# Install frontend dependencies
|
||||
npm install
|
||||
|
||||
# For running tests
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# For building desktop executables
|
||||
pip install -e ".[desktop]"
|
||||
```
|
||||
|
||||
Using a virtual environment is recommended:
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -e ".[dev,desktop]"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
Two servers run in development: the Vite frontend dev server and the Python backend. Vite proxies all API and WebSocket requests to the backend, so you only open the Vite URL in your browser.
|
||||
|
||||
```bash
|
||||
# Terminal 1 — Python backend (http://127.0.0.1:8188)
|
||||
npm run backend
|
||||
|
||||
# Terminal 2 — Vite frontend with hot-reload
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open the URL printed by Vite (typically `http://localhost:5173`).
|
||||
|
||||
Changes to Python files take effect after restarting the backend. Changes to frontend files hot-reload automatically.
|
||||
|
||||
---
|
||||
|
||||
## Web deployment
|
||||
|
||||
Build the frontend bundle and serve the backend:
|
||||
|
||||
```bash
|
||||
# Build frontend to frontend/dist/
|
||||
npm run build
|
||||
|
||||
# Start the server (serves the built frontend at /)
|
||||
python -m backend.main
|
||||
```
|
||||
|
||||
The server listens on `http://127.0.0.1:8188` by default. It serves the built frontend from `frontend/dist/` and exposes the REST + WebSocket API.
|
||||
|
||||
The web mode is a multi-session server: each browser tab gets its own session and isolated file workspace. Local filesystem access is disabled (users upload files through the browser).
|
||||
|
||||
---
|
||||
|
||||
## Desktop app
|
||||
|
||||
The desktop app uses pywebview to embed the frontend in a native window. The Python server runs in a background thread; the app picks a free port automatically.
|
||||
|
||||
```bash
|
||||
# Build frontend + launch desktop app
|
||||
npm run desktop
|
||||
```
|
||||
|
||||
This is equivalent to `npm run build && python desktop.py`.
|
||||
|
||||
In desktop mode `allow_local_filesystem=True`, which means:
|
||||
- Users can open files directly from their filesystem
|
||||
- The plugin system is enabled (see [plugins.md](plugins.md))
|
||||
|
||||
---
|
||||
|
||||
## Building desktop executables
|
||||
|
||||
The build scripts use PyInstaller to produce a self-contained executable. They build the frontend first, then package everything (Python runtime, backend, `frontend/dist/`, `demo/`) into a single distributable.
|
||||
|
||||
### macOS
|
||||
|
||||
Produces a `.app` bundle and a `.dmg` installer.
|
||||
|
||||
```bash
|
||||
npm run build:mac
|
||||
# Output: desktop-dist/tono.dmg
|
||||
```
|
||||
|
||||
Options:
|
||||
```bash
|
||||
bash scripts/build-mac.sh --onefile # Single executable instead of bundle
|
||||
bash scripts/build-mac.sh --no-dmg # Skip DMG creation
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Produces a `.tar.gz` archive containing the app directory.
|
||||
|
||||
```bash
|
||||
npm run build:linux
|
||||
# Output: desktop-dist/tono-linux.tar.gz
|
||||
```
|
||||
|
||||
Options:
|
||||
```bash
|
||||
bash scripts/build-linux.sh --onefile # Single executable
|
||||
bash scripts/build-linux.sh --no-tar # Skip archive creation
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
Produces a `tono.exe` inside an output folder.
|
||||
|
||||
```powershell
|
||||
npm run build:windows
|
||||
# Output: desktop-dist\tono\tono.exe
|
||||
```
|
||||
|
||||
Options:
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts\build-windows.ps1 -OneFile
|
||||
```
|
||||
|
||||
> **Note:** Run the build scripts from the repo root. They expect a `.venv` at the repo root; if not found, they fall back to the system `python` / `python3`.
|
||||
|
||||
---
|
||||
|
||||
## Runtime data directories
|
||||
|
||||
| Mode | Directory |
|
||||
|---|---|
|
||||
| Development | Repo root (`input/`, `output/`, `plugins/`) |
|
||||
| macOS packaged | `~/Library/Application Support/tono/` |
|
||||
| Linux packaged | `~/.local/share/tono/` |
|
||||
| Windows packaged | `%LOCALAPPDATA%\tono\` |
|
||||
|
||||
Override with the `TONO_APPDATA` environment variable:
|
||||
|
||||
```bash
|
||||
TONO_APPDATA=/my/data/dir python desktop.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key npm scripts summary
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `npm run dev` | Start Vite dev server + Python backend |
|
||||
| `npm run backend` | Start Python backend only |
|
||||
| `npm run build` | Build frontend to `frontend/dist/` |
|
||||
| `npm run preview` | Preview the production frontend build |
|
||||
| `npm run desktop` | Build frontend + launch desktop app |
|
||||
| `npm run build:mac` | Build macOS `.dmg` |
|
||||
| `npm run build:linux` | Build Linux `.tar.gz` |
|
||||
| `npm run build:windows` | Build Windows `.exe` |
|
||||
331
docs/plugins.md
Normal file
331
docs/plugins.md
Normal file
@@ -0,0 +1,331 @@
|
||||
# Writing plugins
|
||||
|
||||
Plugins are plain Python files dropped into the `plugins/` directory. Each file registers one or more nodes that appear in the Add Node menu immediately — no restart required if uploaded via UI upload.
|
||||
|
||||
A complete, annotated example is at [plugins/example_normalize.py](../plugins/example_normalize.py).
|
||||
|
||||
> **Note:** The plugin system is enabled on native desktop builds and disabled on web deployments by default. Override with the `TONO_PLUGINS=1` environment variable.
|
||||
|
||||
---
|
||||
|
||||
## Minimal plugin
|
||||
|
||||
```python
|
||||
# plugins/my_filter.py
|
||||
import numpy as np
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
@register_node(display_name="My Filter")
|
||||
class MyFilter:
|
||||
CATEGORY = "Plugins"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (("DATA_FIELD", "result"),)
|
||||
FUNCTION = "process"
|
||||
|
||||
def process(self, field: DataField, strength: float) -> tuple:
|
||||
scaled = field.data * strength
|
||||
return (field.replace(data=scaled),)
|
||||
```
|
||||
|
||||
Drop this file into `plugins/` and the node appears under **Plugins → My Filter** in the Add Node menu.
|
||||
|
||||
---
|
||||
|
||||
## Node class attributes
|
||||
|
||||
| Attribute | Required | Description |
|
||||
|---|---|---|
|
||||
| `INPUT_TYPES` | Yes | Classmethod returning `{"required": {...}, "optional": {...}}` |
|
||||
| `OUTPUTS` | Yes | Tuple of `(type, name)` or `(type, name, meta)` entries |
|
||||
| `FUNCTION` | Yes | Name of the method to call on execution |
|
||||
| `CATEGORY` | No | Menu category; defaults to `"Unsorted"` if omitted |
|
||||
| `DESCRIPTION` | No | Human-readable description shown in the UI |
|
||||
| `OUTPUT_NODE` | No | Set `True` to mark this node as a terminal output node |
|
||||
| `MANUAL_TRIGGER` | No | Set `True` to require the user to click Run manually |
|
||||
|
||||
---
|
||||
|
||||
## Input types
|
||||
|
||||
### Data types (socket connections)
|
||||
|
||||
These appear as connectable sockets on the node. They cannot be set inline by the user — they must be wired from another node.
|
||||
|
||||
| Type string | Python type received | Description |
|
||||
|---|---|---|
|
||||
| `"DATA_FIELD"` | `DataField` | 2D spatial/height data with physical metadata |
|
||||
| `"IMAGE"` | `np.ndarray` (uint8) | Greyscale (H×W) or RGB (H×W×3) image or mask |
|
||||
| `"LINE"` | `LineData` | 1D profile data with optional X axis and units |
|
||||
| `"RECORD_TABLE"` | `RecordTable` (list of dicts) | Named scalar measurements |
|
||||
| `"MESH_MODEL"` | `MeshModel` | 3D triangle mesh |
|
||||
|
||||
### Widget types (inline controls)
|
||||
|
||||
These appear as UI controls on the node body. They can also be connected from another node's output socket.
|
||||
|
||||
#### FLOAT
|
||||
|
||||
```python
|
||||
"sigma": ("FLOAT", {
|
||||
"default": 1.0,
|
||||
"min": 0.0, # optional
|
||||
"max": 10.0, # optional
|
||||
"step": 0.1, # optional, default step for dragging
|
||||
})
|
||||
```
|
||||
|
||||
Add `"socket_only": True` in the optional dict to suppress the widget and show only a socket:
|
||||
|
||||
```python
|
||||
# optional section:
|
||||
"value": ("FLOAT", {"socket_only": True}),
|
||||
```
|
||||
|
||||
#### INT
|
||||
|
||||
```python
|
||||
"count": ("INT", {
|
||||
"default": 5,
|
||||
"min": 1,
|
||||
"max": 100,
|
||||
"step": 1,
|
||||
})
|
||||
```
|
||||
|
||||
#### Dropdown / choice list
|
||||
|
||||
Pass a list as the first element of the spec tuple:
|
||||
|
||||
```python
|
||||
"method": (["nearest", "bilinear", "bicubic"],),
|
||||
# or with a default:
|
||||
"method": (["nearest", "bilinear", "bicubic"], {"default": "bilinear"}),
|
||||
```
|
||||
|
||||
#### STRING
|
||||
|
||||
```python
|
||||
"label": ("STRING", {
|
||||
"default": "",
|
||||
"placeholder": "Enter text...", # optional
|
||||
"multiline": False, # optional
|
||||
})
|
||||
```
|
||||
|
||||
### Optional inputs
|
||||
|
||||
Declare inputs under `"optional"` to make them not required for execution. Your `process()` method receives `None` for any unconnected optional input, so guard against it:
|
||||
|
||||
```python
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {"field": ("DATA_FIELD",)},
|
||||
"optional": {"mask": ("IMAGE",)},
|
||||
}
|
||||
|
||||
def process(self, field, mask=None):
|
||||
if mask is not None:
|
||||
# use mask
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output types
|
||||
|
||||
Each entry in `OUTPUTS` is `(type_string, display_name)` or `(type_string, display_name, meta_dict)`.
|
||||
|
||||
| Type string | Python value to return | Description |
|
||||
|---|---|---|
|
||||
| `"DATA_FIELD"` | `DataField` | 2D spatial data |
|
||||
| `"IMAGE"` | `np.ndarray` (uint8) | Greyscale or RGB image / mask |
|
||||
| `"LINE"` | `LineData` | 1D profile |
|
||||
| `"RECORD_TABLE"` | `RecordTable` | Named scalar measurement table |
|
||||
| `"FLOAT"` | `float` | Scalar number |
|
||||
|
||||
The return value of `process()` must be a **tuple** with one item per `OUTPUTS` entry, in the same order:
|
||||
|
||||
```python
|
||||
OUTPUTS = (
|
||||
("DATA_FIELD", "result"),
|
||||
("RECORD_TABLE", "stats"),
|
||||
("FLOAT", "mean"),
|
||||
)
|
||||
|
||||
def process(self, field):
|
||||
...
|
||||
return (result_field, table, mean_value) # must be a tuple
|
||||
```
|
||||
|
||||
### Accepting multiple input types on one output slot
|
||||
|
||||
Use `accepted_types` in the output metadata to allow wiring from additional types:
|
||||
|
||||
```python
|
||||
OUTPUTS = (
|
||||
("DATA_FIELD", "output", {"accepted_types": ["IMAGE"]}),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data types reference
|
||||
|
||||
### DataField
|
||||
|
||||
The main SPM data container. Mirrors Gwyddion's `GwyDataField`.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class DataField:
|
||||
data: np.ndarray # shape (yres, xres), dtype float64
|
||||
xres: int # pixel count in X (set automatically from data)
|
||||
yres: int # pixel count in Y (set automatically from data)
|
||||
xreal: float # physical width in metres
|
||||
yreal: float # physical height in metres
|
||||
xoff: float # X position offset in metres
|
||||
yoff: float # Y position offset in metres
|
||||
si_unit_xy: str # lateral unit, e.g. "m"
|
||||
si_unit_z: str # value unit, e.g. "m", "V", "A"
|
||||
domain: str # "spatial" or "frequency"
|
||||
colormap: str | dict # colormap name or custom dict
|
||||
display_offset: float # normalized display window offset
|
||||
display_scale: float # normalized display window scale
|
||||
overlays: list # list of overlay dicts (annotations etc.)
|
||||
|
||||
# Computed properties:
|
||||
field.dx # physical pixel size X = xreal / xres (metres)
|
||||
field.dy # physical pixel size Y = yreal / yres (metres)
|
||||
```
|
||||
|
||||
**Always use `field.replace()` instead of constructing a new `DataField`** — it copies all metadata and only substitutes what you specify:
|
||||
|
||||
```python
|
||||
# Good: physical dimensions, units, colormap all preserved
|
||||
result = field.replace(data=new_data)
|
||||
|
||||
# Also valid: change data and units together
|
||||
result = field.replace(data=fft_data, si_unit_z="1/m", domain="frequency")
|
||||
```
|
||||
|
||||
Available built-in colormaps: `viridis`, `gray`, `hot`, `jet`, `plasma`, `inferno`, `terrain`, `cividis`, `magma`, `copper`, `afmhot`.
|
||||
|
||||
### LineData
|
||||
|
||||
1D profile data.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class LineData:
|
||||
data: np.ndarray # 1D float64 array of Y values
|
||||
x_axis: np.ndarray | None # optional 1D float64 array of X positions
|
||||
x_unit: str # unit label for X axis
|
||||
y_unit: str # unit label for Y axis
|
||||
|
||||
# Supports NumPy interface transparently:
|
||||
np.asarray(line) # → line.data
|
||||
len(line) # → len(line.data)
|
||||
line[i] # → line.data[i]
|
||||
```
|
||||
|
||||
### MeshModel
|
||||
|
||||
3D triangle mesh for the 3D view node.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class MeshModel:
|
||||
vertices: np.ndarray # shape (N, 3), float32 — XYZ positions
|
||||
faces: np.ndarray # shape (M, 3), int32 — triangle vertex indices
|
||||
colors: np.ndarray | None # shape (N, 3), uint8 — per-vertex RGB (optional)
|
||||
```
|
||||
|
||||
### RecordTable
|
||||
|
||||
A measurement table: a plain list of `{"quantity", "value", "unit"}` dicts. Can be wired to the **Print Table** or **Save** nodes.
|
||||
|
||||
```python
|
||||
from backend.data_types import RecordTable
|
||||
|
||||
table = RecordTable([
|
||||
{"quantity": "RMS roughness", "value": 2.34e-9, "unit": "m"},
|
||||
{"quantity": "Mean", "value": 0.12e-9, "unit": "m"},
|
||||
{"quantity": "Pixel count", "value": 4096, "unit": ""},
|
||||
])
|
||||
```
|
||||
|
||||
Use `field.si_unit_z` for the physical Z unit of the input field. Use `""` for dimensionless quantities.
|
||||
|
||||
---
|
||||
|
||||
## Execution context: emit functions
|
||||
|
||||
Import from `backend.execution_context` to send data to the frontend during execution — for example, to show a preview chart or a warning message.
|
||||
|
||||
```python
|
||||
from backend.execution_context import emit_preview, emit_table, emit_warning, emit_value
|
||||
```
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `emit_preview(data_uri)` | Push a preview image (base64 data URI string) to the preview panel |
|
||||
| `emit_table(rows)` | Push a list of dicts as a table update |
|
||||
| `emit_value(payload)` | Push a scalar value (or `{"value": v, "unit": "m"}` dict) |
|
||||
| `emit_warning(message)` | Show a warning banner in the UI |
|
||||
|
||||
These functions are no-ops if called outside an active execution context, so they are safe to call unconditionally.
|
||||
|
||||
```python
|
||||
from backend.execution_context import emit_warning
|
||||
|
||||
def process(self, field, threshold):
|
||||
if threshold > field.data.max():
|
||||
emit_warning("Threshold is above the data maximum — result will be empty.")
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multi-file plugins
|
||||
|
||||
A directory with an `__init__.py` is treated as a plugin package. Private helpers (names starting with `_`) are ignored by the loader.
|
||||
|
||||
```
|
||||
plugins/
|
||||
my_suite/
|
||||
__init__.py # registers nodes with @register_node
|
||||
_helpers.py # private helpers, not auto-loaded
|
||||
```
|
||||
|
||||
In `__init__.py`:
|
||||
|
||||
```python
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
from my_suite._helpers import compute_something
|
||||
|
||||
@register_node(display_name="Suite Node A")
|
||||
class SuiteNodeA:
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Uploading plugins via the web interface
|
||||
|
||||
On native builds, plugins can be uploaded without restarting via the toolbar.
|
||||
|
||||
The server saves the file, hot-reloads all plugins, and broadcasts a `nodes_updated` WebSocket message so the frontend refreshes the node list automatically.
|
||||
|
||||
> **Security note:** Uploading a `.py` file is equivalent to executing arbitrary code inside the server process. Only expose this endpoint on trusted local networks.
|
||||
148
docs/testing.md
Normal file
148
docs/testing.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Testing
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
python -m pytest -q
|
||||
|
||||
# Run with coverage report
|
||||
python -m pytest -q --cov=backend --cov-report=term-missing
|
||||
|
||||
# Run a single test file
|
||||
python -m pytest tests/node_tests/gaussian_filter.py -v
|
||||
|
||||
# Run a single test by name
|
||||
python -m pytest tests/test_grains.py::test_threshold_otsu_bimodal -v
|
||||
```
|
||||
|
||||
The test suite is configured in `pytest.ini` at the repo root. All tests under `tests/` are collected automatically, with the exception of private files (names starting with `_`).
|
||||
|
||||
---
|
||||
|
||||
## Test structure
|
||||
|
||||
```
|
||||
tests/
|
||||
node_tests/ # One file per node or node group
|
||||
_shared.py # Shared helpers (not collected as tests)
|
||||
gaussian_filter.py
|
||||
fft_2d.py
|
||||
...
|
||||
test_grains.py # Integration tests
|
||||
test_fft.py
|
||||
test_session_runtime.py
|
||||
test_frontend_build.py
|
||||
```
|
||||
|
||||
**`tests/node_tests/`** contains per-node unit tests. Each file exercises a single node class or closely related group using the execution engine. Files are auto-collected by pytest; files whose names start with `_` are excluded.
|
||||
|
||||
**`tests/`** (top level) contains broader integration tests that cut across multiple nodes or test server-level behaviour.
|
||||
|
||||
---
|
||||
|
||||
## Writing tests
|
||||
|
||||
### Imports
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import backend.nodes # registers all built-in nodes as a side-effect
|
||||
from backend.execution import ExecutionEngine
|
||||
from tests.node_tests._shared import make_field
|
||||
```
|
||||
|
||||
`import backend.nodes` must appear before any test that uses a built-in node, because node registration happens at import time via `@register_node`.
|
||||
|
||||
### The `make_field` helper
|
||||
|
||||
```python
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
# Default: 64×64 random field, xreal=yreal=1e-6 m, units "m"/"m"
|
||||
field = make_field()
|
||||
|
||||
# Custom shape and physical size
|
||||
field = make_field(shape=(128, 256), xreal=5e-6, yreal=5e-6)
|
||||
|
||||
# Custom data
|
||||
field = make_field(data=np.zeros((32, 32)))
|
||||
```
|
||||
|
||||
### Executing a node
|
||||
|
||||
Use the `ExecutionEngine` with the prompt format:
|
||||
|
||||
```python
|
||||
def test_my_node():
|
||||
engine = ExecutionEngine()
|
||||
prompt = {
|
||||
"1": {
|
||||
"class_type": "GaussianFilter",
|
||||
"inputs": {
|
||||
"field": make_field(), # pass objects directly in tests
|
||||
"sigma": 1.5,
|
||||
},
|
||||
}
|
||||
}
|
||||
outputs = engine.execute(prompt)
|
||||
result = outputs["1"][0] # first output of node "1"
|
||||
assert result.data.shape == (64, 64)
|
||||
```
|
||||
|
||||
Outputs are returned as a dict mapping node id → tuple of output values, in the same order as `OUTPUTS`.
|
||||
|
||||
### Linking nodes
|
||||
|
||||
To chain nodes, use a `[node_id, slot_index]` link in the inputs dict:
|
||||
|
||||
```python
|
||||
prompt = {
|
||||
"1": {"class_type": "GaussianFilter", "inputs": {"field": make_field(), "sigma": 1.5}},
|
||||
"2": {"class_type": "PlaneLevelField", "inputs": {"field": ["1", 0]}},
|
||||
}
|
||||
outputs = engine.execute(prompt)
|
||||
result = outputs["2"][0]
|
||||
```
|
||||
|
||||
### Testing your own node class directly
|
||||
|
||||
You can also instantiate and call a node class directly without the engine:
|
||||
|
||||
```python
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
def test_process_directly():
|
||||
field = make_field()
|
||||
node = MyNode()
|
||||
result, = node.process(field=field, sigma=2.0)
|
||||
assert isinstance(result, DataField)
|
||||
```
|
||||
|
||||
### Assertions on DataField
|
||||
|
||||
```python
|
||||
result = outputs["1"][0]
|
||||
|
||||
assert isinstance(result, DataField)
|
||||
assert result.data.shape == (64, 64)
|
||||
assert result.si_unit_z == "m"
|
||||
assert np.isfinite(result.data).all()
|
||||
|
||||
# Physical dimensions are preserved by field.replace()
|
||||
assert result.xreal == field.xreal
|
||||
assert result.yreal == field.yreal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coverage
|
||||
|
||||
Coverage is configured in `pyproject.toml` under `[tool.coverage]`. It measures the `backend` package and excludes `backend/nodes/__init__.py`.
|
||||
|
||||
```bash
|
||||
python -m pytest -q --cov=backend --cov-report=term-missing
|
||||
```
|
||||
|
||||
The report shows which lines are not exercised by any test.
|
||||
Reference in New Issue
Block a user