Merge origin/master do PRE-16: naprawa regresji strumienia postepu
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m33s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m43s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 33s
Testy / Kontrola składni wszystkich warstw (push) Successful in 15s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m39s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m45s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 26s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 15s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m33s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m43s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 33s
Testy / Kontrola składni wszystkich warstw (push) Successful in 15s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m39s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m45s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 26s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 15s
Konflikt w logic_client.py::positions() rozwiazany biorac OBIE zmiany: routing przez szyfrowane _post() (PRE-16) + dluzszy timeout takze dla tables (LOG-23) — warunek (stations or tables). Wazniejsza rzecz, ktora scalenie ujawnilo: okno postepu (#19/#22) i szyfrowanie lacz (#21) powstaly na rownoleglych galeziach, ktore sie nie widzialy. Po zejsciu razem strumien horoskopu szedl SUROWYM httpx, z pominieciem szyfrowania. Przy wlaczonym LINK_ENCRYPTION_REQUIRED serwer odrzucalby to zadanie (400), a nawet bez wymagania odpowiedz wracalaby jako nieczytelne ramki — okno postepu przestaloby dzialac na produkcji. Naprawa: - nowy link_crypto.stream_lines(): strumieniowe POST przez szyfrowane lacze; pieczetuje zadanie i odszyfrowuje odpowiedz ramka po ramce, sklejajac bufor bo granice ramek nie pokrywaja sie z granicami linii NDJSON. Dostarczanie na zywo zachowane. Bez klucza — jak dotad (dev). - horoscope_stream() w kliencie idzie teraz przez stream_lines zamiast surowego client.stream. - fail-closed takze dla strumienia: bez klucza przy wymaganym szyfrowaniu klient nie wysyla NIC (wczesniej cialo — dane urodzenia — szloby w eter, dopiero potem serwer odmawial). Ujednolica kontrakt z call(). Weryfikacja e2e na prawdziwym uvicornie z podsluchem gniazda: z kluczem strumien dziala (5 etapow + result, na zywo), na kablu ZERO tresci bazy (grep=0; jedyne 'horoscope' to sciezka URL w naglowku, ktory z zalozenia jest jawny); bez klucza klient zatrzymuje sie przed wyslaniem. Testy: +4 na stream_lines (round-trip, sciezka jawna, fail-closed serwera i klienta), niezmiennik strukturalny rozszerzony o stream_lines jako droge w dol (sprawdzone celowym zepsuciem — czerwienieje). Calosc: logika 234 passed / 1 skipped, prezentacja 25 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -273,6 +273,56 @@ def test_streaming_response_survives_encryption(link):
|
||||
assert all(s["note"] == SECRET for s in steps)
|
||||
|
||||
|
||||
def test_stream_lines_helper_round_trips_ndjson(link):
|
||||
"""Wyższa warstwa (`stream_lines`) — dokładnie tej używa klient okna postępu.
|
||||
|
||||
Regresja, którą pilnuje ten test: strumień horoskopu powstał na gałęzi, która
|
||||
nie widziała szyfrowania, więc szedł surowym httpx. Po scaleniu z PRE-16 przy
|
||||
włączonym łączu żądanie było odrzucane (400) albo odpowiedź wracała jako
|
||||
nieczytelne ramki. Tu sprawdzamy, że helper zwraca CZYSTE linie NDJSON i że
|
||||
tajny opis nie przecieka po drodze."""
|
||||
with TestClient(_app(link)) as client:
|
||||
lines = list(link_crypto.stream_lines(
|
||||
client, "http://testserver/stream", payload=None, link=link))
|
||||
steps = [json.loads(line) for line in lines]
|
||||
assert [s["step"] for s in steps] == [0, 1, 2, 3]
|
||||
assert all(s["note"] == SECRET for s in steps)
|
||||
|
||||
|
||||
def test_stream_lines_plaintext_path_still_works(link):
|
||||
"""Bez klucza (dev) strumień ma działać jak dotąd — surowy, bez szyfrowania."""
|
||||
with TestClient(_app(None)) as client:
|
||||
lines = list(link_crypto.stream_lines(
|
||||
client, "http://testserver/stream", payload=None, link=None))
|
||||
assert [json.loads(line)["step"] for line in lines] == [0, 1, 2, 3]
|
||||
|
||||
|
||||
def test_stream_request_without_key_is_refused_when_required(link):
|
||||
"""Fail-closed obejmuje też strumień: nieszyfrowane żądanie na łączu z kluczem
|
||||
dostaje odmowę, a nie cichy jawny przelot tajnych danych."""
|
||||
with TestClient(_app(link)) as client:
|
||||
response = client.post("http://testserver/stream", json={})
|
||||
assert response.status_code == 400
|
||||
assert SECRET.encode() not in response.content
|
||||
|
||||
|
||||
def test_stream_lines_client_is_fail_closed_when_required(monkeypatch, link):
|
||||
"""Klient strumienia też nie wypuszcza jawnego żądania — jak `call`. Bez tego
|
||||
ciało (dane urodzenia) poszłoby w eter, zanim serwer zdążyłby odmówić."""
|
||||
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
|
||||
sent = []
|
||||
|
||||
class Tripwire:
|
||||
def stream(self, *args, **kwargs):
|
||||
sent.append(args)
|
||||
raise AssertionError("strumień NIE powinien opuścić procesu")
|
||||
|
||||
with pytest.raises(LinkError, match=link_crypto.ENV_REQUIRED):
|
||||
list(link_crypto.stream_lines(Tripwire(), "http://logic/chart/horoscope/stream",
|
||||
payload={"lat": 50.0}, link=None))
|
||||
assert not sent, "żądanie strumienia wyszłoby jawnym tekstem"
|
||||
|
||||
|
||||
def test_incremental_unframing_handles_split_frames(link):
|
||||
"""Ramka potrafi rozjechać się między dwa odczyty z gniazda — składamy ją
|
||||
w buforze, zamiast zakładać, że każdy kawałek to komplet."""
|
||||
|
||||
@@ -405,3 +405,33 @@ def test_max_budget_differs_between_models(monkeypatch):
|
||||
haiku = prompt_token_budget(*factory.resolve_model("anthropic", "claude-haiku-4-5"))
|
||||
local = prompt_token_budget(*factory.resolve_model("local", "llama3.1:8b"))
|
||||
assert opus > haiku > local > 0
|
||||
|
||||
|
||||
def test_turn_budget_counts_produced_not_requested(monkeypatch):
|
||||
"""Regresja: odejmowanie ZAMOWIONEGO limitu tury zamiast wyprodukowanych
|
||||
tokenow konczylo petle po jednej turze — urwany fragment wracal jako calosc."""
|
||||
seq = [_chat("Fragment 1. ", "length"), _chat("Fragment 2. ", "length"),
|
||||
_chat("Zakonczenie. KONIEC", "stop")]
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(200, json=seq.pop(0) if seq else seq[-1])
|
||||
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||
# budzet 8000 < TURN_TOKENS_CAP: przy starej logice byla dokladnie jedna tura
|
||||
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 8000)
|
||||
assert out.usage["turns"] == 3, "urwana odpowiedz musi byc kontynuowana"
|
||||
assert "Fragment 1." in out.text and "Zakonczenie." in out.text
|
||||
|
||||
|
||||
def test_generate_reports_progress_events(monkeypatch):
|
||||
"""Log w UI ma pokazywac RZECZYWISTE tury, nie udawany pasek postepu."""
|
||||
seq = [_chat("Czesc. ", "length"), _chat("Reszta. KONIEC", "stop")]
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(
|
||||
lambda r: httpx.Response(200, json=seq.pop(0) if seq else seq[-1])))
|
||||
events = []
|
||||
ChatCompletionsProvider("local", "http://x/v1", "m").generate(
|
||||
"p", 8000, on_event=events.append)
|
||||
kinds = [e["type"] for e in events]
|
||||
assert kinds.count("turn_start") == 2 and kinds.count("turn_end") == 2
|
||||
assert kinds[-1] == "generated"
|
||||
assert any("urwana" in e["message"] for e in events if e["type"] == "turn_end")
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Tabele pomocnicze horoskopu (LOG-23).
|
||||
|
||||
Wartości referencyjne dla horoskopu 30.04.1984 09:20 UTC, Kraków (50.0647N, 19.9450E)
|
||||
sprawdzone wobec faktów NIEZALEŻNYCH od naszego kodu:
|
||||
* 30.04.1984 to poniedziałek → władca dnia Księżyc; 5. godzina poniedziałku
|
||||
w porządku chaldejskim to Słońce (Mo, Sa, Ju, Ma, Su),
|
||||
* wschód/zachód dla Krakowa końcem kwietnia ≈ 5:18 / 19:57 czasu lokalnego
|
||||
(CEST = UTC+2), czyli 03:18 / 17:57 UTC,
|
||||
* pełnia poprzedzająca urodzenie: 15.04.1984 ok. 19:11 UTC.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.chart import build_chart
|
||||
from app.engine.models import ChartMoment
|
||||
from app.engine.tables import (
|
||||
build_tables,
|
||||
critical_degrees,
|
||||
dwadasamsa,
|
||||
element_of,
|
||||
moon_phase,
|
||||
navamsa,
|
||||
planetary_hours,
|
||||
prenatal_syzygy,
|
||||
quality_of,
|
||||
tally,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def krakow():
|
||||
return ChartMoment(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc),
|
||||
lat=50.0647, lon=19.9450)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tables(own_engine, krakow):
|
||||
return build_tables(own_engine, krakow, build_chart(own_engine, krakow))
|
||||
|
||||
|
||||
# ------------------------------------------------------ żywioły i jakości
|
||||
|
||||
def test_element_and_quality_mapping():
|
||||
assert element_of("Aries") == "Fire" and element_of("Cancer") == "Water"
|
||||
assert quality_of("Aries") == "Cardinal" and quality_of("Taurus") == "Fixed"
|
||||
assert quality_of("Gemini") == "Mutable"
|
||||
|
||||
|
||||
def test_tally_matches_hand_count(tables):
|
||||
"""Ręcznie przeliczone dla horoskopu referencyjnego (10 planet + Asc)."""
|
||||
base = tables["tally"]["with_modern_10_plus_asc"]
|
||||
assert base["elements"] == {"Fire": 4, "Earth": 4, "Air": 0, "Water": 3}
|
||||
assert base["qualities"] == {"Cardinal": 4, "Fixed": 6, "Mutable": 1}
|
||||
assert base["total"] == 11
|
||||
|
||||
|
||||
def test_missing_element_detected(tables):
|
||||
"""Klasyczne „no air" — podstawa pod scoring siły (LOG-21)."""
|
||||
assert tables["tally"]["missing_elements"] == ["Air"]
|
||||
assert tables["tally"]["missing_qualities"] == []
|
||||
|
||||
|
||||
def test_tally_variants_differ_by_object_count(tables):
|
||||
t = tables["tally"]
|
||||
assert t["classical_7"]["total"] == 7
|
||||
assert t["with_modern_10"]["total"] == 10
|
||||
assert t["classical_7_plus_asc"]["total"] == 8
|
||||
assert t["with_modern_10_plus_asc"]["total"] == 11
|
||||
|
||||
|
||||
def test_tally_sums_equal_counted_objects(tables):
|
||||
for variant in ("classical_7", "with_modern_10", "with_modern_10_plus_asc"):
|
||||
v = tables["tally"][variant]
|
||||
assert sum(v["elements"].values()) == v["total"]
|
||||
assert sum(v["qualities"].values()) == v["total"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------- faza Księżyca
|
||||
|
||||
def test_moon_phase_reference_is_balsamic_new(tables):
|
||||
"""Nów wypadł 1.05.1984, więc 30.04 Księżyc jest tuż przed nowiem."""
|
||||
mp = tables["moon_phase"]
|
||||
assert mp["phase"] == "New Moon"
|
||||
assert 340.0 < mp["angle"] < 360.0
|
||||
assert mp["illumination"] < 0.05
|
||||
assert mp["waxing"] is False # elongacja > 180 = ubywa
|
||||
|
||||
|
||||
@pytest.mark.parametrize("angle,expected", [
|
||||
(0.0, "New Moon"), (90.0, "First Quarter"), (180.0, "Full Moon"),
|
||||
(270.0, "Last Quarter"), (46.0, "Waxing Crescent"), (300.0, "Waning Crescent"),
|
||||
])
|
||||
def test_moon_phase_buckets(angle, expected):
|
||||
assert moon_phase(0.0, angle)["phase"] == expected
|
||||
|
||||
|
||||
def test_moon_phase_illumination_extremes():
|
||||
assert moon_phase(0.0, 0.0)["illumination"] == 0.0
|
||||
assert moon_phase(0.0, 180.0)["illumination"] == 1.0
|
||||
assert moon_phase(0.0, 90.0)["illumination"] == pytest.approx(0.5)
|
||||
|
||||
|
||||
# ------------------------------------------------------- stopnie krytyczne
|
||||
|
||||
def test_critical_degrees_reference(tables):
|
||||
found = {c["name"]: c["flags"] for c in tables["critical_degrees"]}
|
||||
assert "Jupiter" in found # Cap 12°57' -> 13° kardynalny
|
||||
assert any("13" in f for f in found["Jupiter"])
|
||||
assert "Pluto" in found # Sco 0°28' -> wejście w znak
|
||||
|
||||
|
||||
def test_anaretic_degree_flagged():
|
||||
flags = critical_degrees([{"name": "X", "sign": "Leo", "decimal": 149.5,
|
||||
"in_sign": "Leo 29°30'"}])
|
||||
assert flags and any("anaretyczny" in f for f in flags[0]["flags"])
|
||||
|
||||
|
||||
def test_no_flags_for_ordinary_degree():
|
||||
assert critical_degrees([{"name": "X", "sign": "Leo", "decimal": 135.0,
|
||||
"in_sign": "Leo 15°"}]) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------- podziały
|
||||
|
||||
def test_dwadasamsa_starts_from_own_sign():
|
||||
"""12. część liczy się OD znaku, w którym stoi punkt."""
|
||||
assert dwadasamsa(0.0) == pytest.approx(0.0) # Ari 0 -> Ari
|
||||
assert dwadasamsa(2.5) == pytest.approx(30.0) # Ari 2°30' -> Tau 0
|
||||
assert dwadasamsa(30.0) == pytest.approx(30.0) # Tau 0 -> Tau
|
||||
|
||||
|
||||
def test_navamsa_classic_starts():
|
||||
"""Znaki kardynalne zaczynają od siebie, stałe od 9. znaku."""
|
||||
assert navamsa(0.0) == pytest.approx(0.0) # Ari -> Ari
|
||||
assert navamsa(30.0) == pytest.approx(270.0) # Tau -> Cap (9. od Byka)
|
||||
assert navamsa(60.0) == pytest.approx(180.0) # Gem -> Lib
|
||||
|
||||
|
||||
def test_divisional_covers_all_positions(tables, own_engine, krakow):
|
||||
chart = build_chart(own_engine, krakow)
|
||||
assert len(tables["divisional"]) == len(chart["positions"])
|
||||
|
||||
|
||||
# ------------------------------------------------- dzień i godziny planetarne
|
||||
|
||||
def test_planetary_day_ruler_is_moon_on_monday(tables):
|
||||
"""30.04.1984 to poniedziałek → władcą dnia jest Księżyc."""
|
||||
assert tables["planetary_hours"]["day_ruler"] == "Moon"
|
||||
|
||||
|
||||
def test_planetary_hour_matches_chaldean_sequence(tables):
|
||||
"""Poniedziałek: 1=Mo, 2=Sa, 3=Ju, 4=Ma, 5=Su — urodzenie w 5. godzinie dnia."""
|
||||
ph = tables["planetary_hours"]
|
||||
assert ph["hour_number"] == 5
|
||||
assert ph["hour_ruler"] == "Sun"
|
||||
assert ph["daytime"] is True
|
||||
|
||||
|
||||
def test_sunrise_sunset_match_krakow_late_april(tables):
|
||||
"""Wschód ≈ 03:18 UTC, zachód ≈ 17:57 UTC (5:18 i 19:57 czasu lokalnego)."""
|
||||
ph = tables["planetary_hours"]
|
||||
assert ph["period_start"].startswith("1984-04-30T03:1")
|
||||
assert ph["period_end"].startswith("1984-04-30T17:5")
|
||||
|
||||
|
||||
def test_planetary_hours_are_unequal_and_complete(tables):
|
||||
"""Godziny są nierówne: wiosną dzienna trwa dłużej niż 60 minut."""
|
||||
ph = tables["planetary_hours"]
|
||||
assert ph["hour_length_minutes"] > 60.0
|
||||
assert len(ph["hours"]) == 12
|
||||
assert sum(1 for h in ph["hours"] if h["current"]) == 1
|
||||
|
||||
|
||||
def test_polar_night_returns_none(own_engine):
|
||||
"""Za kołem podbiegunowym w grudniu Słońce nie wschodzi — brak godzin."""
|
||||
polar = ChartMoment(when_utc=datetime(2024, 12, 21, 12, 0, tzinfo=timezone.utc),
|
||||
lat=78.0, lon=15.0)
|
||||
assert planetary_hours(own_engine, polar) is None
|
||||
|
||||
|
||||
# ------------------------------------------------------- syzygia prenatalna
|
||||
|
||||
def test_prenatal_syzygy_is_april_1984_full_moon(tables):
|
||||
"""Rzeczywista pełnia: 15.04.1984 ok. 19:11 UTC."""
|
||||
s = tables["prenatal_syzygy"]
|
||||
assert s["type"] == "full_moon"
|
||||
assert s["when_utc"].startswith("1984-04-15T19:1")
|
||||
|
||||
|
||||
def test_prenatal_syzygy_precedes_birth_within_a_cycle(tables):
|
||||
days = tables["prenatal_syzygy"]["days_before_birth"]
|
||||
assert 0 < days < 29.6, "syzygia musi być w ostatnim cyklu przed urodzeniem"
|
||||
|
||||
|
||||
def test_prenatal_syzygy_elongation_is_at_target(own_engine, krakow):
|
||||
"""W znalezionym momencie elongacja MUSI wynosić 0° albo 180°."""
|
||||
from app.engine.formats import norm360
|
||||
|
||||
s = prenatal_syzygy(own_engine, krakow)
|
||||
when = datetime.fromisoformat(s["when_utc"])
|
||||
pts = {p.name: p.longitude for p in
|
||||
own_engine.positions(ChartMoment(when_utc=when, lat=krakow.lat, lon=krakow.lon),
|
||||
["Sun", "Moon"])}
|
||||
elong = norm360(pts["Moon"] - pts["Sun"])
|
||||
target = 0.0 if s["type"] == "new_moon" else 180.0
|
||||
assert abs(((elong - target + 180.0) % 360.0) - 180.0) < 0.02
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ całość
|
||||
|
||||
def test_build_tables_light_skips_numeric_search(own_engine, krakow):
|
||||
"""heavy=False pomija to, co wymaga szukania numerycznego."""
|
||||
light = build_tables(own_engine, krakow, build_chart(own_engine, krakow), heavy=False)
|
||||
assert "tally" in light and "moon_phase" in light
|
||||
assert "planetary_hours" not in light and "prenatal_syzygy" not in light
|
||||
Reference in New Issue
Block a user