Improve space launch email, add tests.

This commit is contained in:
Edward Betts 2025-08-12 12:13:27 +01:00
parent 808f5c1d22
commit ebceb4cb51
3 changed files with 667 additions and 333 deletions

View file

@ -221,12 +221,11 @@ def format_probability_change(old_val: int, new_val: int) -> str:
return f"Launch probability changed from {old_val}% to {new_val}%"
def handle_value_changes(differences: StrDict) -> tuple[list[str], set[str]]:
"""Handle value changes in launch data."""
def format_launch_changes(differences: StrDict) -> str:
"""Convert deepdiff output to human-readable format."""
changes: list[str] = []
processed_paths: set[str] = set()
# Skip fields that aren't useful to users
SKIP_FIELDS = {
"agency_launch_attempt_count",
"agency_launch_attempt_count_year",
@ -238,84 +237,84 @@ def handle_value_changes(differences: StrDict) -> tuple[list[str], set[str]]:
"orbital_launch_attempt_count_year",
}
if "values_changed" not in differences:
return changes, processed_paths
# --- 1. Handle Special Group Value Changes ---
# Process high-level, user-friendly summaries first.
values = differences.get("values_changed", {})
if "root['status']['name']" in values:
old_val = values["root['status']['name']"]["old_value"]
new_val = values["root['status']['name']"]["new_value"]
changes.append(f"Status changed from '{old_val}' to '{new_val}'")
processed_paths.add("root['status']")
for path, change in differences["values_changed"].items():
if any(path.startswith(processed) for processed in processed_paths):
if "root['net_precision']['name']" in values:
old_val = values["root['net_precision']['name']"]["old_value"]
new_val = values["root['net_precision']['name']"]["new_value"]
changes.append(f"Launch precision changed from '{old_val}' to '{new_val}'")
processed_paths.add("root['net_precision']")
# --- 2. Handle Type Changes ---
# This is often more significant than a value change (e.g., probability becoming None).
if "type_changes" in differences:
for path, change in differences["type_changes"].items():
if any(path.startswith(p) for p in processed_paths):
continue
field = path.replace("root['", "").replace("']", "").replace("root.", "")
if field == "probability":
# Use custom formatter only for meaningful None transitions.
if change["old_type"] is type(None) or change["new_type"] is type(None):
changes.append(
format_probability_change(
change["old_value"], change["new_value"]
)
)
else: # For other type changes (e.g., int to str), use the generic message.
changes.append(
f"{field.replace('_', ' ').title()} type changed "
+ f"from {change['old_type'].__name__} to {change['new_type'].__name__}"
)
else:
changes.append(
f"{field.replace('_', ' ').title()} type changed "
+ f"from {change['old_type'].__name__} to {change['new_type'].__name__}"
)
processed_paths.add(path)
# --- 3. Handle Remaining Value Changes ---
for path, change in values.items():
if any(path.startswith(p) for p in processed_paths):
continue
field = path.replace("root['", "").replace("']", "").replace("root.", "")
if field in SKIP_FIELDS:
continue
old_val = change["old_value"]
new_val = change["new_value"]
match field:
case "status['name']":
changes.append(f"Status changed from '{old_val}' to '{new_val}'")
processed_paths.update(
[
"root['status']['id']",
"root['status']['name']",
"root['status']['abbrev']",
"root['status']['description']",
]
)
case x if x.startswith("status["):
continue
case "net_precision['name']":
changes.append(
f"Launch precision changed from '{old_val}' to '{new_val}'"
)
processed_paths.update(
[
"root['net_precision']['id']",
"root['net_precision']['name']",
"root['net_precision']['abbrev']",
"root['net_precision']['description']",
]
)
case x if x.startswith("net_precision["):
continue
case "net":
changes.append(format_datetime_change("Launch time", old_val, new_val))
case "window_start":
changes.append(
format_datetime_change("Launch window start", old_val, new_val)
)
case "window_end":
changes.append(
format_datetime_change("Launch window end", old_val, new_val)
)
case "last_updated":
changes.append(format_datetime_update("Last updated", new_val))
case "name":
changes.append(f"Mission name changed from '{old_val}' to '{new_val}'")
case "probability":
changes.append(format_probability_change(old_val, new_val))
case x if x in SKIP_FIELDS:
continue
case _:
changes.append(f"{field} changed from '{old_val}' to '{new_val}'")
processed_paths.add(path)
return changes, processed_paths
def format_launch_changes(differences: StrDict) -> str:
"""Convert deepdiff output to human-readable format using match/case."""
changes, processed_paths = handle_value_changes(differences)
# Handle other difference types...
# --- 4. Handle Added/Removed Fields ---
if "dictionary_item_added" in differences:
for path in differences["dictionary_item_added"]:
field = path.replace("root['", "").replace("']", "").replace("root.", "")
@ -326,16 +325,9 @@ def format_launch_changes(differences: StrDict) -> str:
field = path.replace("root['", "").replace("']", "").replace("root.", "")
changes.append(f"Field removed: {field.replace('_', ' ').title()}")
if "type_changes" in differences:
for path, change in differences["type_changes"].items():
field = path.replace("root['", "").replace("']", "").replace("root.", "")
changes.append(
f"{field.replace('_', ' ').title()} type changed "
+ f"from {change['old_type'].__name__} to {change['new_type'].__name__}"
)
# Sort changes for deterministic output in tests
return (
"\n".join(f"{change}" for change in changes)
"\n".join(f"{change}" for change in sorted(changes))
if changes
else "No specific changes detected"
)

View file

@ -1,270 +0,0 @@
"""Tests for calendar functionality."""
from datetime import date, datetime, timedelta
from typing import Any
from agenda.calendar import build_events, colors, event_type_color_map
from agenda.event import Event
class TestEventTypeColorMap:
"""Test the event type color mapping."""
def test_event_type_color_map_contains_expected_types(self) -> None:
"""Test that color map contains expected event types."""
expected_types = {
"bank_holiday", "conference", "us_holiday",
"birthday", "waste_schedule"
}
assert set(event_type_color_map.keys()) == expected_types
def test_event_type_color_map_values_are_valid(self) -> None:
"""Test that all color values are valid color names."""
expected_colors = {
"success-subtle", "primary-subtle", "secondary-subtle",
"info-subtle", "danger-subtle"
}
assert set(event_type_color_map.values()).issubset(expected_colors)
class TestColorsDict:
"""Test the colors dictionary."""
def test_colors_contains_expected_keys(self) -> None:
"""Test that colors dict contains expected color keys."""
expected_keys = {
"primary-subtle", "secondary-subtle", "success-subtle",
"info-subtle", "warning-subtle", "danger-subtle"
}
assert set(colors.keys()) == expected_keys
def test_colors_values_are_hex_codes(self) -> None:
"""Test that all color values are valid hex codes."""
for color_value in colors.values():
assert color_value.startswith("#")
assert len(color_value) == 7
# Check that remaining characters are valid hex
hex_part = color_value[1:]
int(hex_part, 16) # This will raise ValueError if not valid hex
class TestBuildEvents:
"""Test the build_events function."""
def test_build_events_empty_list(self) -> None:
"""Test building events with empty list."""
result = build_events([])
assert result == []
def test_build_events_today_event_filtered(self) -> None:
"""Test that 'today' events are filtered out."""
events = [
Event(date=date(2024, 1, 1), name="today", title="Today"),
Event(date=date(2024, 1, 2), name="birthday", title="Birthday"),
]
result = build_events(events)
assert len(result) == 1
assert result[0]["title"] == "🎈 Birthday"
def test_build_events_simple_event(self) -> None:
"""Test building a simple event without time."""
events = [
Event(
date=date(2024, 1, 15),
name="birthday",
title="John's Birthday"
)
]
result = build_events(events)
assert len(result) == 1
item = result[0]
assert item["allDay"] is True
assert item["title"] == "🎈 John's Birthday"
assert item["start"] == "2024-01-15"
assert item["end"] == "2024-01-16" # Next day
assert item["color"] == colors["info-subtle"]
assert item["textColor"] == "black"
def test_build_events_with_time(self) -> None:
"""Test building an event with time."""
event_datetime = datetime(2024, 1, 15, 14, 30)
events = [
Event(
date=event_datetime,
name="meeting",
title="Team Meeting"
)
]
result = build_events(events)
assert len(result) == 1
item = result[0]
assert item["allDay"] is False
assert item["title"] == "Team Meeting"
assert item["start"] == "2024-01-15T14:30:00"
assert item["end"] == "2024-01-15T15:00:00" # 30 minutes later
def test_build_events_with_end_date(self) -> None:
"""Test building an event with end date."""
events = [
Event(
date=date(2024, 1, 15),
name="conference",
title="Tech Conference",
end_date=date(2024, 1, 17)
)
]
result = build_events(events)
assert len(result) == 1
item = result[0]
assert item["allDay"] is True
assert item["start"] == "2024-01-15"
assert item["end"] == "2024-01-18" # End date + 1 day
def test_build_events_with_time_and_end_date(self) -> None:
"""Test building an event with time and end date."""
start_datetime = datetime(2024, 1, 15, 9, 0)
end_datetime = datetime(2024, 1, 15, 17, 0)
events = [
Event(
date=start_datetime,
name="workshop",
title="Python Workshop",
end_date=end_datetime
)
]
result = build_events(events)
assert len(result) == 1
item = result[0]
assert item["allDay"] is False
assert item["start"] == "2024-01-15T09:00:00"
assert item["end"] == "2024-01-15T17:00:00"
def test_build_events_with_url(self) -> None:
"""Test building an event with URL."""
events = [
Event(
date=date(2024, 1, 15),
name="conference",
title="Tech Conference",
url="https://example.com"
)
]
result = build_events(events)
assert len(result) == 1
item = result[0]
assert item["url"] == "https://example.com"
def test_build_events_accommodation(self) -> None:
"""Test building accommodation events."""
events = [
Event(
date=datetime(2024, 1, 15, 15, 0), # Check-in time
name="accommodation",
title="Hotel Stay",
end_date=datetime(2024, 1, 17, 11, 0), # Check-out time
url="https://hotel.com"
)
]
result = build_events(events)
# Should create 3 events: main accommodation + check-in + check-out
assert len(result) == 3
# Main accommodation event
main_event = result[0]
assert main_event["allDay"] is True
assert main_event["title"] == "🏨 Hotel Stay"
assert main_event["start"] == "2024-01-15"
assert main_event["end"] == "2024-01-18" # End date + 1 day
assert main_event["url"] == "https://hotel.com"
# Check-in event
checkin_event = result[1]
assert checkin_event["allDay"] is False
assert checkin_event["title"] == "check-in: Hotel Stay"
assert checkin_event["start"] == "2024-01-15T15:00:00"
assert checkin_event["url"] == "https://hotel.com"
# Check-out event
checkout_event = result[2]
assert checkout_event["allDay"] is False
assert checkout_event["title"] == "checkout: Hotel Stay"
assert checkout_event["start"] == "2024-01-17T11:00:00"
assert checkout_event["url"] == "https://hotel.com"
def test_build_events_no_color_for_unknown_type(self) -> None:
"""Test that events with unknown types don't get color."""
events = [
Event(
date=date(2024, 1, 15),
name="unknown_type",
title="Unknown Event"
)
]
result = build_events(events)
assert len(result) == 1
item = result[0]
assert "color" not in item
assert "textColor" not in item
def test_build_events_multiple_event_types(self) -> None:
"""Test building events with different types and colors."""
events = [
Event(date=date(2024, 1, 15), name="bank_holiday", title="New Year"),
Event(date=date(2024, 1, 16), name="conference", title="PyCon"),
Event(date=date(2024, 1, 17), name="birthday", title="Birthday"),
Event(date=date(2024, 1, 18), name="waste_schedule", title="Recycling"),
Event(date=date(2024, 1, 19), name="us_holiday", title="MLK Day"),
]
result = build_events(events)
assert len(result) == 5
# Check colors are assigned correctly
assert result[0]["color"] == colors["success-subtle"] # bank_holiday
assert result[1]["color"] == colors["primary-subtle"] # conference
assert result[2]["color"] == colors["info-subtle"] # birthday
assert result[3]["color"] == colors["danger-subtle"] # waste_schedule
assert result[4]["color"] == colors["secondary-subtle"] # us_holiday
# All should have black text
for item in result:
assert item["textColor"] == "black"
def test_build_events_mixed_scenarios(self) -> None:
"""Test building events with mixed scenarios."""
events = [
# Today event (should be filtered)
Event(date=date(2024, 1, 1), name="today", title="Today"),
# Simple event
Event(date=date(2024, 1, 15), name="birthday", title="Birthday"),
# Event with time
Event(date=datetime(2024, 1, 16, 10, 0), name="meeting", title="Meeting"),
# Accommodation
Event(
date=datetime(2024, 1, 20, 15, 0),
name="accommodation",
title="Hotel",
end_date=datetime(2024, 1, 22, 11, 0),
url="https://hotel.com"
),
]
result = build_events(events)
# Should have 5 events total (today filtered, accommodation creates 3)
assert len(result) == 5
# Verify titles
titles = [item["title"] for item in result]
assert "🎈 Birthday" in titles
assert "Meeting" in titles
assert "🏨 Hotel" in titles
assert "check-in: Hotel" in titles
assert "checkout: Hotel" in titles

612
tests/test_thespacedevs.py Normal file
View file

@ -0,0 +1,612 @@
# test_thespacedevs.py
import deepdiff
import pytest
from agenda.thespacedevs import format_launch_changes
# --- Helper Functions for Tests ---
def create_base_launch():
"""Creates a base launch dictionary for diffing."""
return {
"id": "test-id",
"name": "Starship | Flight 10",
"status": {
"id": 8,
"name": "To Be Confirmed",
"abbrev": "TBC",
"description": "Awaiting official confirmation...",
},
"last_updated": "2025-08-08T16:03:39Z",
"net": "2025-08-22T23:30:00Z",
"window_end": "2025-08-23T01:34:00Z",
"window_start": "2025-08-22T23:30:00Z",
"net_precision": {
"id": 1,
"name": "Minute",
"abbrev": "MIN",
"description": "The T-0 is accurate to the minute.",
},
"probability": 75,
"pad": {
"id": 188,
"name": "Orbital Launch Mount A",
"location": {"name": "SpaceX Starbase, TX, USA"},
},
"mission": {
"name": "Flight 10"
# description intentionally omitted initially for test_dictionary_item_added
},
# Fields that should be skipped
"agency_launch_attempt_count": 550,
"location_launch_attempt_count": 18,
"pad_launch_attempt_count": 9,
"orbital_launch_attempt_count": 5,
"agency_launch_attempt_count_year": 100,
"location_launch_attempt_count_year": 3,
"pad_launch_attempt_count_year": 3,
"orbital_launch_attempt_count_year": 1,
}
# --- Tests for format_launch_changes ---
def test_no_changes():
"""Test when there are no differences."""
prev = create_base_launch()
cur = create_base_launch()
diff = deepdiff.DeepDiff(prev, cur, ignore_order=True)
result = format_launch_changes(diff)
assert result == "No specific changes detected"
def test_status_change():
"""Test changes to the status name."""
prev = create_base_launch()
cur = create_base_launch()
cur["status"]["name"] = "Go for Launch"
# Note: ID change might happen, but the test focuses on the name change message
diff = deepdiff.DeepDiff(prev, cur, ignore_order=True)
# print(f"DEBUG test_status_change diff: {diff}") # Uncomment for debugging
result = format_launch_changes(diff)
# DeepDiff should report this as values_changed
assert "Status changed from 'To Be Confirmed' to 'Go for Launch'" in result
def test_net_precision_change():
"""Test changes to the net precision name."""
prev = create_base_launch()
cur = create_base_launch()
cur["net_precision"]["name"] = "Hour"
# Note: ID change might happen, but the test focuses on the name change message
diff = deepdiff.DeepDiff(prev, cur, ignore_order=True)
# print(f"DEBUG test_net_precision_change diff: {diff}") # Uncomment for debugging
result = format_launch_changes(diff)
# DeepDiff should report this as values_changed
assert "Launch precision changed from 'Minute' to 'Hour'" in result
def test_net_change():
"""Test changes to the net (launch time)."""
prev = create_base_launch()
cur = create_base_launch()
cur["net"] = "2025-08-23T00:00:00Z"
diff = deepdiff.DeepDiff(prev, cur, ignore_order=True)
result = format_launch_changes(diff)
# The function uses format_date, so the output will be formatted
assert (
"Launch time changed from 22 Aug 2025 at 23:30 UTC to 23 Aug 2025 at 00:00 UTC"
in result
)
def test_window_start_change():
"""Test changes to the window start."""
prev = create_base_launch()
cur = create_base_launch()
cur["window_start"] = "2025-08-22T23:00:00Z"
diff = deepdiff.DeepDiff(prev, cur, ignore_order=True)
result = format_launch_changes(diff)
assert (
"Launch window start changed from 22 Aug 2025 at 23:30 UTC to 22 Aug 2025 at 23:00 UTC"
in result
)
def test_window_end_change():
"""Test changes to the window end."""
prev = create_base_launch()
cur = create_base_launch()
cur["window_end"] = "2025-08-23T02:00:00Z"
diff = deepdiff.DeepDiff(prev, cur, ignore_order=True)
result = format_launch_changes(diff)
assert (
"Launch window end changed from 23 Aug 2025 at 01:34 UTC to 23 Aug 2025 at 02:00 UTC"
in result
)
def test_last_updated_change():
"""Test changes to the last updated time."""
prev = create_base_launch()
cur = create_base_launch()
cur["last_updated"] = "2025-08-09T10:00:00Z"
diff = deepdiff.DeepDiff(prev, cur, ignore_order=True)
result = format_launch_changes(diff)
assert "Last updated: 09 Aug 2025 at 10:00 UTC" in result
def test_name_change():
"""Test changes to the launch name."""
prev = create_base_launch()
cur = create_base_launch()
cur["name"] = "Starship | Flight 10 - Revised"
diff = deepdiff.DeepDiff(prev, cur, ignore_order=True)
result = format_launch_changes(diff)
assert (
"Mission name changed from 'Starship | Flight 10' to 'Starship | Flight 10 - Revised'"
in result
)
def test_probability_change():
"""Test changes to the launch probability."""
prev = create_base_launch()
cur = create_base_launch()
cur["probability"] = 85
diff = deepdiff.DeepDiff(prev, cur, ignore_order=True)
result = format_launch_changes(diff)
assert "Launch probability changed from 75% to 85%" in result
def test_probability_set():
"""Test setting probability from None."""
prev = create_base_launch()
prev["probability"] = None # Start with None
cur = create_base_launch() # End with 75 (int)
diff = deepdiff.DeepDiff(prev, cur, ignore_order=True)
result = format_launch_changes(diff)
assert "Launch probability set to 75%" in result
def test_probability_removed():
"""Test removing probability (setting to None)."""
prev = create_base_launch() # Start with 75 (int)
cur = create_base_launch()
cur["probability"] = None # End with None
diff = deepdiff.DeepDiff(prev, cur, ignore_order=True)
result = format_launch_changes(diff)
assert "Launch probability removed" in result
def test_skip_fields():
"""Test that specific fields are skipped."""
prev = create_base_launch()
cur = create_base_launch()
cur["agency_launch_attempt_count"] = 551
cur["pad_launch_attempt_count_year"] = 4
diff = deepdiff.DeepDiff(prev, cur, ignore_order=True)
result = format_launch_changes(diff)
# The only change is in skipped fields, so no user-facing changes
assert result == "No specific changes detected"
def test_dictionary_item_added():
"""Test adding a new field."""
prev = create_base_launch()
# Ensure 'description' is not in the base mission dict
assert "description" not in prev["mission"]
cur = create_base_launch()
cur["mission"]["description"] = "New mission description."
diff = deepdiff.DeepDiff(prev, cur, ignore_order=True)
# print(f"DEBUG test_dictionary_item_added diff: {diff}") # Uncomment for debugging
result = format_launch_changes(diff)
# DeepDiff path for nested dict item added
assert (
"New field added: Mission['Description" in result
) # Matches the output format
def test_dictionary_item_removed():
"""Test removing a field."""
prev = create_base_launch()
# Add 'description' to prev so it can be removed
prev["mission"]["description"] = "Old description."
cur = create_base_launch()
# Ensure 'description' is not in cur's mission dict
assert "description" not in cur["mission"]
diff = deepdiff.DeepDiff(prev, cur, ignore_order=True)
# print(f"DEBUG test_dictionary_item_removed diff: {diff}") # Uncomment for debugging
result = format_launch_changes(diff)
# DeepDiff path for nested dict item removed
assert "Field removed: Mission['Description" in result # Matches the output format
def test_type_change():
"""Test changing the type of a field."""
prev = create_base_launch()
cur = create_base_launch()
cur["probability"] = "High" # Change int to string
diff = deepdiff.DeepDiff(prev, cur, ignore_order=True)
result = format_launch_changes(diff)
assert "Probability type changed from int to str" in result
# --- Test with Sample Data (Simulated Diff) ---
def test_with_sample_data_status_change():
"""Simulate a diff like the one that might occur for status change using sample data structure."""
# Simulate a diff where status changes from TBC to Success (ID 3)
# This mimics the EXACT structure DeepDiff produces for values_changed
# We only include the fields we care about in the test
sample_diff_status = deepdiff.DeepDiff(
{"status": {"id": 8, "name": "To Be Confirmed"}},
{"status": {"id": 3, "name": "Success"}},
ignore_order=True,
)
# print(f"DEBUG test_with_sample_data_status_change diff: {sample_diff_status}") # Debug
result = format_launch_changes(sample_diff_status)
# Should report the status name change
assert "Status changed from 'To Be Confirmed' to 'Success'" in result
# Ensure it doesn't report ID change separately due to the 'startswith' logic
# (The logic should group them under the name change)
# A simple check: if name change is there, and ID is handled, it's likely correct.
# The exact number of bullet points might vary based on ID/abbrev handling,
# but the key message should be present.
def test_with_sample_data_net_change():
"""Simulate a diff for net change."""
sample_diff_net = {
"values_changed": {
"root['net']": {
"new_value": "2025-08-25T12:00:00Z",
"old_value": "2025-08-22T23:30:00Z",
}
}
}
result = format_launch_changes(sample_diff_net)
assert (
"Launch time changed from 22 Aug 2025 at 23:30 UTC to 25 Aug 2025 at 12:00 UTC"
in result
)
def test_status_name_change():
diffs = {
"values_changed": {
"root['status']['name']": {
"old_value": "To Be Confirmed",
"new_value": "Go for Launch",
}
}
}
out = format_launch_changes(diffs)
assert "Status changed from 'To Be Confirmed' to 'Go for Launch'" in out
def test_net_precision_change():
diffs = {
"values_changed": {
"root['net_precision']['name']": {
"old_value": "Hour",
"new_value": "Minute",
}
}
}
out = format_launch_changes(diffs)
assert "Launch precision changed from 'Hour' to 'Minute'" in out
@pytest.mark.parametrize(
"field,label",
[
("net", "Launch time"),
("window_start", "Launch window start"),
("window_end", "Launch window end"),
],
)
def test_datetime_changes_formatted(field: str, label: str):
diffs = {
"values_changed": {
f"root['{field}']": {
"old_value": "2025-08-22T23:30:00Z",
"new_value": "2025-08-23T01:34:00Z",
}
}
}
out = format_launch_changes(diffs)
assert (
f"{label} changed from 22 Aug 2025 at 23:30 UTC " f"to 23 Aug 2025 at 01:34 UTC"
) in out
def test_last_updated_formatted():
diffs = {
"values_changed": {
"root['last_updated']": {
"old_value": "2025-08-08T16:03:39Z",
"new_value": "2025-08-09T12:00:00Z",
}
}
}
out = format_launch_changes(diffs)
# Only the new value is shown for last_updated
assert "Last updated: 09 Aug 2025 at 12:00 UTC" in out
assert "16:03" not in out # ensure old value not included
def test_name_and_probability_changes():
diffs = {
"values_changed": {
"root['name']": {
"old_value": "Starship | Flight 10",
"new_value": "Starship | Flight X",
},
"root['probability']": {"old_value": 70, "new_value": 85},
}
}
out = format_launch_changes(diffs)
assert (
"Mission name changed from 'Starship | Flight 10' to 'Starship | Flight X'"
in out
)
assert "Launch probability changed from 70% to 85%" in out
def test_probability_set_and_removed():
# Set from None
diffs_set = {
"values_changed": {"root['probability']": {"old_value": None, "new_value": 40}}
}
out_set = format_launch_changes(diffs_set)
assert "Launch probability set to 40%" in out_set
# Removed to None
diffs_removed = {
"values_changed": {"root['probability']": {"old_value": 30, "new_value": None}}
}
out_removed = format_launch_changes(diffs_removed)
assert "Launch probability removed" in out_removed
def test_skipped_fields_are_not_reported():
diffs = {
"values_changed": {
"root['agency_launch_attempt_count']": {
"old_value": 556,
"new_value": 557,
},
"root['pad_launch_attempt_count_year']": {
"old_value": 3,
"new_value": 4,
},
}
}
out = format_launch_changes(diffs)
# No bullet points should be produced for skipped fields
assert out == "No specific changes detected"
def test_generic_value_change_fallback():
diffs = {
"values_changed": {
"root['rocket']['configuration']['variant']": {
"old_value": "",
"new_value": "Block 2",
}
}
}
out = format_launch_changes(diffs)
assert (
"rocket']['configuration']['variant'] changed from '' to 'Block 2'" not in out
) # ensure path cleaning happened
assert "rocket']['configuration']['variant" not in out # sanity: no stray brackets
assert "rocket']['configuration']['variant" not in out # redundant but explicit
# Expected cleaned path from the function logic:
assert (
"rocket']['configuration']['variant" not in out
) # path is cleaned to "rocket']['configuration']['variant"
# The function replaces prefixes; the fallback prints the cleaned field string:
# "rocket']['configuration']['variant changed from '' to 'Block 2'"
# Because there is no special handling for deeper nesting, we assert a substring:
assert "variant changed from '' to 'Block 2'" in out
def test_dictionary_item_added_removed_and_type_change():
diffs = {
"dictionary_item_added": {"root['new_field']"},
"dictionary_item_removed": {"root['old_field_name']"},
"type_changes": {
"root['probability']": {
"old_type": int,
"new_type": str,
"old_value": 70,
"new_value": "70%",
}
},
}
out = format_launch_changes(diffs)
assert "New field added: New Field" in out
assert "Field removed: Old Field Name" in out
assert "Probability type changed from int to str" in out
def test_no_changes_message():
diffs = {}
out = format_launch_changes(diffs)
assert out == "No specific changes detected"
def test_no_changes():
"""
Tests that no message is generated when there are no differences.
"""
old_launch = {"id": 1, "name": "Launch A"}
new_launch = {"id": 1, "name": "Launch A"}
diff = deepdiff.DeepDiff(old_launch, new_launch)
assert format_launch_changes(diff) == "No specific changes detected"
def test_mission_name_change():
"""
Tests a simple change to the mission name.
"""
old_launch = {"name": "Starship | Flight 9"}
new_launch = {"name": "Starship | Flight 10"}
diff = deepdiff.DeepDiff(old_launch, new_launch)
expected = "• Mission name changed from 'Starship | Flight 9' to 'Starship | Flight 10'"
assert format_launch_changes(diff) == expected
def test_status_change():
"""
Tests that a change in status name is formatted correctly and that other
changes within the 'status' dictionary are ignored for a cleaner output.
"""
old_launch = {"status": {"id": 8, "name": "To Be Confirmed", "abbrev": "TBC"}}
new_launch = {"status": {"id": 1, "name": "Go for Launch", "abbrev": "Go"}}
diff = deepdiff.DeepDiff(old_launch, new_launch)
expected = "• Status changed from 'To Be Confirmed' to 'Go for Launch'"
assert format_launch_changes(diff) == expected
def test_datetime_changes():
"""
Tests the custom formatting for various datetime fields.
"""
old_launch = {
"net": "2025-08-22T23:30:00Z",
"window_start": "2025-08-22T23:30:00Z",
"window_end": "2025-08-23T01:34:00Z",
"last_updated": "2025-08-08T16:03:39Z",
}
new_launch = {
"net": "2025-08-23T00:00:00Z",
"window_start": "2025-08-23T00:00:00Z",
"window_end": "2025-08-23T02:00:00Z",
"last_updated": "2025-08-09T10:00:00Z",
}
diff = deepdiff.DeepDiff(old_launch, new_launch)
result_lines = format_launch_changes(diff).split("\n")
expected_changes = [
"• Launch time changed from 22 Aug 2025 at 23:30 UTC to 23 Aug 2025 at 00:00 UTC",
"• Launch window start changed from 22 Aug 2025 at 23:30 UTC to 23 Aug 2025 at 00:00 UTC",
"• Launch window end changed from 23 Aug 2025 at 01:34 UTC to 23 Aug 2025 at 02:00 UTC",
"• Last updated: 09 Aug 2025 at 10:00 UTC",
]
assert len(result_lines) == len(expected_changes)
for expected_line in expected_changes:
assert expected_line in result_lines
def test_datetime_change_fallback():
"""
Tests the fallback for malformed datetime strings.
"""
old_launch = {"net": "an-invalid-date"}
new_launch = {"net": "another-invalid-date"}
diff = deepdiff.DeepDiff(old_launch, new_launch)
expected = "• Launch time changed from an-invalid-date to another-invalid-date"
assert format_launch_changes(diff) == expected
def test_probability_changes():
"""
Tests the three scenarios for probability changes:
1. From a value to another value.
2. From None to a value.
3. From a value to None.
"""
# From value to value
old = {"probability": 70}
new = {"probability": 80}
diff = deepdiff.DeepDiff(old, new)
assert format_launch_changes(diff) == "• Launch probability changed from 70% to 80%"
# From None to value
old = {"probability": None}
new = {"probability": 90}
diff = deepdiff.DeepDiff(old, new)
assert format_launch_changes(diff) == "• Launch probability set to 90%"
# From value to None
old = {"probability": 90}
new = {"probability": None}
diff = deepdiff.DeepDiff(old, new)
assert format_launch_changes(diff) == "• Launch probability removed"
def test_skipped_field_is_ignored():
"""
Tests that changes to fields in the SKIP_FIELDS list are ignored.
"""
old = {"pad_launch_attempt_count_year": 4}
new = {"pad_launch_attempt_count_year": 5}
diff = deepdiff.DeepDiff(old, new)
assert format_launch_changes(diff) == "No specific changes detected"
def test_dictionary_item_added():
"""
Tests that newly added fields are reported.
"""
old = {"name": "Launch"}
new = {"name": "Launch", "hashtag": "#launchday"}
diff = deepdiff.DeepDiff(old, new)
assert format_launch_changes(diff) == "• New field added: Hashtag"
def test_dictionary_item_removed():
"""
Tests that removed fields are reported.
"""
old = {"name": "Launch", "hashtag": "#launchday"}
new = {"name": "Launch"}
diff = deepdiff.DeepDiff(old, new)
assert format_launch_changes(diff) == "• Field removed: Hashtag"
def test_type_change():
"""
Tests that a change in the data type of a field is reported.
"""
old = {"probability": 50}
new = {"probability": "50%"}
diff = deepdiff.DeepDiff(old, new)
assert format_launch_changes(diff) == "• Probability type changed from int to str"
def test_multiple_changes():
"""
Tests a scenario with multiple, mixed changes to ensure all are reported.
Order is not guaranteed, so we check for the presence of each line.
"""
old_launch = {
"status": {"name": "To Be Confirmed"},
"probability": 90,
"name": "Old Mission Name",
}
new_launch = {
"status": {"name": "Go for Launch"},
"name": "New Mission Name",
"weather_concerns": "High winds.",
}
diff = deepdiff.DeepDiff(old_launch, new_launch)
result_lines = format_launch_changes(diff).split("\n")
expected_changes = [
"• Status changed from 'To Be Confirmed' to 'Go for Launch'",
"• Mission name changed from 'Old Mission Name' to 'New Mission Name'",
"• Field removed: Probability",
"• New field added: Weather Concerns",
]
assert len(result_lines) == len(expected_changes)
for expected_line in expected_changes:
assert expected_line in result_lines