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:
2025-12-28 19:51:28 +00:00
parent 7e9c370c80
commit e3d65283da
4 changed files with 671 additions and 0 deletions

View 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