feat: add interval projection schema and models
Add time-series tracking tables for animal location, tag, and attribute history. Each interval represents a period when an animal had a specific state, with open intervals (end_utc=NULL) for current state. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
56
migrations/0004-interval-projections.sql
Normal file
56
migrations/0004-interval-projections.sql
Normal file
@@ -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)
|
||||
);
|
||||
44
src/animaltrack/models/intervals.py
Normal file
44
src/animaltrack/models/intervals.py
Normal file
@@ -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
|
||||
396
tests/test_migration_interval_projections.py
Normal file
396
tests/test_migration_interval_projections.py
Normal file
@@ -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
|
||||
175
tests/test_models_intervals.py
Normal file
175
tests/test_models_intervals.py
Normal file
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user