release: bump version to 0.70.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled

This commit is contained in:
linkong
2026-06-04 17:16:23 +08:00
parent acbbfdf9e2
commit 8c204717cd
78 changed files with 1762 additions and 703 deletions

View File

@@ -1,4 +1,4 @@
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
import pytest
@@ -15,6 +15,7 @@ from app.services.earth_news import (
_enrich_items_with_target_locations,
_extract_target_location_from_text,
_parse_feed_entries,
_rank_and_trim_items,
_serialize_item,
get_earth_news_payload,
test_news_source_config as run_news_source_config_test,
@@ -50,6 +51,85 @@ def test_serialize_item_includes_region_anchor_for_cruise():
assert payload["published_at"] == "2026-04-23T02:30:00Z"
def test_serialize_item_includes_breaking_fields():
item = ParsedNewsItem(
id="breaking:test",
title="Major market halt",
summary="Trading halt after flash crash",
url="https://example.com/breaking",
source="Example Source",
feed_name="Example Feed",
feed_region="global",
homepage_url="https://example.com",
published_at=datetime(2026, 5, 15, 2, 0, tzinfo=UTC),
breaking_level="critical",
breaking_scope="global",
breaking_reasons=["重大金融市场异常"],
breaking_source="rules",
breaking_confidence=0.72,
breaking_expires_at=datetime(2026, 5, 16, 2, 0, tzinfo=UTC),
)
payload = _serialize_item(item, active_region="europe")
assert payload["breaking_level"] == "critical"
assert payload["breaking_scope"] == "global"
assert payload["breaking_reasons"] == ["重大金融市场异常"]
assert payload["breaking_source"] == "rules"
assert payload["breaking_confidence"] == 0.72
assert payload["breaking_expires_at"] == "2026-05-16T02:00:00Z"
def test_rank_and_trim_items_prioritizes_active_breaking():
older_breaking = ParsedNewsItem(
id="global:critical",
title="Nuclear accident reported",
summary="A nuclear accident has been reported.",
url="https://example.com/critical",
source="Global Source",
feed_name="Global Feed",
feed_region="global",
homepage_url="https://example.com",
published_at=datetime.now(UTC) - timedelta(hours=2),
breaking_level="critical",
breaking_scope="global",
breaking_expires_at=datetime.now(UTC) + timedelta(hours=6),
)
newer_regular = ParsedNewsItem(
id="europe:regular",
title="Regular Europe story",
summary="A newer regular story.",
url="https://example.com/regular",
source="Europe Source",
feed_name="Europe Feed",
feed_region="europe",
homepage_url="https://example.com",
published_at=datetime.now(UTC),
)
expired_breaking = ParsedNewsItem(
id="europe:expired",
title="Expired breaking",
summary="Expired breaking story.",
url="https://example.com/expired",
source="Europe Source",
feed_name="Europe Feed",
feed_region="europe",
homepage_url="https://example.com",
published_at=datetime.now(UTC) + timedelta(minutes=1),
breaking_level="critical",
breaking_scope="regional",
breaking_expires_at=datetime.now(UTC) - timedelta(minutes=1),
)
ranked = _rank_and_trim_items(
[newer_regular, expired_breaking, older_breaking],
active_region="europe",
limit=3,
)
assert [item.id for item in ranked] == ["global:critical", "europe:expired", "europe:regular"]
def test_serialize_item_falls_back_to_global_anchor():
item = ParsedNewsItem(
id="custom:test",
@@ -1019,6 +1099,8 @@ async def test_earth_news_payload_passes_region_and_category_filters_to_store(mo
"sources": [],
"limit": 12,
"locale": "zh-CN",
"has_breaking": False,
"highest_breaking_level": "none",
}
assert payload["items"][0]["category"] == "business"

View File

@@ -0,0 +1,74 @@
"""Compatibility contracts for stable backend protocol enums."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from app.core.enums import (
BreakingLevel,
BreakingScope,
JobStatus,
NewsImportanceLevel,
NewsSourceType,
PlaygroundMessageKind,
PlaygroundMessageStatus,
UserRole,
parse_enum,
)
from app.services.earth_news_classification import (
BREAKING_LEVEL_RANK,
BREAKING_TTL,
breaking_sort_rank,
importance_level,
)
def test_protocol_enum_values_remain_api_compatible() -> None:
assert [item.value for item in NewsImportanceLevel] == ["low", "medium", "high", "critical"]
assert [item.value for item in BreakingLevel] == ["none", "watch", "breaking", "critical"]
assert [item.value for item in BreakingScope] == ["regional", "global"]
assert [item.value for item in NewsSourceType] == ["rss", "atom", "aggregated", "reference"]
assert [item.value for item in UserRole] == ["viewer", "admin", "super_admin"]
assert JobStatus.RUNNING.value == "running"
assert PlaygroundMessageKind.THINKING.value == "thinking"
assert PlaygroundMessageStatus.ERROR.value == "error"
assert PlaygroundMessageStatus.STOPPED.value == "stopped"
def test_parse_enum_accepts_legacy_strings_and_safely_falls_back(caplog) -> None:
assert parse_enum(JobStatus, "RUNNING", JobStatus.FAILED) is JobStatus.RUNNING
assert parse_enum(JobStatus, None, JobStatus.QUEUED) is JobStatus.QUEUED
assert parse_enum(JobStatus, "legacy-unknown", JobStatus.FAILED) is JobStatus.FAILED
assert "legacy-unknown" in caplog.text
def test_importance_level_boundaries() -> None:
expected = {
34: NewsImportanceLevel.LOW,
35: NewsImportanceLevel.MEDIUM,
59: NewsImportanceLevel.MEDIUM,
60: NewsImportanceLevel.HIGH,
79: NewsImportanceLevel.HIGH,
80: NewsImportanceLevel.CRITICAL,
}
assert {score: importance_level(score) for score in expected} == expected
def test_breaking_rank_and_ttl_contracts() -> None:
assert BREAKING_LEVEL_RANK[BreakingLevel.CRITICAL] > BREAKING_LEVEL_RANK[BreakingLevel.BREAKING]
assert BREAKING_TTL[BreakingLevel.WATCH] == timedelta(hours=6)
assert BREAKING_TTL[BreakingLevel.BREAKING] == timedelta(hours=12)
assert BREAKING_TTL[BreakingLevel.CRITICAL] == timedelta(hours=24)
now = datetime.now(UTC)
active = SimpleNamespace(
breaking_level=BreakingLevel.BREAKING.value,
breaking_expires_at=now + timedelta(minutes=1),
)
expired = SimpleNamespace(
breaking_level=BreakingLevel.CRITICAL.value,
breaking_expires_at=now - timedelta(minutes=1),
)
assert breaking_sort_rank(active) == BREAKING_LEVEL_RANK[BreakingLevel.BREAKING]
assert breaking_sort_rank(expired) == 0

View File

@@ -8,7 +8,7 @@ from app.api.v1 import visualization
from app.api.v1.visualization import convert_vessels_to_geojson
from app.db.session import get_db
from app.main import app
from app.models.vessel import AISRawObservation, VesselPosition, VesselStatic
from app.models.vessel import AISRawObservation, VesselCurrentState, VesselPosition, VesselStatic
from app.services import barentswatch
from app.services.collectors.aisstream import AISStreamCollector
from app.services.collectors.vessel_ais import VesselAISCollector
@@ -17,6 +17,7 @@ from app.services.vessel_ais_aggregation import (
build_field_conflict_candidates,
build_observation_hash,
record_vessel_ais_observation,
upsert_vessel_current_state,
)
@@ -109,6 +110,58 @@ async def test_record_vessel_ais_observation_skips_existing_hash():
assert db.added == []
@pytest.mark.asyncio
async def test_upsert_vessel_current_state_keeps_latest_position_and_static_fields():
current = VesselCurrentState(
mmsi=257123000,
lat=59.91,
lon=10.73,
name="OSLO TRADER",
source="barentswatch_vessels",
observed_at=datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc),
field_sources={"name": "aisstream_vessels"},
)
class _Session:
async def get(self, _model, _mmsi):
return current
def add(self, _item):
raise AssertionError("existing current state should be updated")
db = _Session()
result = await upsert_vessel_current_state(
db,
source="aisstream_vessels",
normalized_payload={"mmsi": 257123000, "lat": 59.92, "lon": 10.74, "sog": 12.4},
observed_at=datetime(2026, 4, 30, 12, 1, tzinfo=timezone.utc),
)
assert result is current
assert current.lat == pytest.approx(59.92)
assert current.lon == pytest.approx(10.74)
assert current.name == "OSLO TRADER"
assert current.source == "aisstream_vessels"
await upsert_vessel_current_state(
db,
source="barentswatch_vessels",
normalized_payload={"mmsi": 257123000, "lat": 59.93, "lon": 10.75, "name": "LOW PRIORITY"},
observed_at=datetime(2026, 4, 30, 12, 2, tzinfo=timezone.utc),
)
assert current.lat == pytest.approx(59.93)
assert current.name == "OSLO TRADER"
await upsert_vessel_current_state(
db,
source="barentswatch_vessels",
normalized_payload={"mmsi": 257123000, "lat": 1, "lon": 2, "name": "OLD"},
observed_at=datetime(2026, 4, 30, 11, 59, tzinfo=timezone.utc),
)
assert current.lat == pytest.approx(59.93)
assert current.name == "OSLO TRADER"
def test_build_field_conflict_candidates_from_raw_observations():
observations = [
AISRawObservation(
@@ -527,7 +580,7 @@ async def test_vessel_snapshot_filters_type_and_bbox(monkeypatch):
now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc)
monkeypatch.setattr(
visualization,
"get_aggregated_vessels_snapshot",
"get_current_vessels_snapshot",
AsyncMock(
return_value=[
{
@@ -573,6 +626,36 @@ async def test_vessel_snapshot_filters_type_and_bbox(monkeypatch):
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_vessel_snapshot_accepts_fractional_zoom(monkeypatch):
monkeypatch.setattr(
visualization,
"get_current_vessels_snapshot",
AsyncMock(return_value=[]),
)
async def override_get_db():
yield object()
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
try:
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/vessels/snapshot",
params={
"bbox": "-180,-85.05112878,180,85.05112878",
"zoom": 1.6,
"limit": 3000,
},
)
assert response.status_code == 200
assert response.json()["count"] == 0
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_legacy_vessels_geojson_route_is_not_registered():
transport = ASGITransport(app=app)
@@ -597,7 +680,7 @@ async def test_vessel_snapshot_filters_bbox_and_caps_limit(monkeypatch):
now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc)
captured = {}
async def fake_get_aggregated_vessels_snapshot(db, *, bbox, limit, observed_since):
async def fake_get_current_vessels_snapshot(db, *, bbox, limit, observed_since):
captured["bbox"] = bbox
captured["limit"] = limit
captured["observed_since"] = observed_since
@@ -624,8 +707,8 @@ async def test_vessel_snapshot_filters_bbox_and_caps_limit(monkeypatch):
monkeypatch.setattr(
visualization,
"get_aggregated_vessels_snapshot",
fake_get_aggregated_vessels_snapshot,
"get_current_vessels_snapshot",
fake_get_current_vessels_snapshot,
)
class _Result:
@@ -661,6 +744,8 @@ async def test_vessel_snapshot_filters_bbox_and_caps_limit(monkeypatch):
assert captured["bbox"] == (10.0, 59.0, 11.0, 60.0)
assert captured["limit"] == 5000
assert data["diagnostics"]["bbox_applied"] is True
assert data["diagnostics"]["source"] == "vessel_current_state"
assert data["diagnostics"]["current_state_count"] == 2
assert data["diagnostics"]["legacy_feature_count"] == 0
assert data["diagnostics"]["legacy_backfilled_mmsi"] == 0
finally:
@@ -668,34 +753,12 @@ async def test_vessel_snapshot_filters_bbox_and_caps_limit(monkeypatch):
@pytest.mark.asyncio
async def test_vessel_snapshot_uses_legacy_fallback_when_raw_window_is_empty(monkeypatch):
now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc)
async def test_vessel_snapshot_does_not_fallback_to_history_when_current_state_is_empty(monkeypatch):
monkeypatch.setattr(
visualization,
"get_aggregated_vessels_snapshot",
"get_current_vessels_snapshot",
AsyncMock(return_value=[]),
)
monkeypatch.setattr(
visualization,
"_load_legacy_vessel_snapshot_features",
AsyncMock(
return_value=[
{
"type": "Feature",
"id": 257123000,
"geometry": {"type": "Point", "coordinates": [10.73, 59.91]},
"properties": {
"mmsi": 257123000,
"name": "OSLO TRADER",
"vessel_type": 70,
"vessel_type_name": "Cargo",
"received_at": now.isoformat(),
},
}
]
),
)
result = await visualization.build_vessel_snapshot_response(
object(),
bbox=(10.0, 59.0, 11.0, 60.0),
@@ -705,12 +768,11 @@ async def test_vessel_snapshot_uses_legacy_fallback_when_raw_window_is_empty(mon
since_minutes=60,
)
assert result["count"] == 1
assert result["features"][0]["properties"]["name"] == "OSLO TRADER"
assert result["count"] == 0
assert result["diagnostics"]["raw_feature_count"] == 0
assert result["diagnostics"]["legacy_feature_count"] == 1
assert result["diagnostics"]["legacy_backfilled_mmsi"] == 1
assert result["diagnostics"]["legacy_fallback_used"] is True
assert result["diagnostics"]["legacy_feature_count"] == 0
assert result["diagnostics"]["legacy_backfilled_mmsi"] == 0
assert result["diagnostics"]["legacy_fallback_used"] is False
@pytest.mark.asyncio