Server: Frame.panel_type (new column + migration) is auto-derived from the device's reported board (X-Frame-Board), never user-set -- the panel is a property of the hardware, not a picker in the UI. image_pipeline's packing/render pipeline is parameterized by panel geometry instead of hardcoded 800x480 globals, with the real confirmed 13.3in geometry (1600x1200) registered alongside the original 7.3in panel. Existing 7.3in frames are unaffected (column default + board mapping both resolve to the original panel). Board identifiers are also renamed (devkit/xiao -> devkit_esp32c6/ xiao_esp32c6, plus new "ee02") since the EE02 board also carries a XIAO module -- "xiao" alone stopped disambiguating hardware. The server keeps accepting the legacy bare names indefinitely for already-flashed devices. Firmware: scaffolds a third build target (ee02, ESP32-S3 -- a real chip-target change, not just a same-chip Kconfig variant like xiao) and a new epd13in3e driver component skeleton. The actual panel init/LUT/ refresh register sequence isn't ported from vendor demo code yet (none was available), so that component deliberately fails to compile (#error) rather than risk sending unverified register values to real hardware -- devkit/xiao are unaffected and build identically to before. CI's ee02 build step is continue-on-error for the same reason.
659 lines
31 KiB
Python
659 lines
31 KiB
Python
"""migration.py structural sanity: MIGRATIONS is well-formed, a fresh
|
|
install lands on the latest schema version with frame #1 + server
|
|
settings seeded, and re-running run_migrations() is a true no-op (it's
|
|
called unconditionally at every app.main import -- see main.py -- so it
|
|
has to tolerate being invoked against an already-current database)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
from sqlalchemy import inspect, select, text
|
|
|
|
from app import db as db_module
|
|
from app import grid
|
|
from app.migration import MIGRATIONS, run_migrations
|
|
from app.models import (
|
|
CalendarWidgetConfig,
|
|
Frame,
|
|
FrameButtonAction,
|
|
FrameCalendar,
|
|
FrameTaskList,
|
|
PhotoWidgetConfig,
|
|
ServerSettings,
|
|
TaskWidgetConfig,
|
|
Widget,
|
|
WhiteboardWidgetConfig,
|
|
)
|
|
|
|
from .conftest import make_user
|
|
|
|
# Columns migration 41 drops from `frames` -- a fresh-install create_all()
|
|
# copy (what every db_session fixture starts from) already reflects
|
|
# today's models.py, i.e. the post-41 shape without these, so a test that
|
|
# wants to simulate a pre-41 database has to add them back itself before
|
|
# setting schema_version below 41 and calling run_migrations() -- same
|
|
# "frames isn't dropped/recreated by these replay tests" situation
|
|
# test_migration_29/30's own comments describe, just for columns being
|
|
# removed instead of added.
|
|
_LEGACY_FRAME_COLUMNS = [
|
|
"mode TEXT NOT NULL DEFAULT 'photos'",
|
|
"album_id TEXT NOT NULL DEFAULT ''",
|
|
"photo_order TEXT NOT NULL DEFAULT 'sequential'",
|
|
"display_mode TEXT NOT NULL DEFAULT 'crop_faces'",
|
|
"queue_target_len INTEGER NOT NULL DEFAULT 20",
|
|
"current_asset_id TEXT NOT NULL DEFAULT ''",
|
|
"current_asset_set_at REAL NOT NULL DEFAULT 0.0",
|
|
"queue TEXT NOT NULL DEFAULT '[]'",
|
|
"queue_cursor INTEGER NOT NULL DEFAULT 0",
|
|
"history TEXT NOT NULL DEFAULT '[]'",
|
|
"excluded_asset_ids TEXT NOT NULL DEFAULT '[]'",
|
|
"calendar_view TEXT NOT NULL DEFAULT 'agenda'",
|
|
"calendar_week_start INTEGER NOT NULL DEFAULT 0",
|
|
"calendar_photo_inlay INTEGER NOT NULL DEFAULT 0",
|
|
"calendar_browse_offset INTEGER NOT NULL DEFAULT 0",
|
|
"calendar_checked_at REAL NOT NULL DEFAULT 0.0",
|
|
"calendar_cached_events TEXT",
|
|
"calendar_fetch_summary TEXT NOT NULL DEFAULT ''",
|
|
"calendar_weather_enabled INTEGER NOT NULL DEFAULT 0",
|
|
"calendar_weather_units TEXT NOT NULL DEFAULT 'fahrenheit'",
|
|
"calendar_weather_cities TEXT",
|
|
"calendar_weather_checked_at REAL NOT NULL DEFAULT 0.0",
|
|
"calendar_weather_cached TEXT",
|
|
"calendar_week_days INTEGER NOT NULL DEFAULT 7",
|
|
"calendar_week_layout TEXT NOT NULL DEFAULT 'horizontal'",
|
|
"calendar_week_start_offset INTEGER NOT NULL DEFAULT 0",
|
|
"calendar_tasks_enabled INTEGER NOT NULL DEFAULT 0",
|
|
"calendar_tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL",
|
|
"calendar_tasks_calendar_key TEXT",
|
|
"calendar_tasks_checked_at REAL NOT NULL DEFAULT 0.0",
|
|
"calendar_tasks_cached TEXT",
|
|
"whiteboard_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL",
|
|
"whiteboard_url TEXT NOT NULL DEFAULT ''",
|
|
"whiteboard_checked_at REAL NOT NULL DEFAULT 0.0",
|
|
"whiteboard_cached_image BLOB",
|
|
"legacy_token_enabled INTEGER NOT NULL DEFAULT 0",
|
|
]
|
|
|
|
|
|
def _add_legacy_frame_columns(conn) -> None:
|
|
existing = {c["name"] for c in inspect(db_module.engine).get_columns("frames")}
|
|
for col_def in _LEGACY_FRAME_COLUMNS:
|
|
if col_def.split()[0] not in existing:
|
|
conn.execute(text(f"ALTER TABLE frames ADD COLUMN {col_def}"))
|
|
|
|
|
|
def test_migrations_list_is_sequential_and_unique():
|
|
versions = [v for v, _ in MIGRATIONS]
|
|
assert versions == sorted(versions)
|
|
assert len(versions) == len(set(versions))
|
|
assert versions == list(range(1, len(versions) + 1))
|
|
|
|
|
|
def test_fresh_install_lands_on_latest_version(db_session):
|
|
with db_module.engine.connect() as conn:
|
|
row = conn.execute(text("SELECT version FROM schema_version")).fetchone()
|
|
assert row is not None
|
|
assert row[0] == MIGRATIONS[-1][0]
|
|
|
|
|
|
def test_fresh_install_seeds_frame_one_and_server_settings(db_session):
|
|
frame = db_session.get(Frame, 1)
|
|
assert frame is not None
|
|
assert frame.name
|
|
|
|
settings = db_session.get(ServerSettings, 1)
|
|
assert settings is not None
|
|
|
|
|
|
def test_rerunning_migrations_is_a_no_op(db_session):
|
|
frame_count_before = len(db_session.query(Frame).all())
|
|
run_migrations()
|
|
run_migrations()
|
|
frame_count_after = len(db_session.query(Frame).all())
|
|
assert frame_count_before == frame_count_after == 1
|
|
|
|
|
|
def test_expected_columns_exist_on_current_schema():
|
|
"""A light spot-check, not exhaustive -- one column from a handful of
|
|
the more recent migrations, to catch an ALTER that silently didn't
|
|
apply (e.g. a typo'd table/column name in a migration function)."""
|
|
inspector = inspect(db_module.engine)
|
|
user_columns = {c["name"] for c in inspector.get_columns("users")}
|
|
frame_columns = {c["name"] for c in inspector.get_columns("frames")}
|
|
task_widget_columns = {c["name"] for c in inspector.get_columns("task_widget_configs")}
|
|
|
|
assert "webdav_base_url" in user_columns # migration 15
|
|
assert "webdav_username" in user_columns # migration 14
|
|
assert "calendar_caldav_url" in user_columns
|
|
assert "name" in task_widget_columns # migration 19
|
|
assert "static_widget_configs" in inspector.get_table_names() # migration 20
|
|
assert "text_widget_configs" in inspector.get_table_names() # migration 21
|
|
text_widget_columns = {c["name"] for c in inspector.get_columns("text_widget_configs")}
|
|
assert "font_family" in text_widget_columns # migration 22
|
|
assert "weather_widget_configs" in inspector.get_table_names() # migration 24
|
|
weather_widget_columns = {c["name"] for c in inspector.get_columns("weather_widget_configs")}
|
|
assert {"mode", "provider", "city_latitude", "cities"} <= weather_widget_columns
|
|
assert "battery_widget_configs" in inspector.get_table_names() # migration 25
|
|
battery_widget_columns = {c["name"] for c in inspector.get_columns("battery_widget_configs")}
|
|
assert "mode" in battery_widget_columns
|
|
widget_columns = {c["name"] for c in inspector.get_columns("widgets")}
|
|
assert {"border_style", "border_thickness", "border_color_index"} <= widget_columns # migration 26
|
|
photo_widget_columns = {c["name"] for c in inspector.get_columns("photo_widget_configs")}
|
|
assert "locked" in photo_widget_columns # migration 27
|
|
button_action_indexes = {idx["name"] for idx in inspector.get_indexes("frame_button_actions")}
|
|
assert "ix_frame_button_actions_widget_button" in button_action_indexes # migration 28
|
|
assert {"hold_duration_ms", "next_hold_action", "back_hold_action", "last_cycled_layout_id"} <= frame_columns
|
|
assert {"last_displayed_image", "last_displayed_at"} <= frame_columns # migration 30
|
|
assert "render_style" in weather_widget_columns # migration 31
|
|
assert {"photo_palette_rgb", "photo_dither_strength"} <= frame_columns # migration 32
|
|
assert "render_style" in battery_widget_columns # migration 33
|
|
assert "render_style" in text_widget_columns # migration 34
|
|
assert "render_style" in task_widget_columns # migration 35
|
|
static_widget_columns = {c["name"] for c in inspector.get_columns("static_widget_configs")}
|
|
assert "render_style" in static_widget_columns # migration 36
|
|
whiteboard_widget_columns = {c["name"] for c in inspector.get_columns("whiteboard_widget_configs")}
|
|
assert "render_style" in whiteboard_widget_columns # migration 37
|
|
calendar_widget_columns = {c["name"] for c in inspector.get_columns("calendar_widget_configs")}
|
|
assert "render_style" in calendar_widget_columns # migration 38
|
|
assert "theme" in frame_columns # migration 39
|
|
assert "font_scale" in widget_columns # migration 40
|
|
assert not {"mode", "album_id", "current_asset_id", "calendar_view", "whiteboard_url",
|
|
"legacy_token_enabled"} & frame_columns # migration 41
|
|
|
|
|
|
# --- widget system: fresh-install default widget, and migration 41's
|
|
# raw-SQL backfill safety net for a pre-widget-system database ---
|
|
|
|
|
|
def test_fresh_install_creates_a_default_photos_widget_with_default_buttons(db_session):
|
|
frame = db_session.get(Frame, 1)
|
|
|
|
widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
|
|
assert len(widgets) == 1
|
|
widget = widgets[0]
|
|
assert widget.widget_type == "photos"
|
|
assert (widget.x, widget.y, widget.w, widget.h) == grid.full_panel_rect(frame.orientation)
|
|
|
|
config = db_session.get(PhotoWidgetConfig, widget.id)
|
|
assert config is not None
|
|
assert config.album_id == ""
|
|
|
|
actions = db_session.scalars(select(FrameButtonAction).where(FrameButtonAction.frame_id == frame.id)).all()
|
|
assert {a.button: a.action for a in actions} == {"next": "advance", "back": "back"}
|
|
assert all(a.widget_id == widget.id for a in actions)
|
|
|
|
|
|
def test_rerunning_migrations_does_not_duplicate_widgets(db_session):
|
|
run_migrations()
|
|
run_migrations()
|
|
widgets = db_session.scalars(select(Widget).where(Widget.frame_id == 1)).all()
|
|
assert len(widgets) == 1
|
|
|
|
|
|
def test_calendar_photo_inlay_frame_backfills_into_two_widgets(db_session):
|
|
"""Reproduces the old fixed 50/50 inlay split as two independent,
|
|
non-overlapping widgets instead of silently dropping the photo half
|
|
on upgrade -- see models.py's CalendarWidgetConfig docstring. Exercises
|
|
_migration_41's raw-SQL backfill safety net: a frame whose legacy
|
|
Frame columns (pre-widget-system) still carry real data but which
|
|
somehow has no Widget yet."""
|
|
frame = Frame(name="Inlay Frame", device_token="tok-inlay", manage_token="mtok-inlay",
|
|
orientation="landscape", created_at=time.time())
|
|
db_session.add(frame)
|
|
db_session.flush()
|
|
frame_id = frame.id
|
|
db_session.commit()
|
|
|
|
with db_module.engine.begin() as conn:
|
|
_add_legacy_frame_columns(conn)
|
|
conn.execute(text(
|
|
"UPDATE frames SET mode='calendar', calendar_view='week', calendar_photo_inlay=1, "
|
|
"album_id='album-123', current_asset_id='asset-1', queue='[\"asset-1\", \"asset-2\"]' "
|
|
"WHERE id = :id"
|
|
), {"id": frame_id})
|
|
conn.execute(text("UPDATE schema_version SET version = 40"))
|
|
|
|
run_migrations()
|
|
|
|
widgets = db_session.scalars(
|
|
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
|
).all()
|
|
assert len(widgets) == 2
|
|
cal_widget, photo_widget = widgets
|
|
assert cal_widget.widget_type == "calendar"
|
|
assert photo_widget.widget_type == "photos"
|
|
|
|
cal_rect = (cal_widget.x, cal_widget.y, cal_widget.w, cal_widget.h)
|
|
photo_rect = (photo_widget.x, photo_widget.y, photo_widget.w, photo_widget.h)
|
|
assert not grid.overlaps(cal_rect, photo_rect)
|
|
assert cal_widget.w + photo_widget.w == grid.grid_dims("landscape")[0]
|
|
assert cal_widget.h == photo_widget.h == grid.grid_dims("landscape")[1]
|
|
|
|
cal_config = db_session.get(CalendarWidgetConfig, cal_widget.id)
|
|
assert cal_config.view == "week"
|
|
photo_config = db_session.get(PhotoWidgetConfig, photo_widget.id)
|
|
assert photo_config.album_id == "album-123"
|
|
assert photo_config.current_asset_id == "asset-1"
|
|
assert photo_config.queue == ["asset-1", "asset-2"]
|
|
|
|
actions = db_session.scalars(select(FrameButtonAction).where(FrameButtonAction.frame_id == frame.id)).all()
|
|
assert {a.button: a.action for a in actions} == {"next": "advance", "back": "back"}
|
|
assert all(a.widget_id == cal_widget.id for a in actions)
|
|
|
|
|
|
def test_whiteboard_frame_backfills_check_now_on_both_buttons(db_session):
|
|
"""Exercises _migration_41's raw-SQL backfill safety net for a
|
|
whiteboard-mode legacy frame -- same shape as the calendar-inlay case
|
|
above, just the simpler single-widget mode dispatch branch."""
|
|
frame = Frame(name="WB Frame", device_token="tok-wb", manage_token="mtok-wb",
|
|
orientation="portrait", created_at=time.time())
|
|
db_session.add(frame)
|
|
db_session.flush()
|
|
frame_id = frame.id
|
|
db_session.commit()
|
|
|
|
with db_module.engine.begin() as conn:
|
|
_add_legacy_frame_columns(conn)
|
|
conn.execute(text(
|
|
"UPDATE frames SET mode='whiteboard', "
|
|
"whiteboard_url='https://example.com/board.whiteboard' WHERE id = :id"
|
|
), {"id": frame_id})
|
|
conn.execute(text("UPDATE schema_version SET version = 40"))
|
|
|
|
run_migrations()
|
|
|
|
widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
|
|
assert len(widgets) == 1
|
|
widget = widgets[0]
|
|
assert widget.widget_type == "whiteboard"
|
|
assert (widget.x, widget.y, widget.w, widget.h) == grid.full_panel_rect("portrait")
|
|
|
|
config = db_session.get(WhiteboardWidgetConfig, widget.id)
|
|
assert config.url == "https://example.com/board.whiteboard"
|
|
|
|
actions = db_session.scalars(select(FrameButtonAction).where(FrameButtonAction.frame_id == frame.id)).all()
|
|
assert {a.action for a in actions} == {"check_now"}
|
|
|
|
|
|
def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db_session):
|
|
"""Exercises _migration_16's actual CREATE TABLE statements (the real
|
|
"existing production database upgrading past this migration"
|
|
scenario) rather than the fresh-install create_all() shortcut, which
|
|
every other test in this file goes through instead. frame_calendars
|
|
also gets rebuilt back to its pre-rekey (frame_id-keyed) shape,
|
|
matching what _migration_9/_migration_11 originally produced, so
|
|
_ensure_frame_calendars_rekeyed has a real frame_id-shaped table to
|
|
migrate."""
|
|
with db_module.engine.begin() as conn:
|
|
# static_widget_configs/text_widget_configs are migration 20/21
|
|
# tables, saved_layouts/saved_layout_widgets/saved_layout_
|
|
# sources/saved_layout_button_actions are migration 23's,
|
|
# weather_widget_configs is migration 24's, and
|
|
# battery_widget_configs is migration 25's (all post-16, like the
|
|
# rest of this list) -- dropped here too so a real version-15
|
|
# database is what's actually being simulated, not "version 15
|
|
# plus tables that wouldn't exist yet". Harmless to omit as long
|
|
# as no migration after the one that creates a table also ALTERs
|
|
# or re-CREATEs it (that's what let a create_all-based migration
|
|
# go unlisted safely so far), but static_widget_configs/
|
|
# text_widget_configs/weather_widget_configs/battery_widget_
|
|
# configs all use a raw CREATE TABLE (not create_all -- see
|
|
# migration 20's own docstring on why), so an already-present one
|
|
# is a real "table already exists" collision, not a silent no-op.
|
|
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
|
|
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
|
|
"text_widget_configs", "saved_layout_button_actions", "saved_layout_sources",
|
|
"saved_layout_widgets", "saved_layouts", "weather_widget_configs",
|
|
"battery_widget_configs", "widgets"):
|
|
conn.execute(text(f"DROP TABLE {table}"))
|
|
conn.execute(text("DROP TABLE frame_calendars"))
|
|
conn.execute(text(
|
|
"CREATE TABLE frame_calendars ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
|
|
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
|
|
"calendar_key TEXT NOT NULL, "
|
|
"calendar_label TEXT NOT NULL DEFAULT '', "
|
|
"included INTEGER NOT NULL DEFAULT 1, "
|
|
"color_index INTEGER)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
|
|
))
|
|
_add_legacy_frame_columns(conn)
|
|
conn.execute(text("UPDATE schema_version SET version = 15"))
|
|
|
|
frame = Frame(name="Upgrading Frame", device_token="tok-up", manage_token="mtok-up",
|
|
created_at=time.time())
|
|
db_session.add(frame)
|
|
db_session.flush()
|
|
user = make_user(db_session, "legacy-owner")
|
|
db_session.commit()
|
|
frame_id, user_id = frame.id, user.id
|
|
|
|
with db_module.engine.begin() as conn:
|
|
conn.execute(text(
|
|
"UPDATE frames SET mode='photos', album_id='legacy-album', current_asset_id='legacy-asset', "
|
|
"queue='[\"legacy-asset\", \"next-asset\"]' WHERE id = :id"
|
|
), {"id": frame_id})
|
|
|
|
# An orphaned frame_calendars row (this frame's mode was never
|
|
# "calendar", so it has no calendar widget for _ensure_frame_
|
|
# calendars_rekeyed to attach it to) -- exercises that it's dropped
|
|
# cleanly rather than raising.
|
|
with db_module.engine.begin() as conn:
|
|
conn.execute(text(
|
|
"INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included) "
|
|
"VALUES (:frame_id, :user_id, 'ics', 'Legacy Cal', 1)"
|
|
), {"frame_id": frame_id, "user_id": user_id})
|
|
|
|
run_migrations()
|
|
|
|
with db_module.engine.begin() as conn:
|
|
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
|
|
assert version == MIGRATIONS[-1][0]
|
|
|
|
widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame_id)).all()
|
|
assert len(widgets) == 1
|
|
config = db_session.get(PhotoWidgetConfig, widgets[0].id)
|
|
assert config.album_id == "legacy-album"
|
|
assert config.current_asset_id == "legacy-asset"
|
|
assert config.queue == ["legacy-asset", "next-asset"]
|
|
|
|
|
|
def test_migration_28_dedupes_and_enforces_one_action_per_widget_per_button(db_session):
|
|
"""Exercises _migration_28's real ALTER path: a pre-existing database
|
|
with more than one FrameButtonAction row bound to the same (widget,
|
|
button) -- the old frame-level "Button assignments" card allowed
|
|
this -- gets de-duped down to one row (MIN(id) survives) before the
|
|
new unique index is created, rather than the migration failing
|
|
outright on "UNIQUE constraint failed"."""
|
|
frame = db_session.get(Frame, 1)
|
|
widget = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).first()
|
|
|
|
with db_module.engine.begin() as conn:
|
|
# The fresh-install create_all() copy already has the unique
|
|
# index (it reflects models.py's current shape) -- drop it first
|
|
# so the duplicate insert below doesn't just fail immediately,
|
|
# simulating a real pre-migration-28 database.
|
|
conn.execute(text("DROP INDEX IF EXISTS ix_frame_button_actions_widget_button"))
|
|
conn.execute(text(
|
|
"INSERT INTO frame_button_actions (frame_id, button, widget_id, action, sort_order, created_at) "
|
|
"VALUES (:frame_id, 'next', :widget_id, 'back', 1, 0)"
|
|
), {"frame_id": frame.id, "widget_id": widget.id})
|
|
conn.execute(text("UPDATE schema_version SET version = 27"))
|
|
|
|
run_migrations()
|
|
|
|
with db_module.engine.connect() as conn:
|
|
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
|
|
assert version == MIGRATIONS[-1][0]
|
|
|
|
actions = db_session.scalars(
|
|
select(FrameButtonAction).where(FrameButtonAction.widget_id == widget.id, FrameButtonAction.button == "next")
|
|
).all()
|
|
assert len(actions) == 1
|
|
|
|
inspector = inspect(db_module.engine)
|
|
indexes = {idx["name"] for idx in inspector.get_indexes("frame_button_actions")}
|
|
assert "ix_frame_button_actions_widget_button" in indexes
|
|
|
|
|
|
def test_migration_29_adds_hold_action_columns_to_an_existing_database(db_session):
|
|
"""Exercises _migration_29's real guarded ALTER path (frames isn't
|
|
dropped/recreated by the pre-widget-system replay tests, so its
|
|
columns must be added defensively, same reasoning as migration
|
|
26/27's own comments)."""
|
|
with db_module.engine.begin() as conn:
|
|
conn.execute(text("UPDATE schema_version SET version = 28"))
|
|
|
|
run_migrations()
|
|
|
|
with db_module.engine.connect() as conn:
|
|
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
|
|
assert version == MIGRATIONS[-1][0]
|
|
|
|
frame = db_session.get(Frame, 1)
|
|
assert frame.hold_duration_ms == 3000
|
|
assert frame.next_hold_action is None
|
|
assert frame.back_hold_action is None
|
|
assert frame.last_cycled_layout_id is None
|
|
|
|
|
|
def test_migration_30_adds_now_displaying_columns_to_an_existing_database(db_session):
|
|
"""Exercises _migration_30's real guarded ALTER path (frames isn't
|
|
dropped/recreated by the pre-widget-system replay tests, so its
|
|
columns must be added defensively, same reasoning as migration
|
|
26/27/29's own comments)."""
|
|
with db_module.engine.begin() as conn:
|
|
conn.execute(text("UPDATE schema_version SET version = 29"))
|
|
|
|
run_migrations()
|
|
|
|
with db_module.engine.connect() as conn:
|
|
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
|
|
assert version == MIGRATIONS[-1][0]
|
|
|
|
frame = db_session.get(Frame, 1)
|
|
assert frame.last_displayed_image is None
|
|
assert frame.last_displayed_at == 0.0
|
|
|
|
|
|
def test_migration_40_adds_font_scale_to_an_existing_database(db_session):
|
|
"""Exercises _migration_40's real guarded ALTER path (widgets isn't
|
|
dropped/recreated by the pre-widget-system replay tests, so its
|
|
columns must be added defensively, same reasoning as migration
|
|
26/27/29/30's own comments)."""
|
|
with db_module.engine.begin() as conn:
|
|
conn.execute(text("UPDATE schema_version SET version = 39"))
|
|
|
|
run_migrations()
|
|
|
|
with db_module.engine.connect() as conn:
|
|
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
|
|
assert version == MIGRATIONS[-1][0]
|
|
|
|
widget = db_session.query(Widget).filter(Widget.frame_id == 1).first()
|
|
assert widget.font_scale == 1.0
|
|
|
|
|
|
def test_migration_42_adds_panel_type_to_an_existing_database(db_session):
|
|
"""Exercises _migration_42's real guarded ALTER path (frames isn't
|
|
dropped/recreated by this replay -- migration 41 already ran -- so
|
|
the column must be added defensively, same reasoning as migration
|
|
39/40's own comments)."""
|
|
with db_module.engine.begin() as conn:
|
|
conn.execute(text("UPDATE schema_version SET version = 41"))
|
|
|
|
run_migrations()
|
|
|
|
with db_module.engine.connect() as conn:
|
|
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
|
|
assert version == MIGRATIONS[-1][0]
|
|
|
|
frame = db_session.query(Frame).filter(Frame.id == 1).first()
|
|
assert frame.panel_type == "epd7in3e"
|
|
|
|
|
|
def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session):
|
|
"""Exercises _migration_17 and _migration_18's actual data-extraction
|
|
SQL back to back (the real "existing widget-system database
|
|
upgrading past both migrations" scenario, since both apply in the
|
|
same run_migrations() call here): a calendar_widget_configs row in
|
|
its pre-17 shape (tasks_* columns still present, still holding a
|
|
configured task source) should come out the other side as a sibling
|
|
`tasks` widget with that source carried forward as its first
|
|
FrameTaskList row (migration 17's extraction, then migration 18's
|
|
further extraction of the single-source TaskWidgetConfig it produces
|
|
into FrameTaskList), with calendar_widget_configs no longer having
|
|
tasks_* columns and TaskWidgetConfig no longer having user_id/
|
|
calendar_key columns either."""
|
|
with db_module.engine.begin() as conn:
|
|
# static_widget_configs/text_widget_configs/saved_layout_*/
|
|
# weather_widget_configs/battery_widget_configs dropped too --
|
|
# see the comment on the identical setup in
|
|
# test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above (migrations
|
|
# 20/21/23/24/25's raw CREATE TABLE collides with an already-present table
|
|
# otherwise, since this test replays 17 through 25 and none of
|
|
# these tables would really exist yet at a genuine pre-migration-17
|
|
# schema_version).
|
|
conn.execute(text("DROP TABLE task_widget_configs"))
|
|
conn.execute(text("DROP TABLE frame_task_lists"))
|
|
conn.execute(text("DROP TABLE calendar_widget_configs"))
|
|
conn.execute(text("DROP TABLE static_widget_configs"))
|
|
conn.execute(text("DROP TABLE text_widget_configs"))
|
|
conn.execute(text("DROP TABLE saved_layout_button_actions"))
|
|
conn.execute(text("DROP TABLE saved_layout_sources"))
|
|
conn.execute(text("DROP TABLE saved_layout_widgets"))
|
|
conn.execute(text("DROP TABLE saved_layouts"))
|
|
conn.execute(text("DROP TABLE weather_widget_configs"))
|
|
conn.execute(text("DROP TABLE battery_widget_configs"))
|
|
conn.execute(text(
|
|
"CREATE TABLE calendar_widget_configs ("
|
|
"widget_id INTEGER PRIMARY KEY REFERENCES widgets(id) ON DELETE CASCADE, "
|
|
"view TEXT NOT NULL DEFAULT 'agenda', "
|
|
"week_start INTEGER NOT NULL DEFAULT 0, "
|
|
"browse_offset INTEGER NOT NULL DEFAULT 0, "
|
|
"checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"cached_events TEXT, "
|
|
"fetch_summary TEXT NOT NULL DEFAULT '', "
|
|
"weather_enabled INTEGER NOT NULL DEFAULT 0, "
|
|
"weather_units TEXT NOT NULL DEFAULT 'fahrenheit', "
|
|
"weather_cities TEXT, "
|
|
"weather_checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"weather_cached TEXT, "
|
|
"week_days INTEGER NOT NULL DEFAULT 7, "
|
|
"week_layout TEXT NOT NULL DEFAULT 'horizontal', "
|
|
"week_start_offset INTEGER NOT NULL DEFAULT 0, "
|
|
"tasks_enabled INTEGER NOT NULL DEFAULT 0, "
|
|
"tasks_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, "
|
|
"tasks_calendar_key TEXT, "
|
|
"tasks_checked_at REAL NOT NULL DEFAULT 0.0, "
|
|
"tasks_cached TEXT)"
|
|
))
|
|
conn.execute(text("UPDATE schema_version SET version = 16"))
|
|
|
|
user = make_user(db_session, "task-owner")
|
|
user_id = user.id
|
|
|
|
# Frame #1's auto-migrated widget is a full-panel "photos" one --
|
|
# remove it to free up grid space for the calendar widget below (and
|
|
# for the tasks widget this migration is expected to carve out).
|
|
db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").delete()
|
|
db_session.commit()
|
|
|
|
calendar_widget = Widget(frame_id=1, widget_type="calendar", x=0, y=0, w=3, h=2,
|
|
sort_order=1, created_at=time.time())
|
|
db_session.add(calendar_widget)
|
|
db_session.flush()
|
|
calendar_widget_id = calendar_widget.id
|
|
db_session.commit()
|
|
|
|
with db_module.engine.begin() as conn:
|
|
conn.execute(text(
|
|
"INSERT INTO calendar_widget_configs "
|
|
"(widget_id, tasks_enabled, tasks_user_id, tasks_calendar_key, tasks_checked_at, tasks_cached) "
|
|
"VALUES (:widget_id, 1, :user_id, 'caldav:/some/tasks/', 123.0, '[{\"summary\": \"Buy milk\"}]')"
|
|
), {"widget_id": calendar_widget_id, "user_id": user_id})
|
|
|
|
run_migrations()
|
|
|
|
with db_module.engine.begin() as conn:
|
|
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
|
|
assert version == MIGRATIONS[-1][0]
|
|
|
|
columns = {c["name"] for c in inspect(db_module.engine).get_columns("calendar_widget_configs")}
|
|
assert not any(c.startswith("tasks_") for c in columns)
|
|
task_cfg_columns = {c["name"] for c in inspect(db_module.engine).get_columns("task_widget_configs")}
|
|
assert "user_id" not in task_cfg_columns and "calendar_key" not in task_cfg_columns
|
|
assert "show_completed" in task_cfg_columns
|
|
|
|
task_widgets = db_session.scalars(
|
|
select(Widget).where(Widget.frame_id == 1, Widget.widget_type == "tasks")
|
|
).all()
|
|
assert len(task_widgets) == 1
|
|
task_widget = task_widgets[0]
|
|
|
|
cfg = db_session.get(TaskWidgetConfig, task_widget.id)
|
|
assert cfg.checked_at == 123.0
|
|
assert cfg.cached == [{"summary": "Buy milk"}]
|
|
assert cfg.show_completed is False
|
|
|
|
task_list = db_session.scalars(
|
|
select(FrameTaskList).where(FrameTaskList.widget_id == task_widget.id)
|
|
).one()
|
|
assert task_list.user_id == user_id
|
|
assert task_list.calendar_key == "caldav:/some/tasks/"
|
|
assert task_list.included is True
|
|
|
|
# Auto-placed without overlapping the calendar widget it was split from.
|
|
cal = db_session.get(Widget, calendar_widget_id)
|
|
assert not grid.overlaps((cal.x, cal.y, cal.w, cal.h),
|
|
(task_widget.x, task_widget.y, task_widget.w, task_widget.h))
|
|
|
|
|
|
def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(db_session):
|
|
"""A frame whose mode was "calendar" (not "photos") gets a calendar
|
|
widget from _ensure_widgets_backfilled -- _ensure_frame_calendars_
|
|
rekeyed should then attach the pre-existing frame_id-keyed
|
|
FrameCalendar row inserted below to that widget's id, not drop it.
|
|
This is the regression case for the ordering bug the two migration
|
|
tests above's setup was designed to catch: the rekey must run AFTER
|
|
the widget backfill, not as a numbered migration racing ahead of
|
|
it (see _ensure_frame_calendars_rekeyed's own docstring)."""
|
|
with db_module.engine.begin() as conn:
|
|
# static_widget_configs/text_widget_configs/saved_layout_*/
|
|
# weather_widget_configs dropped too -- see the comment on the
|
|
# identical setup in
|
|
# test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database above.
|
|
for table in ("frame_button_actions", "whiteboard_widget_configs", "task_widget_configs", "frame_task_lists",
|
|
"calendar_widget_configs", "photo_widget_configs", "static_widget_configs",
|
|
"text_widget_configs", "saved_layout_button_actions", "saved_layout_sources",
|
|
"saved_layout_widgets", "saved_layouts", "weather_widget_configs",
|
|
"battery_widget_configs", "widgets"):
|
|
conn.execute(text(f"DROP TABLE {table}"))
|
|
conn.execute(text("DROP TABLE frame_calendars"))
|
|
conn.execute(text(
|
|
"CREATE TABLE frame_calendars ("
|
|
"id INTEGER PRIMARY KEY, "
|
|
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
|
|
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
|
|
"calendar_key TEXT NOT NULL, "
|
|
"calendar_label TEXT NOT NULL DEFAULT '', "
|
|
"included INTEGER NOT NULL DEFAULT 1, "
|
|
"color_index INTEGER)"
|
|
))
|
|
conn.execute(text(
|
|
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
|
|
))
|
|
_add_legacy_frame_columns(conn)
|
|
conn.execute(text("UPDATE schema_version SET version = 15"))
|
|
|
|
frame = Frame(name="Calendar Frame", device_token="tok-cal", manage_token="mtok-cal",
|
|
created_at=time.time())
|
|
db_session.add(frame)
|
|
db_session.flush()
|
|
user = make_user(db_session, "cal-owner")
|
|
db_session.commit()
|
|
frame_id, user_id = frame.id, user.id
|
|
|
|
with db_module.engine.begin() as conn:
|
|
conn.execute(text("UPDATE frames SET mode='calendar' WHERE id = :id"), {"id": frame_id})
|
|
conn.execute(text(
|
|
"INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included, color_index) "
|
|
"VALUES (:frame_id, :user_id, 'ics', 'Legacy Cal', 1, 3)"
|
|
), {"frame_id": frame_id, "user_id": user_id})
|
|
|
|
run_migrations()
|
|
|
|
widget = db_session.scalars(
|
|
select(Widget).where(Widget.frame_id == frame_id, Widget.widget_type == "calendar")
|
|
).one()
|
|
row = db_session.scalars(select(FrameCalendar).where(FrameCalendar.widget_id == widget.id)).one()
|
|
assert row.user_id == user_id
|
|
assert row.calendar_key == "ics"
|
|
assert row.calendar_label == "Legacy Cal"
|
|
assert row.included is True
|
|
assert row.color_index == 3
|