Files
dabmodel/test/test_model.py

362 lines
12 KiB
Python

# dabmodel (c) by chacha
#
# dabmodel is licensed under a
# Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Unported License.
#
# You should have received a copy of the license along with this
# work. If not, see <https://creativecommons.org/licenses/by-nc-sa/4.0/>.
import unittest
from os import chdir
from pathlib import Path
print(__name__)
print(__package__)
from src import dabmodel as dm
from typing import Annotated, Any, Optional
from uuid import uuid4
import json
from uuid import UUID
from datetime import datetime
testdir_path = Path(__file__).parent.resolve()
chdir(testdir_path.parent.resolve())
"""
class UUIDEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, UUID):
# if the obj is uuid, we simply return the value of uuid
return obj.hex
elif isinstance(obj, datetime):
return str(obj)
return json.JSONEncoder.default(self, obj)
"""
from typing import Optional, Union
from pydantic import ValidationError, StrictInt, StrictStr, Field
# ---------- Base models to reuse in many tests ----------
class Router(dm.BaseAppliance):
class Features:
class Network(dm.BaseFeature):
mtu: StrictInt = 1500
class Defaults:
template_id = "00000000-0000-4000-8000-000000000001"
template_short_name = "net"
class Defaults:
template_id = "11111111-1111-4111-8111-111111111111"
template_short_name = "router"
class Network:
enabled = True
mtu = 9000
class RouterPlus(Router):
model_name: StrictStr = "plus"
class Features:
class Firewall(dm.BaseFeature):
enabled_by_default: bool = True
class Defaults:
template_id = "22222222-2222-4222-8222-222222222222"
template_short_name = "fw"
class Defaults:
class Firewall:
enabled = True
enabled_by_default = True
class RouterLite(RouterPlus):
class Defaults:
template_short_name = "router-lite"
model_name = "lite"
class Network:
enabled = False
class Firewall:
enabled = True
enabled_by_default = False
# ---------- happy path: defaults & merges ----------
class TestDefaultsAndMerges(unittest.TestCase):
def test_root_defaults_and_feature(self):
a = Router(dabinst_short_name="r1")
self.assertEqual(a.template_short_name, "router")
self.assertIsNotNone(a.Network)
self.assertEqual(a.Network.mtu, 9000)
# required feature defaults from Feature.Defaults are present
self.assertIsNotNone(a.Network.template_id)
self.assertEqual(str(a.Network.template_id), "00000000-0000-4000-8000-000000000001")
self.assertEqual(a.Network.template_short_name, "net")
def test_subclass_adds_field_and_feature(self):
a = RouterPlus(dabinst_short_name="rp1")
self.assertEqual(a.model_name, "plus")
self.assertIsNotNone(a.Network)
self.assertIsNotNone(a.Firewall)
self.assertTrue(a.Firewall.enabled_by_default)
def test_subclass_value_overrides(self):
a = RouterLite(dabinst_short_name="rl1")
self.assertEqual(a.template_short_name, "router-lite")
self.assertEqual(a.model_name, "lite")
self.assertIsNone(a.Network) # disabled by subclass default
self.assertIsNotNone(a.Firewall)
self.assertFalse(a.Firewall.enabled_by_default)
def test_user_input_overrides_enabled_and_values(self):
# user value merge (no 'enabled'): stays enabled from class default
a = Router(dabinst_short_name="u1", Network={"mtu": 4096})
self.assertEqual(a.Network.mtu, 4096)
# user disables feature
b = RouterPlus(dabinst_short_name="u2", Firewall={"enabled": False})
self.assertIsNone(b.Firewall)
# user enables & overrides a field
c = RouterPlus(dabinst_short_name="u3", Firewall={"enabled": True, "enabled_by_default": False})
self.assertIsNotNone(c.Firewall)
self.assertFalse(c.Firewall.enabled_by_default)
def test_enabling_without_config_pulls_feature_defaults(self):
class R(Router):
class Defaults:
class Network:
enabled = True # no extra values here
r = R(dabinst_short_name="x")
self.assertIsNotNone(r.Network)
# pulled from Network.Defaults (template fields & mtu default 1500 -> overridden by Router.Defaults to 9000)
self.assertEqual(r.Network.mtu, 9000)
self.assertEqual(r.Network.template_short_name, "net")
# ---------- invariants: schema monotonicity & declarations ----------
class TestInheritanceRules(unittest.TestCase):
def test_cannot_remove_parent_feature(self):
def make_bad():
class Bad(RouterPlus):
class Features:
class Firewall(dm.BaseFeature): # re-declare only one, drop 'Network'
enabled_by_default: bool = True
return Bad
with self.assertRaises(TypeError):
_ = make_bad()
def test_cannot_retarget_parent_feature_type(self):
def make_bad():
class Router2(Router):
class Features:
class Network(dm.BaseFeature): # Trying to retarget 'Network' to a new type
mtu: StrictInt = 1600
return Router2
with self.assertRaises(TypeError):
_ = make_bad()
def test_cannot_change_parent_field_type_or_constraints(self):
# Type change (StrictInt -> int)
def bad_type():
class Bad(Router):
cpu_cnt: int = 2 # loses StrictInt constraint
return Bad
with self.assertRaises(TypeError):
_ = bad_type()
# Constraint change (gt=0 -> ge=0)
def bad_constraint():
class Bad2(Router):
cpu_cnt: StrictInt = Field(1, ge=0)
return Bad2
with self.assertRaises(TypeError):
_ = bad_constraint()
def test_new_feature_must_be_declared_in_inner_Features(self):
# Add a new feature field but forget to declare it in inner Features
def bad_undeclared():
class Bad(Router):
class Features:
class Network(dm.BaseFeature):
mtu: StrictInt = 1500
class Extra(dm.BaseFeature):
foo: StrictInt = 1
Extra: Optional[Extra] = Extra()
return Bad
with self.assertRaises(TypeError):
_ = bad_undeclared()
def test_subclass_can_add_new_feature_when_declared(self):
class Good(Router):
class Features:
class QoS(dm.BaseFeature):
priority: StrictInt = 5
class Defaults:
template_id = "33333333-3333-4333-8333-333333333333"
template_short_name = "qos"
class Defaults:
class QoS:
enabled = True
priority = 7
g = Good(dabinst_short_name="g1")
self.assertIsNotNone(g.QoS)
self.assertEqual(g.QoS.priority, 7)
self.assertEqual(str(g.QoS.template_id), "33333333-3333-4333-8333-333333333333")
# ---------- type allowlist enforcement ----------
class TestAllowedTypes(unittest.TestCase):
def test_disallow_bad_appliance_field_type(self):
def bad_field():
class Bad(Router):
bad: float = 1.2 # float not in allowed set
return Bad
with self.assertRaises(TypeError):
_ = bad_field()
def test_disallow_bad_feature_field_type(self):
def bad_feature():
class Bad(Router):
class Features:
class BadFeat(dm.BaseFeature):
bad: float = 1.2
return Bad
with self.assertRaises(TypeError):
_ = bad_feature()
# ---------- extras=forbid & immutability ----------
class TestValidationAndImmutability(unittest.TestCase):
def test_appliance_extra_forbidden(self):
with self.assertRaises(ValidationError):
_ = Router(dabinst_short_name="r", unknown=1)
def test_feature_extra_forbidden(self):
# 'enabled' is stripped, but unknown keys should error
with self.assertRaises(ValidationError):
_ = Router(dabinst_short_name="r", Network={"enabled": True, "unknown": 42})
def test_instances_are_frozen(self):
r = Router(dabinst_short_name="f")
with self.assertRaises(TypeError):
r.template_short_name = "hack" # frozen
# ---------- Optional/Union handling ----------
class TestOptionalUnionHandling(unittest.TestCase):
def test_optional_feature_annotation_is_detected(self):
class R(dm.BaseAppliance):
class Features:
class Net(dm.BaseFeature):
mtu: StrictInt = 1500
class Defaults:
template_id = "44444444-4444-4444-8444-444444444444"
template_short_name = "net2"
# explicit Optional[...] annotation (the metaclass injects it too if missing)
Net: Optional[Features.Net] = None
class Defaults:
template_id = "55555555-5555-4555-8555-555555555551"
template_short_name = "R"
class Net:
enabled = True
mtu = 2000
r = R(dabinst_short_name="o1")
self.assertIsNotNone(r.Net)
self.assertEqual(r.Net.mtu, 2000)
self.assertEqual(str(r.Net.template_id), "44444444-4444-4444-8444-444444444444")
def test_union_with_none_is_detected(self):
class R(dm.BaseAppliance):
class Features:
class Net(dm.BaseFeature):
mtu: StrictInt = 1500
class Defaults:
template_id = "55555555-5555-4555-8555-555555555555"
template_short_name = "net3"
# fancier spelling of Optional
Net: Union[Features.Net, None] = None
class Defaults:
template_id = "55555555-5555-4555-8555-555555555551"
template_short_name = "R"
class Net:
enabled = True
r = R(dabinst_short_name="u1")
self.assertIsNotNone(r.Net)
self.assertEqual(str(r.Net.template_id), "55555555-5555-4555-8555-555555555555")
# ---------- class defaults MUST NOT mutate during instance creation ----------
class TestDefaultsMutationSafety(unittest.TestCase):
def test_defaults_map_not_mutated(self):
# Preconditions
self.assertIn("Firewall", RouterPlus.__defaults_map__)
self.assertIn("enabled", RouterPlus.__defaults_map__["Firewall"])
# Create an instance that disables Firewall, which previously used to mutate defaults
_ = RouterPlus(dabinst_short_name="x", Firewall={"enabled": False})
# Postconditions: class defaults unchanged
self.assertIn("Firewall", RouterPlus.__defaults_map__)
self.assertIn("enabled", RouterPlus.__defaults_map__["Firewall"])
self.assertTrue(RouterPlus.__defaults_map__["Firewall"]["enabled"])
# ---------- regression: providing config dict without flags still merges ----------
class TestConfigWithoutEnabledFlag(unittest.TestCase):
def test_merge_when_no_flags(self):
# Provide a dict (no 'enabled'), should merge and keep enabled from class default
r = Router(dabinst_short_name="m", Network={"mtu": 7777})
self.assertEqual(r.Network.mtu, 7777)