feat: add Railway-ready Jyotish chat product
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PAGE = ROOT / "frontend" / "src" / "app" / "page.tsx"
|
||||
AGENT = ROOT / "frontend" / "src" / "mastra" / "index.ts"
|
||||
ONBOARDING_ROUTE = ROOT / "frontend" / "src" / "app" / "api" / "onboarding" / "route.ts"
|
||||
CONSULT_ROUTE = ROOT / "frontend" / "src" / "app" / "api" / "consult" / "route.ts"
|
||||
ONBOARDING_MIGRATION = (
|
||||
ROOT
|
||||
/ "frontend"
|
||||
/ "supabase"
|
||||
/ "migrations"
|
||||
/ "20260715040000_agent_onboarding_cache.sql"
|
||||
)
|
||||
|
||||
|
||||
def test_onboarding_and_agent_suggestion_contract() -> None:
|
||||
page = PAGE.read_text(encoding="utf-8")
|
||||
agent = AGENT.read_text(encoding="utf-8")
|
||||
route = ONBOARDING_ROUTE.read_text(encoding="utf-8")
|
||||
consult_route = CONSULT_ROUTE.read_text(encoding="utf-8")
|
||||
migration = ONBOARDING_MIGRATION.read_text(encoding="utf-8")
|
||||
|
||||
assert "onboarding-card" in page
|
||||
assert 'type OnboardingStep = "name" | "birth" | "place"' in page
|
||||
assert "text.slice(0, length)" in page
|
||||
assert "window.setInterval" in page
|
||||
assert "prefers-reduced-motion: reduce" in page
|
||||
assert "用于计算星盘,并安全保存到你的账号" not in page
|
||||
assert "saveOnboardingName" in page
|
||||
assert "saveOnboardingBirth" in page
|
||||
assert "saveOnboardingPlace" in page
|
||||
assert "<BirthMomentFields value={profileDraft}" in page
|
||||
assert "<BirthLocationFields value={profileDraft}" in page
|
||||
assert "Enter 确认称呼" in page
|
||||
assert 'fetch("/api/onboarding"' in page
|
||||
assert "onboarding?.suggestions" in page
|
||||
assert "parseAgentReply(answer, theme)" in page
|
||||
assert "suggestions: reply.suggestions" in page
|
||||
assert "message.suggestions.map" in page
|
||||
|
||||
assert "export const onboardingAgent" in agent
|
||||
assert "skills: [jyotishSkillPath]" in agent
|
||||
assert "This is onboarding, not a chart reading" in agent
|
||||
assert "<!--AYANAM_SUGGESTIONS:" in agent
|
||||
assert "grounded in the answer just given" in agent
|
||||
assert "Treat the server-provided current time as authoritative" in agent
|
||||
|
||||
assert "supabase.auth.getUser()" in route
|
||||
assert "function currentTimeContext(now = new Date())" in consult_route
|
||||
assert "currentTimeContext()," in consult_route
|
||||
assert "中国标准时间(UTC+8)" in consult_route
|
||||
assert 'profile.onboarding_version === ONBOARDING_VERSION' in route
|
||||
assert "onboardingAgent.generate" in route
|
||||
assert 'source: "cache"' in route
|
||||
assert "onboarding_payload" in migration
|
||||
assert "to service_role" in migration
|
||||
assert "to authenticated" not in migration
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Regression coverage for starting the API server as a script."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
API_SERVER = ROOT / "scripts" / "jyotish_api_server.py"
|
||||
|
||||
|
||||
def test_script_entrypoint_can_lazy_import_scripts_package_from_another_cwd() -> None:
|
||||
"""The documented script command must retain repository-root package imports."""
|
||||
program = f"""
|
||||
import runpy
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path({str(ROOT)!r})
|
||||
sys.path[:] = [entry for entry in sys.path if Path(entry or '.').resolve() != root]
|
||||
namespace = runpy.run_path({str(API_SERVER)!r}, run_name='jyotish_api_server_script_entrypoint')
|
||||
handler = namespace['JyotishAPIHandler'].__new__(namespace['JyotishAPIHandler'])
|
||||
result = handler._compute_vedastro_gateway_archives()
|
||||
assert isinstance(result, dict)
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", program],
|
||||
cwd="/tmp",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=20,
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Chat-facing consultation contract regression tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
SCRIPTS = os.path.join(os.path.dirname(__file__), '..', 'scripts')
|
||||
if SCRIPTS not in sys.path:
|
||||
sys.path.insert(0, SCRIPTS)
|
||||
|
||||
from jyotish_api_server import ( # noqa: E402
|
||||
JyotishAPIHandler,
|
||||
_attach_local_consultation_layers,
|
||||
_build_consumer_context,
|
||||
)
|
||||
from unified_consultation_orchestrator import UnifiedConsultationOrchestrator # noqa: E402
|
||||
|
||||
|
||||
def _handler() -> JyotishAPIHandler:
|
||||
return JyotishAPIHandler.__new__(JyotishAPIHandler)
|
||||
|
||||
|
||||
def _base_chart() -> dict:
|
||||
longitudes = {
|
||||
'Sun': 120.9,
|
||||
'Moon': 31.4,
|
||||
'Mars': 173.2,
|
||||
'Mercury': 140.1,
|
||||
'Jupiter': 222.1,
|
||||
'Venus': 120.2,
|
||||
'Saturn': 329.5,
|
||||
'Rahu': 215.8,
|
||||
'Ketu': 35.8,
|
||||
}
|
||||
asc_lon = 205.19
|
||||
asc_idx = int(asc_lon / 30)
|
||||
planets = {
|
||||
name: {
|
||||
'lon': lon,
|
||||
'degree': lon % 30,
|
||||
'sign_idx': int(lon / 30),
|
||||
'house': ((int(lon / 30) - asc_idx) % 12) + 1,
|
||||
}
|
||||
for name, lon in longitudes.items()
|
||||
}
|
||||
return {
|
||||
'success': True,
|
||||
'ascendant': {'lon': asc_lon, 'sign_idx': asc_idx, 'sign': 'Libra'},
|
||||
'planets': planets,
|
||||
'houses': {house: {'sign_idx': (asc_idx + house - 1) % 12} for house in range(1, 13)},
|
||||
'dasha': {'current_md': 'Sun'},
|
||||
'modules': {'dasha': {'current_md': 'Sun'}},
|
||||
}
|
||||
|
||||
|
||||
def test_local_consultation_layers_supply_d10_a10_and_narayana_without_vedastro() -> None:
|
||||
chart = _attach_local_consultation_layers(
|
||||
_handler(),
|
||||
_base_chart(),
|
||||
{'year': 1995, 'month': 8, 'day': 18, 'hour': 12, 'minute': 0},
|
||||
{'current_date': '2026-07-14'},
|
||||
)
|
||||
|
||||
modules = chart['modules']
|
||||
assert 'D9_Navamsa' in modules['varga_full']
|
||||
assert 'D10_Dasamsa' in modules['varga_full']
|
||||
assert modules['arudha_padas']['padas']['A10']['name'] == 'Karma Pada (A10)'
|
||||
assert modules['narayana_dasha']['current_dasha']['md']
|
||||
assert chart['local_consultation_layers']['status'] == 'ready'
|
||||
|
||||
packet = UnifiedConsultationOrchestrator().machine_evidence_packet(
|
||||
chart=chart,
|
||||
route_packet={'question_type': 'career', 'primary_theme': 'career'},
|
||||
vedastro_official={'status': 'blocked', 'runtime_truth': {'status': 'blocked'}},
|
||||
)
|
||||
assert packet['sections']['D10']['status'] == 'used'
|
||||
assert packet['sections']['A10']['status'] == 'used'
|
||||
assert packet['sections']['narayana_dasha']['status'] == 'used'
|
||||
|
||||
|
||||
def test_consumer_context_treats_unconfigured_vedastro_as_optional_cross_check() -> None:
|
||||
chart = _attach_local_consultation_layers(
|
||||
_handler(),
|
||||
_base_chart(),
|
||||
{'year': 1995, 'month': 8, 'day': 18, 'hour': 12, 'minute': 0},
|
||||
{'current_date': '2026-07-14'},
|
||||
)
|
||||
orchestrator = UnifiedConsultationOrchestrator()
|
||||
route = orchestrator.resolve_route('请分析我的事业方向', ['career'])
|
||||
official = {
|
||||
'status': 'service_endpoint_not_configured',
|
||||
'runtime_truth': {
|
||||
'status': 'partial',
|
||||
'official_execution_layers': {'chart_core': 'blocked'},
|
||||
'fallback_active': False,
|
||||
},
|
||||
}
|
||||
packet = orchestrator.machine_evidence_packet(
|
||||
chart=chart,
|
||||
route_packet=route,
|
||||
vedastro_official=official,
|
||||
)
|
||||
context = _build_consumer_context(
|
||||
question='请分析我的事业方向',
|
||||
route_packet=route,
|
||||
chart=chart,
|
||||
rectification={
|
||||
'summary': {
|
||||
'headline': '可读主盘,但高敏分盘需要降级',
|
||||
'warned': ['D9', 'D10'],
|
||||
'disabled': [],
|
||||
},
|
||||
},
|
||||
machine_evidence_packet=packet,
|
||||
vedastro_official=official,
|
||||
)
|
||||
|
||||
assert context['core_status'] == 'ready'
|
||||
assert context['hard_blockers'] == []
|
||||
assert context['missing_route_layers'] == []
|
||||
assert context['answer_policy']['can_answer_direction'] is True
|
||||
assert context['answer_policy']['should_lead_with_limitations'] is False
|
||||
assert context['answer_policy']['provider_unavailable_is_fatal'] is False
|
||||
assert context['optional_unavailable_layers'][0]['layer'] == 'vedastro_official_cross_check'
|
||||
|
||||
|
||||
def test_consumer_context_only_leads_with_limits_for_unavailable_precise_timing() -> None:
|
||||
orchestrator = UnifiedConsultationOrchestrator()
|
||||
route = orchestrator.resolve_route('具体哪一个月份适合跳槽?', ['career'])
|
||||
packet = {
|
||||
'sections': {
|
||||
'D1': {'status': 'used'},
|
||||
'D10': {'status': 'used'},
|
||||
'A10': {'status': 'used'},
|
||||
'dasha_boundaries': {'status': 'used'},
|
||||
'narayana_dasha': {'status': 'missing'},
|
||||
'external_oracle_status': {'status': 'official_blocked'},
|
||||
},
|
||||
}
|
||||
context = _build_consumer_context(
|
||||
question='具体哪一个月份适合跳槽?',
|
||||
route_packet=route,
|
||||
chart={'success': True},
|
||||
rectification={'summary': {'warned': ['D10'], 'disabled': []}},
|
||||
machine_evidence_packet=packet,
|
||||
vedastro_official={'status': 'blocked'},
|
||||
)
|
||||
|
||||
assert context['core_status'] == 'degraded'
|
||||
assert context['answer_policy']['can_answer_direction'] is True
|
||||
assert context['answer_policy']['can_answer_precise_timing'] is False
|
||||
assert context['answer_policy']['should_lead_with_limitations'] is True
|
||||
|
||||
|
||||
def test_thematic_career_evidence_uses_local_d10_a10_and_narayana() -> None:
|
||||
handler = _handler()
|
||||
chart = _attach_local_consultation_layers(
|
||||
handler,
|
||||
_base_chart(),
|
||||
{'year': 1995, 'month': 8, 'day': 18, 'hour': 12, 'minute': 0},
|
||||
{'current_date': '2026-07-14'},
|
||||
)
|
||||
|
||||
items = handler._derived_career_evidence(
|
||||
chart,
|
||||
{
|
||||
'career': {'summary': 'career ready'},
|
||||
'shadbala': {},
|
||||
'full_modules': chart['modules'],
|
||||
},
|
||||
)
|
||||
|
||||
by_technique = {item['technique']: item for item in items}
|
||||
assert by_technique['D10-Dashamsha-local']['chart'] == 'D10'
|
||||
assert by_technique['A10-Karma-Pada-local']['chart'] == 'A10'
|
||||
assert by_technique['Narayana-Dasha-local']['chart'] == 'Narayana'
|
||||
assert '已完成' in by_technique['D10-Dashamsha-local']['conclusion']
|
||||
|
||||
|
||||
def test_thematic_report_reuses_attached_chart_modules_without_claiming_d10_missing(monkeypatch) -> None:
|
||||
handler = _handler()
|
||||
chart = _attach_local_consultation_layers(
|
||||
handler,
|
||||
_base_chart(),
|
||||
{'year': 1995, 'month': 8, 'day': 18, 'hour': 12, 'minute': 0},
|
||||
{'current_date': '2026-07-14'},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(handler, '_compute_dasha_system', lambda body: chart['modules']['dasha'])
|
||||
monkeypatch.setattr(handler, '_compute_yogas_api', lambda body: {})
|
||||
monkeypatch.setattr(handler, '_compute_shadbala', lambda body: {})
|
||||
monkeypatch.setattr(handler, '_compute_ashtakavarga', lambda body: {})
|
||||
monkeypatch.setattr(handler, '_compute_relationship', lambda body: {})
|
||||
monkeypatch.setattr(handler, '_compute_career', lambda body: {'summary': 'career ready'})
|
||||
monkeypatch.setattr(handler, '_compute_jaimini', lambda body: chart['modules'].get('jaimini', {}))
|
||||
|
||||
result = handler._compute_thematic_report({
|
||||
'theme': ['career'],
|
||||
'chart_data': {
|
||||
**chart,
|
||||
'skip_full_reading_for_thematic': True,
|
||||
},
|
||||
'skip_full_reading_for_thematic': True,
|
||||
})
|
||||
|
||||
career = result['themes']['career']
|
||||
techniques = {item['technique'] for item in career['evidence']}
|
||||
assert result['mode'] == 'derived_chart_evidence'
|
||||
assert result['evidence_source']['source'] == 'reused_chart_modules'
|
||||
assert result['evidence_source']['chart_modules_reused'] is True
|
||||
assert 'D10-Dashamsha-local' in techniques
|
||||
assert 'A10-Karma-Pada-local' in techniques
|
||||
assert 'Narayana-Dasha-local' in techniques
|
||||
assert 'Dashamsha 未提供显著信息' not in career['narrative']
|
||||
|
||||
|
||||
def test_strict_narrative_tolerates_optional_none_contracts() -> None:
|
||||
from jyotish_engine import _base_strict_narrative_payload
|
||||
|
||||
payload = _base_strict_narrative_payload(
|
||||
'事业',
|
||||
{
|
||||
'event_judgement': None,
|
||||
'adjudication_stages': None,
|
||||
'prediction_boundary_contract': None,
|
||||
},
|
||||
fallback_headline='事业结构可读',
|
||||
strengths=[],
|
||||
risks=[],
|
||||
boundaries=[],
|
||||
)
|
||||
|
||||
assert payload['headline'] == '事业结构可读'
|
||||
assert 'confidence_cap: unknown' in payload['markdown']
|
||||
|
||||
|
||||
def test_consumer_context_does_not_surface_optional_provider_as_user_limitation() -> None:
|
||||
context = _build_consumer_context(
|
||||
question='请分析我的事业方向',
|
||||
route_packet={'question_type': 'career', 'primary_theme': 'career'},
|
||||
chart={'success': True},
|
||||
rectification={'summary': {'warned': ['D9'], 'disabled': []}},
|
||||
machine_evidence_packet={
|
||||
'sections': {
|
||||
'D1': {'status': 'used'},
|
||||
'D10': {'status': 'used'},
|
||||
'A10': {'status': 'used'},
|
||||
'dasha_boundaries': {'status': 'used'},
|
||||
'narayana_dasha': {'status': 'used'},
|
||||
'external_oracle_status': {'status': 'official_blocked'},
|
||||
},
|
||||
},
|
||||
vedastro_official={'status': 'blocked'},
|
||||
)
|
||||
|
||||
assert context['user_facing_limitation'] is None
|
||||
assert context['optional_unavailable_layers'][0]['layer'] == 'vedastro_official_cross_check'
|
||||
|
||||
|
||||
def test_failed_vedastro_raw_packet_is_not_marked_as_used() -> None:
|
||||
packet = UnifiedConsultationOrchestrator().machine_evidence_packet(
|
||||
chart=_base_chart(),
|
||||
route_packet={'question_type': 'career', 'primary_theme': 'career'},
|
||||
vedastro_official={
|
||||
'status': 'partial',
|
||||
'runtime_truth': {
|
||||
'status': 'partial',
|
||||
'official_execution_layers': {'chart_core': 'blocked'},
|
||||
},
|
||||
'raw_response': {
|
||||
'sections': {
|
||||
'chart_core': {'Status': 'Fail', 'Payload': {'status': 'python_package_not_installed'}},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert packet['sections']['vedastro_official_raw_response']['status'] == 'received_unverified'
|
||||
@@ -2442,8 +2442,8 @@ def test_desktop_packaging_spike_is_documented_and_checkable() -> None:
|
||||
assert "desktop_packaging_preflight.py" in readme
|
||||
assert "tests/run_frontend_click_smoke.py --mode all" in readme
|
||||
assert "普通用户启动路径" in readme
|
||||
assert "先启动网页服务:cd jyotish-app && npm run dev -- --host 127.0.0.1 --port 5173" in readme
|
||||
assert "再启动本地 API 服务:python3 scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200" in readme
|
||||
assert "先启动本地 API 服务:`.venv/bin/python scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200`" in readme
|
||||
assert "再启动网页服务:`cd jyotish-app && npm run dev -- --host 127.0.0.1 --port 5173`" in readme
|
||||
assert "打开 Trust Center,点击运行健康检查" in readme
|
||||
assert "PWA 安装壳只包装网页服务,本地 API 服务仍需单独启动" in readme
|
||||
assert "安装后首次打开" in spike
|
||||
@@ -2458,7 +2458,7 @@ def test_desktop_packaging_spike_is_documented_and_checkable() -> None:
|
||||
quality_gate = (ROOT / "scripts" / "run_quality_gate.py").read_text(encoding="utf-8")
|
||||
assert "普通用户启动路径" in quality_gate
|
||||
assert "cd jyotish-app && npm run dev -- --host 127.0.0.1 --port 5173" in quality_gate
|
||||
assert "python3 scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200" in quality_gate
|
||||
assert ".venv/bin/python scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200" in quality_gate
|
||||
|
||||
|
||||
def test_user_delivery_matrix_is_documented_and_checkable() -> None:
|
||||
@@ -2477,7 +2477,7 @@ def test_user_delivery_matrix_is_documented_and_checkable() -> None:
|
||||
"desktop-shell",
|
||||
"public demo shell",
|
||||
"api_required",
|
||||
"python3 scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200",
|
||||
".venv/bin/python scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200",
|
||||
"npm run preview -- --host 127.0.0.1 --port 4173",
|
||||
"http://localhost:5300",
|
||||
]:
|
||||
@@ -2755,7 +2755,7 @@ def test_user_startup_labels_are_consistent_across_recovery_surfaces() -> None:
|
||||
assert token in app_text
|
||||
for command in [
|
||||
"cd jyotish-app && npm run dev -- --host 127.0.0.1 --port 5173",
|
||||
"python3 scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200",
|
||||
".venv/bin/python scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200",
|
||||
]:
|
||||
assert command in readme
|
||||
assert command in quality_gate
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LOGIN = ROOT / "frontend" / "src" / "app" / "login" / "page.tsx"
|
||||
README = ROOT / "frontend" / "README.md"
|
||||
|
||||
|
||||
def test_email_login_sends_and_verifies_otp() -> None:
|
||||
source = LOGIN.read_text(encoding="utf-8")
|
||||
readme = README.read_text(encoding="utf-8")
|
||||
|
||||
assert ".auth.signInWithOtp" in source
|
||||
assert ".auth.verifyOtp" in source
|
||||
assert 'type: "email"' in source
|
||||
assert "emailRedirectTo" not in source
|
||||
assert "发送验证码" in source
|
||||
assert "{{ .Token }}" in readme
|
||||
|
||||
|
||||
def test_admin_pages_are_guarded_server_side() -> None:
|
||||
layout = (ROOT / "frontend" / "src" / "app" / "admin" / "layout.tsx").read_text(encoding="utf-8")
|
||||
|
||||
assert "createServerSupabaseClient" in layout
|
||||
assert "isAdminEmail(user.email)" in layout
|
||||
assert 'redirect("/login")' in layout
|
||||
assert 'redirect("/")' in layout
|
||||
@@ -0,0 +1,17 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_railway_services_use_the_product_frontend_and_dynamic_ports() -> None:
|
||||
web = (ROOT / "deploy" / "railway-web.Dockerfile").read_text(encoding="utf-8")
|
||||
api = (ROOT / "deploy" / "railway-api.Dockerfile").read_text(encoding="utf-8")
|
||||
|
||||
assert "COPY frontend/package.json frontend/package-lock.json" in web
|
||||
assert "--hostname 0.0.0.0" in web and "${PORT:-3000}" in web
|
||||
assert "next-env.d.ts" not in web
|
||||
|
||||
assert "COPY SKILL.md mcp_server.py" in api
|
||||
assert "--host 0.0.0.0" in api and "${PORT:-5200}" in api
|
||||
assert "http.server" not in api
|
||||
@@ -0,0 +1,103 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MIGRATION = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "frontend"
|
||||
/ "supabase"
|
||||
/ "migrations"
|
||||
/ "20260715030000_user_profiles_chat_sessions.sql"
|
||||
)
|
||||
COORDS_MIGRATION = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "frontend"
|
||||
/ "supabase"
|
||||
/ "migrations"
|
||||
/ "20260715050000_profile_coordinates.sql"
|
||||
)
|
||||
PAGE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "page.tsx"
|
||||
|
||||
|
||||
def _sql() -> str:
|
||||
return re.sub(r"\s+", " ", MIGRATION.read_text(encoding="utf-8").lower()).strip()
|
||||
|
||||
|
||||
def test_user_profile_and_chat_session_database_contract() -> None:
|
||||
sql = _sql()
|
||||
|
||||
for definition in (
|
||||
"name text",
|
||||
"birth_date date",
|
||||
"birth_time time without time zone",
|
||||
"country_code text",
|
||||
"province_code text",
|
||||
"city_code text",
|
||||
"district_code text",
|
||||
):
|
||||
assert f"add column if not exists {definition}" in sql
|
||||
|
||||
assert "create policy profiles_update_own" in sql
|
||||
assert "for update to authenticated using ((select auth.uid()) = id) with check ((select auth.uid()) = id)" in sql
|
||||
assert "grant update ( name, birth_date, birth_time, country_code, province_code, city_code, district_code, updated_at ) on table public.profiles to authenticated" in sql
|
||||
|
||||
assert "create table if not exists public.chat_sessions" in sql
|
||||
for definition in (
|
||||
"id uuid primary key default gen_random_uuid()",
|
||||
"user_id uuid not null references auth.users(id) on delete cascade",
|
||||
"title text not null default '新对话'",
|
||||
"theme text not null default 'general'",
|
||||
"messages jsonb not null default '[]'::jsonb",
|
||||
"created_at timestamptz not null default now()",
|
||||
"updated_at timestamptz not null default now()",
|
||||
):
|
||||
assert definition in sql
|
||||
assert "check (theme in ('career', 'marriage', 'timing', 'general'))" in sql
|
||||
assert "check (jsonb_typeof(messages) = 'array')" in sql
|
||||
assert "alter table public.chat_sessions enable row level security" in sql
|
||||
assert "create policy chat_sessions_select_own on public.chat_sessions for select to authenticated using ((select auth.uid()) = user_id)" in sql
|
||||
assert "create policy chat_sessions_insert_own on public.chat_sessions for insert to authenticated with check ((select auth.uid()) = user_id)" in sql
|
||||
assert "create policy chat_sessions_update_own on public.chat_sessions for update to authenticated using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id)" in sql
|
||||
assert "revoke all on table public.chat_sessions from anon, authenticated, service_role" in sql
|
||||
assert "grant select on table public.chat_sessions to authenticated" in sql
|
||||
assert "grant insert (id, user_id, title, theme, messages, updated_at) on table public.chat_sessions to authenticated" in sql
|
||||
assert "grant update (title, theme, messages, updated_at) on table public.chat_sessions to authenticated" in sql
|
||||
assert "grant delete" not in sql
|
||||
|
||||
|
||||
def test_chat_page_uses_authenticated_cloud_persistence() -> None:
|
||||
source = PAGE.read_text(encoding="utf-8")
|
||||
|
||||
assert '.from("profiles")' in source
|
||||
assert '.from("chat_sessions")' in source
|
||||
assert 'user_id: account.user.id' in source
|
||||
assert '.upsert(' not in source
|
||||
assert '.update(values)' in source
|
||||
assert '.insert({' in source
|
||||
assert source.index('await persistSession(userSession)') < source.index('setOnboardingJustCompleted(false)')
|
||||
assert source.index('await persistSession(userSession)') < source.index('updateSession(sessionId, () => userSession)')
|
||||
assert 'function completedOnboardingTranscript(profile: Profile): Message[]' in source
|
||||
assert 'messages: [...preservedMessages, { role: "user", text: question }]' in source
|
||||
assert "localStorage" not in source
|
||||
assert "ayanam-profile" not in source
|
||||
assert "ayanam-sessions" not in source
|
||||
|
||||
|
||||
def test_profile_coordinates_are_persisted_with_database_bounds() -> None:
|
||||
sql = re.sub(r"\s+", " ", COORDS_MIGRATION.read_text(encoding="utf-8").lower()).strip()
|
||||
source = PAGE.read_text(encoding="utf-8")
|
||||
|
||||
for definition in (
|
||||
"latitude double precision",
|
||||
"longitude double precision",
|
||||
"timezone_offset double precision",
|
||||
):
|
||||
assert f"add column if not exists {definition}" in sql
|
||||
|
||||
assert "latitude between -90 and 90" in sql
|
||||
assert "longitude between -180 and 180" in sql
|
||||
assert "timezone_offset between -12 and 14" in sql
|
||||
assert "grant update (latitude, longitude, timezone_offset) on table public.profiles to authenticated" in sql
|
||||
assert "latitude: birthPlace?.lat ?? null" in source
|
||||
assert "longitude: birthPlace?.lon ?? null" in source
|
||||
assert "timezone_offset: birthPlace?.tz ?? null" in source
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def _birth_case() -> dict[str, object]:
|
||||
return {
|
||||
"year": 1992,
|
||||
"month": 8,
|
||||
"day": 17,
|
||||
"hour": 14,
|
||||
"minute": 30,
|
||||
"lat": 23.1291,
|
||||
"lon": 113.2644,
|
||||
"tz": 8,
|
||||
"reference_date": "2026-07-14",
|
||||
}
|
||||
|
||||
|
||||
def test_vedastro_child_process_defaults_to_active_interpreter(monkeypatch) -> None:
|
||||
from scripts import vedastro_service_adapter
|
||||
|
||||
monkeypatch.delenv("VEDASTRO_PYTHON_BIN", raising=False)
|
||||
monkeypatch.delenv("PYTHON_BIN", raising=False)
|
||||
|
||||
assert vedastro_service_adapter._vedastro_python_bin() == sys.executable
|
||||
|
||||
|
||||
def test_fast_snapshot_executes_only_five_scalar_methods(monkeypatch) -> None:
|
||||
from scripts import vedastro_official_capability_runner as runner
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_call(method: str, _payload: dict[str, object]) -> dict[str, object]:
|
||||
calls.append(method)
|
||||
return {"available": True, "status": "ok", "result": {"method": method}}
|
||||
|
||||
monkeypatch.setattr(runner, "SNAPSHOT_FANOUT_ENABLED", False)
|
||||
monkeypatch.setattr(runner, "_call_bridge", fake_call)
|
||||
|
||||
result = runner.run_snapshot_bundle("official_full_snapshot", _birth_case())
|
||||
|
||||
assert calls == [
|
||||
"DasaAtRange",
|
||||
"DasaAtTime",
|
||||
"GetCharaDasaAtTime",
|
||||
"AllPlanetStrength",
|
||||
"AshtakvargaLifeMap",
|
||||
]
|
||||
assert result["available"] is True
|
||||
assert result["summary"]["requested_method_count"] == 5
|
||||
assert result["summary"]["fanout_enabled"] is False
|
||||
assert set(result["result"]["snapshot_sections"]) == {
|
||||
"dasha_all",
|
||||
"vimshottari_now",
|
||||
"chara_dasha_now",
|
||||
"shadbala",
|
||||
"ashtakavarga",
|
||||
}
|
||||
|
||||
|
||||
def test_disabled_range_scan_never_calls_network(monkeypatch) -> None:
|
||||
from scripts import vedastro_service_adapter
|
||||
|
||||
monkeypatch.setenv("VEDASTRO_API_ENDPOINT", "https://api.vedastro.org/api")
|
||||
monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "1")
|
||||
monkeypatch.setenv("VEDASTRO_RANGE_SCAN_NETWORK_ENABLED", "0")
|
||||
|
||||
def fail_if_called(*_args, **_kwargs):
|
||||
raise AssertionError("range scan network request should not run in chat mode")
|
||||
|
||||
monkeypatch.setattr(vedastro_service_adapter, "_post_json_with_retry", fail_if_called)
|
||||
|
||||
result = vedastro_service_adapter.run_range_scan_for_case(
|
||||
_birth_case(),
|
||||
"career",
|
||||
"2026-07-14",
|
||||
"2026-08-14",
|
||||
)
|
||||
|
||||
assert result["status"] == "network_execution_disabled"
|
||||
assert "interactive chat path" in result["reason"]
|
||||
Reference in New Issue
Block a user