Skip to content
Draft
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
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install flake8
run: pip install flake8

- name: Run flake8 (errors only)
run: flake8 API/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics

test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest flask flask-cors numpy pandas openpyxl python-dotenv waitress boto3

- name: Run tests
run: pytest tests/ -v --tb=short
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ WebAPP/References/wijmoOLD/
WebAPP/References/wijmo/DistributionKey.txt
WebAPP/References/wijmo/licence.js
app.spec
__pycache__/
2 changes: 1 addition & 1 deletion API/Classes/Base/Config.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@
'DiscountRate': ['r'],
'OutputActivityRatio':['r','f','t','y','m'],
'InputActivityRatio':['r','f','t','y','m'],
'EmissionActivityRatio':['r','e''t','y','m'],
'EmissionActivityRatio':['r','e','t','y','m'],
'TotalAnnualMaxCapacityInvestment':['r','t','y'],
'TotalAnnualMinCapacityInvestment':['r','t','y'],
'TotalTechnologyAnnualActivityUpperLimit':['r','t','y'],
Expand Down
4 changes: 2 additions & 2 deletions API/Classes/Base/CustomThreadClass.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ def run(self):
if self._target is not None:
self._return = self._target(*self._args, **self._kwargs)

def join(self):
Thread.join(self)
def join(self, timeout=None):
Thread.join(self, timeout=timeout)
return self._return
50 changes: 15 additions & 35 deletions API/Classes/Base/FileClass.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,66 +4,46 @@
class File:
@staticmethod
def readFile(path):
try:
f = open(path, mode="r")
data = json.loads(f.read())
#cirilica u json file
#data = json.load(open(path, encoding='utf-8-sig'))
f.close()
try:
with open(path, mode="r") as f:
data = json.loads(f.read())
return data
except( IndexError):
except (IndexError):
raise IndexError
except(IOError):
except (IOError):
raise IOError
except OSError:
raise OSError

@staticmethod
def writeFile(data, path):
try:
f = open(path, mode="w")
#json
#f.write(json.dumps(data, ensure_ascii=False, separators=(',', ':')))
#f.write(json.dumps(data, ensure_ascii=True, indent=4, sort_keys=False))
#ascii false da zapisemo cirilicu u file
f.write(json.dumps(data, ensure_ascii=True, indent=4, sort_keys=False))
#usjon
#f.write(json.dumps(data))
f.close()
# except(IOError, IndexError):
# return('File not found or file is empty')
#ovako prosljedjujemo exception u prethodnom slucaju vracamo response u funkciju koja poziva writeFile
except(IOError, IndexError):
with open(path, mode="w") as f:
f.write(json.dumps(data, ensure_ascii=True, indent=4, sort_keys=False))
except (IOError, IndexError):
raise IndexError
except OSError:
raise OSError

#drugi nacin pisanj u file
#with open(self.hData, mode="w") as f:
#json.dump(data,f)

@staticmethod
def writeFileUJson(data, path):
try:
f = open(path, mode="w")
#usjon
f.write(json.dumps(data))
f.close()
except(IOError, IndexError):
with open(path, mode="w") as f:
f.write(json.dumps(data))
except (IOError, IndexError):
raise IndexError
except OSError:
raise OSError

@staticmethod
def readParamFile(path):
try:
f = open(path, mode="r")
data = json.loads(f.read())
f.close()
with open(path, mode="r") as f:
data = json.loads(f.read())
return data
except( IndexError):
except (IndexError):
raise IndexError
except(IOError):
except (IOError):
raise IOError
except OSError:
raise OSError
5 changes: 5 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
Empty file added tests/__init__.py
Empty file.
66 changes: 66 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Tests for API.Classes.Base.Config — validate configuration constants."""

import os
import sys

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'API'))

from Classes.Base import Config


class TestParametersCIntegrity:
"""Verify PARAMETERS_C has correct dimension keys."""

def test_emission_activity_ratio_dimensions(self):
"""Regression: 'e''t' string concatenation typo must be fixed."""
dims = Config.PARAMETERS_C['EmissionActivityRatio']
assert dims == ['r', 'e', 't', 'y', 'm'], (
f"EmissionActivityRatio dimensions are wrong: {dims}"
)

def test_all_dimension_keys_are_single_chars_or_known(self):
"""Every dimension key should be a short known identifier."""
known = {'r', 'f', 't', 'y', 'm', 'e', 'l', 's', 'rr', 'cn',
'ls', 'ld', 'lh'}
for param, dims in Config.PARAMETERS_C.items():
for d in dims:
assert d in known, (
f"Unexpected dimension key '{d}' in PARAMETERS_C['{param}']"
)


class TestConfigPaths:
"""Verify that configured paths are pathlib.Path objects."""

def test_data_storage_is_path(self):
from pathlib import Path
assert isinstance(Config.DATA_STORAGE, Path)

def test_solvers_folder_is_path(self):
from pathlib import Path
assert isinstance(Config.SOLVERs_FOLDER, Path)


class TestConfigGroups:
"""Sanity-check that group tuples are non-empty."""

def test_tech_groups_non_empty(self):
assert len(Config.TECH_GROUPS) > 0

def test_comm_groups_non_empty(self):
assert len(Config.COMM_GROUPS) > 0

def test_emis_groups_non_empty(self):
assert len(Config.EMIS_GROUPS) > 0


class TestDefaultUpdateGenMappings:
"""DEFAULT_F, UPDATE_F, and GEN_F must have the same keys."""

def test_default_and_update_keys_match(self):
assert set(Config.DEFAULT_F.keys()) == set(Config.UPDATE_F.keys())

def test_default_and_gen_keys_match(self):
assert set(Config.DEFAULT_F.keys()) == set(Config.GEN_F.keys())
61 changes: 61 additions & 0 deletions tests/test_custom_thread.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Tests for API.Classes.Base.CustomThreadClass."""

import os
import sys
import time

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'API'))

from Classes.Base.CustomThreadClass import CustomThread


class TestCustomThread:
"""CustomThread basic functionality."""

def test_returns_target_result(self):
"""join() should return the value produced by the target function."""
t = CustomThread(target=lambda: 42)
t.start()
result = t.join()
assert result == 42

def test_returns_none_for_void_target(self):
t = CustomThread(target=lambda: None)
t.start()
assert t.join() is None

def test_passes_args(self):
t = CustomThread(target=lambda a, b: a + b, args=(3, 7))
t.start()
assert t.join() == 10

def test_passes_kwargs(self):
t = CustomThread(target=lambda x=0: x * 2, kwargs={"x": 5})
t.start()
assert t.join() == 10

def test_join_accepts_timeout(self):
"""Regression: join(timeout=...) must not raise TypeError."""
def slow():
time.sleep(0.5)
return "done"

t = CustomThread(target=slow)
t.start()
result = t.join(timeout=2)
assert result == "done"

def test_join_timeout_returns_before_completion(self):
"""join() with a short timeout should return without blocking forever."""
def very_slow():
time.sleep(10)
return "never"

t = CustomThread(target=very_slow)
t.daemon = True # so it won't block test exit
t.start()
t.join(timeout=0.1)
# Thread is still alive because timeout was short
assert t.is_alive()
94 changes: 94 additions & 0 deletions tests/test_file_class.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Tests for API.Classes.Base.FileClass."""

import json
import os
import tempfile

import pytest

# Allow imports from API/ directory
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'API'))

from Classes.Base.FileClass import File


@pytest.fixture
def tmp_json(tmp_path):
"""Return a helper that writes raw text to a temp .json file."""
def _make(data, filename="test.json"):
path = tmp_path / filename
path.write_text(json.dumps(data), encoding="utf-8")
return path
return _make


class TestReadFile:
"""File.readFile round-trip tests."""

def test_reads_valid_json(self, tmp_json):
payload = {"key": "value", "nums": [1, 2, 3]}
path = tmp_json(payload)
result = File.readFile(path)
assert result == payload

def test_reads_nested_json(self, tmp_json):
payload = {"a": {"b": {"c": 42}}}
path = tmp_json(payload)
assert File.readFile(path) == payload

def test_raises_on_missing_file(self, tmp_path):
with pytest.raises((IOError, OSError)):
File.readFile(tmp_path / "nonexistent.json")

def test_raises_on_invalid_json(self, tmp_path):
bad = tmp_path / "bad.json"
bad.write_text("{not valid json", encoding="utf-8")
with pytest.raises(Exception):
File.readFile(bad)


class TestWriteFile:
"""File.writeFile round-trip tests."""

def test_write_then_read(self, tmp_path):
payload = {"x": 1, "arr": [10, 20]}
path = tmp_path / "out.json"
File.writeFile(payload, path)
assert File.readFile(path) == payload

def test_overwrites_existing(self, tmp_path):
path = tmp_path / "out.json"
File.writeFile({"v": 1}, path)
File.writeFile({"v": 2}, path)
assert File.readFile(path)["v"] == 2

def test_writes_pretty_printed(self, tmp_path):
path = tmp_path / "out.json"
File.writeFile({"a": 1}, path)
raw = path.read_text(encoding="utf-8")
# indent=4 produces multi-line output
assert "\n" in raw


class TestWriteFileUJson:
"""File.writeFileUJson round-trip tests."""

def test_write_then_read(self, tmp_path):
payload = {"compact": True}
path = tmp_path / "compact.json"
File.writeFileUJson(payload, path)
assert File.readFile(path) == payload


class TestReadParamFile:
"""File.readParamFile tests."""

def test_reads_valid_json(self, tmp_json):
payload = [{"id": "p1", "value": "v1"}]
path = tmp_json(payload)
assert File.readParamFile(path) == payload

def test_raises_on_missing_file(self, tmp_path):
with pytest.raises((IOError, OSError)):
File.readParamFile(tmp_path / "missing.json")