Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/workflows/test-modelmaker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Tests

on:
push:
branches: [platypus_dev_1.3, main]
paths:
- 'tinyml-modelmaker/**'
- '.github/workflows/test-modelmaker.yml'
pull_request:
branches: [platypus_dev_1.3, main]
paths:
- 'tinyml-modelmaker/**'
workflow_dispatch: # manual trigger

jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
name: Tests (${{ matrix.os }})
# Windows support is experimental — don't block the overall run
continue-on-error: ${{ matrix.os == 'windows-latest' }}

defaults:
run:
shell: bash

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python 3.10
uses: actions/setup-python@v5
with:
python-version: '3.10'

- name: Install dependencies
run: |
python -m pip install --upgrade pip setuptools wheel
pip install -e tinyml-modelzoo
pip install -e tinyml-tinyverse
pip install -e "tinyml-modeloptimization/torchmodelopt"
pip install -e tinyml-modelmaker --no-deps
pip install defusedxml==0.7.1 "numpy==2.2.6" "PyYAML==6.0.3" "tqdm==4.67.1" "requests==2.32.5" "torch==2.7.1"
pip install pytest

- name: Install ti_mcu_nnc (Linux only)
if: runner.os == 'Linux'
run: |
pip install "ti_mcu_nnc @ https://software-dl.ti.com/mctools/esd/tvm/mcu/ti_mcu_nnc-2.1.1-cp310-cp310-linux_x86_64.whl" || true

- name: Install ti_mcu_nnc (Windows only)
if: runner.os == 'Windows'
run: |
pip install "ti_mcu_nnc @ https://software-dl.ti.com/mctools/esd/tvm/mcu/ti_mcu_nnc-2.1.1-cp310-cp310-win_amd64.whl" || true

- name: Tier 1 — Component Tests
working-directory: tinyml-modelmaker
run: python -m pytest tests/test_model_registry.py tests/test_config_validation.py -v --tb=short

- name: Tier 3 — Cross-Device Validation
working-directory: tinyml-modelmaker
run: python -m pytest tests/test_cross_device.py -v --tb=short

- name: Tier 2 — Pipeline Smoke Tests
working-directory: tinyml-modelmaker
run: python -m pytest tests/test_pipeline_smoke.py -v --tb=short
7 changes: 7 additions & 0 deletions tinyml-modelmaker/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,10 @@ exclude = [ "tests*",]

[tool.setuptools.dynamic.version]
attr = "tinyml_modelmaker.__version__"

[tool.pytest.ini_options]
markers = [
"component: Tier 1 component tests",
"smoke: Tier 2 pipeline smoke tests",
"device: Tier 3 cross-device validation tests",
]
Empty file.
181 changes: 181 additions & 0 deletions tinyml-modelmaker/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"""Shared fixtures for tinyml-modelmaker tests.

Provides:
- pytest markers for component and smoke test tiers
- Synthetic dataset generators for pipeline smoke tests
"""

import os
import csv
import random
import pytest
from pathlib import Path


# ---------------------------------------------------------------------------
# Custom markers
# ---------------------------------------------------------------------------

def pytest_configure(config):
"""Register custom markers for test tiers."""
config.addinivalue_line("markers", "component: Tier 1 component tests (fast, no training)")
config.addinivalue_line("markers", "smoke: Tier 2 pipeline smoke tests (1-epoch, synthetic data)")


# ---------------------------------------------------------------------------
# Synthetic dataset generators
# ---------------------------------------------------------------------------

@pytest.fixture
def synthetic_classification_data(tmp_path):
"""Create a minimal 3-class classification dataset.

Structure:
tmp_path/
classes/
class_a/ (30 CSV files)
class_b/ (30 CSV files)
class_c/ (30 CSV files)
annotations/
file_list.txt (all files)
Each CSV has 256 rows (samples) × 1 column (single-channel sensor data).
"""
random.seed(42)
classes_dir = tmp_path / "classes"
all_files = []

for label in ["class_a", "class_b", "class_c"]:
class_dir = classes_dir / label
class_dir.mkdir(parents=True)
for i in range(30):
filename = f"{label}_{i:03d}.csv"
filepath = class_dir / filename
with open(filepath, "w", newline="") as f:
writer = csv.writer(f)
for _ in range(256):
writer.writerow([random.gauss(0, 1)])
all_files.append(f"classes/{label}/{filename}")

# Create annotations directory with file list
ann_dir = tmp_path / "annotations"
ann_dir.mkdir(parents=True)
(ann_dir / "file_list.txt").write_text("\n".join(all_files))

return str(tmp_path)


@pytest.fixture
def synthetic_regression_data(tmp_path):
"""Create a minimal regression dataset.

Structure:
tmp_path/
files/ (60 CSV files — flat layout for regression)
annotations/
instances_train_list.txt
instances_val_list.txt
Each CSV has 256 rows × 2 columns (input feature, target value).
"""
random.seed(42)
files_dir = tmp_path / "files"
files_dir.mkdir(parents=True)
all_files = []

for i in range(60):
filename = f"sample_{i:03d}.csv"
filepath = files_dir / filename
with open(filepath, "w", newline="") as f:
writer = csv.writer(f)
for _ in range(256):
x = random.gauss(0, 1)
y = 2.0 * x + random.gauss(0, 0.1)
writer.writerow([x, y])
all_files.append(f"files/{filename}")

# Create annotations with train/val split (48 train, 12 val)
# File names must match GenericTSDataset glob patterns: *train*_list.txt, *val*_list.txt
ann_dir = tmp_path / "annotations"
ann_dir.mkdir(parents=True)
(ann_dir / "instances_train_list.txt").write_text("\n".join(all_files[:48]))
(ann_dir / "instances_val_list.txt").write_text("\n".join(all_files[48:]))

return str(tmp_path)


@pytest.fixture
def synthetic_anomaly_data(tmp_path):
"""Create a minimal anomaly detection dataset.

Structure:
tmp_path/
classes/
normal/ (50 CSV files)
anomaly/ (10 CSV files)
"""
random.seed(42)
classes_dir = tmp_path / "classes"

for label, count, mu in [("normal", 50, 0.0), ("anomaly", 10, 5.0)]:
class_dir = classes_dir / label
class_dir.mkdir(parents=True)
for i in range(count):
filepath = class_dir / f"{label}_{i:03d}.csv"
with open(filepath, "w", newline="") as f:
writer = csv.writer(f)
for _ in range(256):
writer.writerow([random.gauss(mu, 1)])

return str(tmp_path)


@pytest.fixture
def synthetic_forecasting_data(tmp_path):
"""Create a minimal forecasting dataset.

Structure:
tmp_path/
files/ (60 CSV files — flat layout for forecasting)
annotations/
instances_train_list.txt
instances_val_list.txt
Each CSV has 64 rows × 1 column (univariate time series).
"""
random.seed(42)
files_dir = tmp_path / "files"
files_dir.mkdir(parents=True)
all_files = []

for i in range(60):
filename = f"series_{i:03d}.csv"
filepath = files_dir / filename
with open(filepath, "w", newline="") as f:
writer = csv.writer(f)
val = random.gauss(0, 1)
for _ in range(64):
val += random.gauss(0, 0.3)
writer.writerow([val])
all_files.append(f"files/{filename}")

# Create annotations with train/val split (48 train, 12 val)
# File names must match GenericTSDataset glob patterns: *train*_list.txt, *val*_list.txt
ann_dir = tmp_path / "annotations"
ann_dir.mkdir(parents=True)
(ann_dir / "instances_train_list.txt").write_text("\n".join(all_files[:48]))
(ann_dir / "instances_val_list.txt").write_text("\n".join(all_files[48:]))

return str(tmp_path)


@pytest.fixture
def modelzoo_examples_dir():
"""Return the path to the modelzoo examples directory.

Returns None if not found (not all environments will have modelzoo).
"""
this_dir = Path(__file__).resolve().parent
modelmaker_dir = this_dir.parent
tensorlab_dir = modelmaker_dir.parent
examples_dir = tensorlab_dir / "tinyml-modelzoo" / "examples"
if examples_dir.is_dir():
return examples_dir
return None
46 changes: 46 additions & 0 deletions tinyml-modelmaker/tests/test_config_dict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Tests for ConfigDict."""

import os
import tempfile

import pytest
import yaml

from tinyml_modelmaker.utils.config_dict import ConfigDict


class TestConfigDict:
def test_from_dict(self):
d = {"a": 1, "b": {"c": 2}}
cfg = ConfigDict(d)
assert cfg.a == 1
assert cfg.b.c == 2

def test_from_yaml(self, tmp_path):
data = {"x": 10, "nested": {"y": 20}}
yaml_file = tmp_path / "test.yaml"
yaml_file.write_text(yaml.dump(data))
cfg = ConfigDict(str(yaml_file))
assert cfg.x == 10
assert cfg.nested.y == 20

def test_invalid_extension_raises(self, tmp_path):
txt_file = tmp_path / "test.txt"
txt_file.write_text("hello")
with pytest.raises(ValueError, match="unrecognized file type"):
ConfigDict(str(txt_file))

def test_invalid_input_raises(self):
with pytest.raises(TypeError, match="got invalid input"):
ConfigDict(12345)

def test_update(self):
cfg = ConfigDict({"a": 1, "b": 2})
cfg.update({"b": 3, "c": 4})
assert cfg.b == 3
assert cfg.c == 4

def test_none_input(self):
cfg = ConfigDict(None)
# Should create an empty config without error
assert isinstance(cfg, ConfigDict)
Loading