diff --git a/migrations/0004-interval-projections.sql b/migrations/0004-interval-projections.sql new file mode 100644 index 0000000..62f73f8 --- /dev/null +++ b/migrations/0004-interval-projections.sql @@ -0,0 +1,56 @@ +-- ABOUTME: Creates interval projection tables for time-series tracking. +-- ABOUTME: Tracks animal location, tag, and attribute history over time. + +-- Track animal location history +-- Each row represents a period when an animal was at a specific location +CREATE TABLE animal_location_intervals ( + animal_id TEXT NOT NULL CHECK(length(animal_id) = 26), + location_id TEXT NOT NULL CHECK(length(location_id) = 26), + start_utc INTEGER NOT NULL, + end_utc INTEGER, + PRIMARY KEY (animal_id, start_utc), + CHECK(end_utc IS NULL OR end_utc > start_utc) +); + +-- Index for "which animals were at location X at time T" queries +CREATE INDEX idx_ali_loc_time ON animal_location_intervals( + location_id, start_utc, COALESCE(end_utc, 32503680000000) +); + +-- Index for "where was animal X at time T" queries +CREATE INDEX idx_ali_animal_time ON animal_location_intervals( + animal_id, start_utc, COALESCE(end_utc, 32503680000000) +); + +-- Track animal tag history +-- Each row represents a period when an animal had a specific tag +CREATE TABLE animal_tag_intervals ( + animal_id TEXT NOT NULL CHECK(length(animal_id) = 26), + tag TEXT NOT NULL, + start_utc INTEGER NOT NULL, + end_utc INTEGER, + PRIMARY KEY (animal_id, tag, start_utc), + CHECK(end_utc IS NULL OR end_utc > start_utc) +); + +-- Index for "which animals had tag X at time T" queries +CREATE INDEX idx_ati_tag_time ON animal_tag_intervals( + tag, start_utc, COALESCE(end_utc, 32503680000000) +); + +-- Track attribute changes (sex, life_stage, repro_status, status) +-- Each row represents a period when an animal had a specific attribute value +CREATE TABLE animal_attr_intervals ( + animal_id TEXT NOT NULL CHECK(length(animal_id) = 26), + attr TEXT NOT NULL CHECK(attr IN ('sex', 'life_stage', 'repro_status', 'status')), + value TEXT NOT NULL, + start_utc INTEGER NOT NULL, + end_utc INTEGER, + PRIMARY KEY (animal_id, attr, start_utc), + CHECK(end_utc IS NULL OR end_utc > start_utc) +); + +-- Index for "which animals had attr=value at time T" queries +CREATE INDEX idx_aai_attr_time ON animal_attr_intervals( + attr, value, start_utc, COALESCE(end_utc, 32503680000000) +); diff --git a/src/animaltrack/models/intervals.py b/src/animaltrack/models/intervals.py new file mode 100644 index 0000000..df7b139 --- /dev/null +++ b/src/animaltrack/models/intervals.py @@ -0,0 +1,44 @@ +# ABOUTME: Pydantic models for time-series interval tracking. +# ABOUTME: Tracks animal location, tag, and attribute history over time. + +from typing import Literal + +from pydantic import BaseModel, Field + + +class LocationInterval(BaseModel): + """Represents a period when an animal was at a specific location. + + Open intervals (end_utc=None) indicate the animal is still at that location. + """ + + animal_id: str = Field(..., min_length=26, max_length=26) + location_id: str = Field(..., min_length=26, max_length=26) + start_utc: int + end_utc: int | None = None + + +class TagInterval(BaseModel): + """Represents a period when an animal had a specific tag. + + Open intervals (end_utc=None) indicate the tag is still active. + """ + + animal_id: str = Field(..., min_length=26, max_length=26) + tag: str + start_utc: int + end_utc: int | None = None + + +class AttrInterval(BaseModel): + """Represents a period when an animal had a specific attribute value. + + Tracks changes to sex, life_stage, repro_status, and status over time. + Open intervals (end_utc=None) indicate the current attribute value. + """ + + animal_id: str = Field(..., min_length=26, max_length=26) + attr: Literal["sex", "life_stage", "repro_status", "status"] + value: str + start_utc: int + end_utc: int | None = None diff --git a/tests/test_migration_interval_projections.py b/tests/test_migration_interval_projections.py new file mode 100644 index 0000000..645b249 --- /dev/null +++ b/tests/test_migration_interval_projections.py @@ -0,0 +1,396 @@ +# ABOUTME: Tests for the interval projections migration (0004-interval-projections.sql). +# ABOUTME: Validates tables, constraints, and indexes for time-series interval tracking. + +import apsw +import pytest + + +class TestMigrationCreatesAllTables: + """Tests that migration creates all interval tables.""" + + def test_animal_location_intervals_table_exists(self, migrated_db): + """Migration creates animal_location_intervals table.""" + result = migrated_db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='animal_location_intervals'" + ).fetchone() + assert result is not None + assert result[0] == "animal_location_intervals" + + def test_animal_tag_intervals_table_exists(self, migrated_db): + """Migration creates animal_tag_intervals table.""" + result = migrated_db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='animal_tag_intervals'" + ).fetchone() + assert result is not None + assert result[0] == "animal_tag_intervals" + + def test_animal_attr_intervals_table_exists(self, migrated_db): + """Migration creates animal_attr_intervals table.""" + result = migrated_db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='animal_attr_intervals'" + ).fetchone() + assert result is not None + assert result[0] == "animal_attr_intervals" + + +class TestAnimalLocationIntervalsTable: + """Tests for animal_location_intervals table schema and constraints.""" + + def test_insert_valid_interval(self, migrated_db): + """Can insert valid location interval data.""" + migrated_db.execute( + """ + INSERT INTO animal_location_intervals (animal_id, location_id, start_utc, end_utc) + VALUES (?, ?, ?, ?) + """, + ( + "01ARZ3NDEKTSV4RRFFQ69G5FAA", + "01ARZ3NDEKTSV4RRFFQ69G5FAV", + 1704067200000, + 1704153600000, + ), + ) + result = migrated_db.execute( + "SELECT animal_id, location_id, start_utc, end_utc FROM animal_location_intervals" + ).fetchone() + assert result == ( + "01ARZ3NDEKTSV4RRFFQ69G5FAA", + "01ARZ3NDEKTSV4RRFFQ69G5FAV", + 1704067200000, + 1704153600000, + ) + + def test_null_end_utc_allowed(self, migrated_db): + """Open interval with NULL end_utc is allowed.""" + migrated_db.execute( + """ + INSERT INTO animal_location_intervals (animal_id, location_id, start_utc) + VALUES (?, ?, ?) + """, + ("01ARZ3NDEKTSV4RRFFQ69G5FAA", "01ARZ3NDEKTSV4RRFFQ69G5FAV", 1704067200000), + ) + result = migrated_db.execute("SELECT end_utc FROM animal_location_intervals").fetchone() + assert result[0] is None + + def test_animal_id_length_check(self, migrated_db): + """Animal ID must be exactly 26 characters.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO animal_location_intervals (animal_id, location_id, start_utc) + VALUES ('short', '01ARZ3NDEKTSV4RRFFQ69G5FAV', 1704067200000) + """ + ) + + def test_location_id_length_check(self, migrated_db): + """Location ID must be exactly 26 characters.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO animal_location_intervals (animal_id, location_id, start_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'short', 1704067200000) + """ + ) + + def test_end_utc_must_be_greater_than_start_utc(self, migrated_db): + """end_utc must be greater than start_utc when not NULL.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO animal_location_intervals (animal_id, location_id, start_utc, end_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', '01ARZ3NDEKTSV4RRFFQ69G5FAV', + 1704067200000, 1704067200000) + """ + ) + + def test_end_utc_less_than_start_utc_rejected(self, migrated_db): + """end_utc less than start_utc is rejected.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO animal_location_intervals (animal_id, location_id, start_utc, end_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', '01ARZ3NDEKTSV4RRFFQ69G5FAV', + 1704067200000, 1704000000000) + """ + ) + + def test_composite_primary_key(self, migrated_db): + """Primary key is (animal_id, start_utc).""" + # Insert first interval + migrated_db.execute( + """ + INSERT INTO animal_location_intervals (animal_id, location_id, start_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', '01ARZ3NDEKTSV4RRFFQ69G5FAV', 1704067200000) + """ + ) + # Same animal, different start_utc should work + migrated_db.execute( + """ + INSERT INTO animal_location_intervals (animal_id, location_id, start_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', '01ARZ3NDEKTSV4RRFFQ69G5FAW', 1704153600000) + """ + ) + # Same animal, same start_utc should fail + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO animal_location_intervals (animal_id, location_id, start_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', '01ARZ3NDEKTSV4RRFFQ69G5FAX', 1704067200000) + """ + ) + + +class TestAnimalTagIntervalsTable: + """Tests for animal_tag_intervals table schema and constraints.""" + + def test_insert_valid_interval(self, migrated_db): + """Can insert valid tag interval data.""" + migrated_db.execute( + """ + INSERT INTO animal_tag_intervals (animal_id, tag, start_utc, end_utc) + VALUES (?, ?, ?, ?) + """, + ("01ARZ3NDEKTSV4RRFFQ69G5FAA", "friendly", 1704067200000, 1704153600000), + ) + result = migrated_db.execute( + "SELECT animal_id, tag, start_utc, end_utc FROM animal_tag_intervals" + ).fetchone() + assert result == ("01ARZ3NDEKTSV4RRFFQ69G5FAA", "friendly", 1704067200000, 1704153600000) + + def test_null_end_utc_allowed(self, migrated_db): + """Open interval with NULL end_utc is allowed.""" + migrated_db.execute( + """ + INSERT INTO animal_tag_intervals (animal_id, tag, start_utc) + VALUES (?, ?, ?) + """, + ("01ARZ3NDEKTSV4RRFFQ69G5FAA", "leader", 1704067200000), + ) + result = migrated_db.execute("SELECT end_utc FROM animal_tag_intervals").fetchone() + assert result[0] is None + + def test_animal_id_length_check(self, migrated_db): + """Animal ID must be exactly 26 characters.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO animal_tag_intervals (animal_id, tag, start_utc) + VALUES ('short', 'friendly', 1704067200000) + """ + ) + + def test_end_utc_must_be_greater_than_start_utc(self, migrated_db): + """end_utc must be greater than start_utc when not NULL.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO animal_tag_intervals (animal_id, tag, start_utc, end_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'friendly', 1704067200000, 1704067200000) + """ + ) + + def test_composite_primary_key(self, migrated_db): + """Primary key is (animal_id, tag, start_utc).""" + # Insert first interval + migrated_db.execute( + """ + INSERT INTO animal_tag_intervals (animal_id, tag, start_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'friendly', 1704067200000) + """ + ) + # Same animal, same tag, different start_utc should work + migrated_db.execute( + """ + INSERT INTO animal_tag_intervals (animal_id, tag, start_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'friendly', 1704153600000) + """ + ) + # Same animal, different tag, same start_utc should work + migrated_db.execute( + """ + INSERT INTO animal_tag_intervals (animal_id, tag, start_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'leader', 1704067200000) + """ + ) + # Same animal, same tag, same start_utc should fail + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO animal_tag_intervals (animal_id, tag, start_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'friendly', 1704067200000) + """ + ) + + +class TestAnimalAttrIntervalsTable: + """Tests for animal_attr_intervals table schema and constraints.""" + + def test_insert_valid_interval(self, migrated_db): + """Can insert valid attr interval data.""" + migrated_db.execute( + """ + INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc, end_utc) + VALUES (?, ?, ?, ?, ?) + """, + ("01ARZ3NDEKTSV4RRFFQ69G5FAA", "sex", "female", 1704067200000, 1704153600000), + ) + result = migrated_db.execute( + "SELECT animal_id, attr, value, start_utc, end_utc FROM animal_attr_intervals" + ).fetchone() + assert result == ( + "01ARZ3NDEKTSV4RRFFQ69G5FAA", + "sex", + "female", + 1704067200000, + 1704153600000, + ) + + def test_null_end_utc_allowed(self, migrated_db): + """Open interval with NULL end_utc is allowed.""" + migrated_db.execute( + """ + INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc) + VALUES (?, ?, ?, ?) + """, + ("01ARZ3NDEKTSV4RRFFQ69G5FAA", "status", "alive", 1704067200000), + ) + result = migrated_db.execute("SELECT end_utc FROM animal_attr_intervals").fetchone() + assert result[0] is None + + def test_animal_id_length_check(self, migrated_db): + """Animal ID must be exactly 26 characters.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc) + VALUES ('short', 'sex', 'female', 1704067200000) + """ + ) + + def test_attr_check_constraint_sex(self, migrated_db): + """attr='sex' is allowed.""" + migrated_db.execute( + """ + INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'sex', 'female', 1704067200000) + """ + ) + count = migrated_db.execute("SELECT COUNT(*) FROM animal_attr_intervals").fetchone()[0] + assert count == 1 + + def test_attr_check_constraint_life_stage(self, migrated_db): + """attr='life_stage' is allowed.""" + migrated_db.execute( + """ + INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'life_stage', 'adult', 1704067200000) + """ + ) + count = migrated_db.execute("SELECT COUNT(*) FROM animal_attr_intervals").fetchone()[0] + assert count == 1 + + def test_attr_check_constraint_repro_status(self, migrated_db): + """attr='repro_status' is allowed.""" + migrated_db.execute( + """ + INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'repro_status', 'intact', 1704067200000) + """ + ) + count = migrated_db.execute("SELECT COUNT(*) FROM animal_attr_intervals").fetchone()[0] + assert count == 1 + + def test_attr_check_constraint_status(self, migrated_db): + """attr='status' is allowed.""" + migrated_db.execute( + """ + INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'status', 'alive', 1704067200000) + """ + ) + count = migrated_db.execute("SELECT COUNT(*) FROM animal_attr_intervals").fetchone()[0] + assert count == 1 + + def test_attr_check_constraint_invalid_rejected(self, migrated_db): + """Invalid attr value is rejected.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'invalid_attr', 'value', 1704067200000) + """ + ) + + def test_end_utc_must_be_greater_than_start_utc(self, migrated_db): + """end_utc must be greater than start_utc when not NULL.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc, end_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'sex', 'female', 1704067200000, 1704067200000) + """ + ) + + def test_composite_primary_key(self, migrated_db): + """Primary key is (animal_id, attr, start_utc).""" + # Insert first interval + migrated_db.execute( + """ + INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'sex', 'female', 1704067200000) + """ + ) + # Same animal, same attr, different start_utc should work + migrated_db.execute( + """ + INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'sex', 'male', 1704153600000) + """ + ) + # Same animal, different attr, same start_utc should work + migrated_db.execute( + """ + INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'status', 'alive', 1704067200000) + """ + ) + # Same animal, same attr, same start_utc should fail + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc) + VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'sex', 'unknown', 1704067200000) + """ + ) + + +class TestIndexes: + """Tests that indexes are created correctly.""" + + def test_location_intervals_indexes_exist(self, migrated_db): + """animal_location_intervals table has required indexes.""" + indexes = migrated_db.execute( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='animal_location_intervals'" + ).fetchall() + index_names = {row[0] for row in indexes} + + assert "idx_ali_loc_time" in index_names + assert "idx_ali_animal_time" in index_names + + def test_tag_intervals_indexes_exist(self, migrated_db): + """animal_tag_intervals table has required indexes.""" + indexes = migrated_db.execute( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='animal_tag_intervals'" + ).fetchall() + index_names = {row[0] for row in indexes} + + assert "idx_ati_tag_time" in index_names + + def test_attr_intervals_indexes_exist(self, migrated_db): + """animal_attr_intervals table has required indexes.""" + indexes = migrated_db.execute( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='animal_attr_intervals'" + ).fetchall() + index_names = {row[0] for row in indexes} + + assert "idx_aai_attr_time" in index_names diff --git a/tests/test_models_intervals.py b/tests/test_models_intervals.py new file mode 100644 index 0000000..f4efaf0 --- /dev/null +++ b/tests/test_models_intervals.py @@ -0,0 +1,175 @@ +# ABOUTME: Tests for interval-related Pydantic models. +# ABOUTME: Validates LocationInterval, TagInterval, and AttrInterval models. + +import pytest +from pydantic import ValidationError + +from animaltrack.models.intervals import AttrInterval, LocationInterval, TagInterval + + +class TestLocationInterval: + """Tests for the LocationInterval model.""" + + def test_valid_interval(self): + """LocationInterval with all required fields validates successfully.""" + interval = LocationInterval( + animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA", + location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV", + start_utc=1704067200000, + end_utc=1704153600000, + ) + assert interval.animal_id == "01ARZ3NDEKTSV4RRFFQ69G5FAA" + assert interval.location_id == "01ARZ3NDEKTSV4RRFFQ69G5FAV" + assert interval.start_utc == 1704067200000 + assert interval.end_utc == 1704153600000 + + def test_end_utc_defaults_to_none(self): + """end_utc defaults to None (open interval).""" + interval = LocationInterval( + animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA", + location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV", + start_utc=1704067200000, + ) + assert interval.end_utc is None + + def test_animal_id_must_be_26_chars(self): + """LocationInterval with short animal_id raises ValidationError.""" + with pytest.raises(ValidationError): + LocationInterval( + animal_id="short", + location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV", + start_utc=1704067200000, + ) + + def test_location_id_must_be_26_chars(self): + """LocationInterval with short location_id raises ValidationError.""" + with pytest.raises(ValidationError): + LocationInterval( + animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA", + location_id="short", + start_utc=1704067200000, + ) + + +class TestTagInterval: + """Tests for the TagInterval model.""" + + def test_valid_interval(self): + """TagInterval with all required fields validates successfully.""" + interval = TagInterval( + animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA", + tag="friendly", + start_utc=1704067200000, + end_utc=1704153600000, + ) + assert interval.animal_id == "01ARZ3NDEKTSV4RRFFQ69G5FAA" + assert interval.tag == "friendly" + assert interval.start_utc == 1704067200000 + assert interval.end_utc == 1704153600000 + + def test_end_utc_defaults_to_none(self): + """end_utc defaults to None (open interval).""" + interval = TagInterval( + animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA", + tag="leader", + start_utc=1704067200000, + ) + assert interval.end_utc is None + + def test_animal_id_must_be_26_chars(self): + """TagInterval with short animal_id raises ValidationError.""" + with pytest.raises(ValidationError): + TagInterval( + animal_id="short", + tag="friendly", + start_utc=1704067200000, + ) + + +class TestAttrInterval: + """Tests for the AttrInterval model.""" + + def test_valid_interval(self): + """AttrInterval with all required fields validates successfully.""" + interval = AttrInterval( + animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA", + attr="sex", + value="female", + start_utc=1704067200000, + end_utc=1704153600000, + ) + assert interval.animal_id == "01ARZ3NDEKTSV4RRFFQ69G5FAA" + assert interval.attr == "sex" + assert interval.value == "female" + assert interval.start_utc == 1704067200000 + assert interval.end_utc == 1704153600000 + + def test_end_utc_defaults_to_none(self): + """end_utc defaults to None (open interval).""" + interval = AttrInterval( + animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA", + attr="status", + value="alive", + start_utc=1704067200000, + ) + assert interval.end_utc is None + + def test_animal_id_must_be_26_chars(self): + """AttrInterval with short animal_id raises ValidationError.""" + with pytest.raises(ValidationError): + AttrInterval( + animal_id="short", + attr="sex", + value="female", + start_utc=1704067200000, + ) + + def test_attr_sex_is_valid(self): + """attr='sex' is a valid value.""" + interval = AttrInterval( + animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA", + attr="sex", + value="female", + start_utc=1704067200000, + ) + assert interval.attr == "sex" + + def test_attr_life_stage_is_valid(self): + """attr='life_stage' is a valid value.""" + interval = AttrInterval( + animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA", + attr="life_stage", + value="adult", + start_utc=1704067200000, + ) + assert interval.attr == "life_stage" + + def test_attr_repro_status_is_valid(self): + """attr='repro_status' is a valid value.""" + interval = AttrInterval( + animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA", + attr="repro_status", + value="intact", + start_utc=1704067200000, + ) + assert interval.attr == "repro_status" + + def test_attr_status_is_valid(self): + """attr='status' is a valid value.""" + interval = AttrInterval( + animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA", + attr="status", + value="alive", + start_utc=1704067200000, + ) + assert interval.attr == "status" + + def test_attr_invalid_raises_error(self): + """Invalid attr value raises ValidationError.""" + with pytest.raises(ValidationError): + AttrInterval( + animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA", + attr="invalid_attr", + value="value", + start_utc=1704067200000, + )