diff --git a/agenda/thespacedevs.py b/agenda/thespacedevs.py index 0f16e34..7e6e2dd 100644 --- a/agenda/thespacedevs.py +++ b/agenda/thespacedevs.py @@ -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" ) diff --git a/tests/test_calendar.py b/tests/test_calendar.py deleted file mode 100644 index 29ec0f1..0000000 --- a/tests/test_calendar.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/tests/test_thespacedevs.py b/tests/test_thespacedevs.py new file mode 100644 index 0000000..369c556 --- /dev/null +++ b/tests/test_thespacedevs.py @@ -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