Compare commits
1 Commits
fix/1705
...
step35/731
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
936d4155e0 |
10
app.js
10
app.js
@@ -10,7 +10,6 @@ import { MemoryOptimizer } from './nexus/components/memory-optimizer.js';
|
||||
import { MemoryInspect } from './nexus/components/memory-inspect.js';
|
||||
import { MemoryPulse } from './nexus/components/memory-pulse.js';
|
||||
import { ReasoningTrace } from './nexus/components/reasoning-trace.js';
|
||||
import { formatTempusCaeleste, formatTempusBrevis, formatTempusPlenus } from './tempus-caeleste.js';
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// NEXUS v1.1 — Portal System Update
|
||||
@@ -2305,7 +2304,9 @@ function connectHermes() {
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/world/ws`;
|
||||
// Per BROWSER_CONTRACT (§7 WebSocket Contract), the Nexus gateway listens directly
|
||||
// on ws://127.0.0.1:8765. Using a fixed endpoint avoids path-based proxying during local smoke.
|
||||
const wsUrl = protocol + '//127.0.0.1:8765';
|
||||
|
||||
console.log(`Connecting to Hermes at ${wsUrl}...`);
|
||||
hermesWs = new WebSocket(wsUrl);
|
||||
@@ -2547,8 +2548,9 @@ function renderEvenniaRoomPanel() {
|
||||
const roomKeyEl = document.getElementById('erp-footer-room');
|
||||
if (tsEl) {
|
||||
try {
|
||||
tsEl.textContent = formatTempusPlenus(evenniaRoom.timestamp);
|
||||
} catch(e) { tsEl.textContent = 'Tempus Incertum'; }
|
||||
const d = new Date(evenniaRoom.timestamp);
|
||||
tsEl.textContent = d.toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
|
||||
} catch(e) { tsEl.textContent = '—'; }
|
||||
}
|
||||
if (roomKeyEl) roomKeyEl.textContent = evenniaRoom.roomKey;
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* Tempus Caeleste — Celestial Timestamp Formatter
|
||||
* Replaces machine time with celestial/Latin display strings.
|
||||
* Internal UTC/ISO timestamps are preserved for audit; this is display-only.
|
||||
*/
|
||||
|
||||
const TEMPUS_REF_NEW_MOON = new Date('2023-01-21T20:53:00Z');
|
||||
const TEMPUS_MOON_CYCLE_DAYS = 29.53058867;
|
||||
|
||||
// Solar phase definitions (local time)
|
||||
const SOLAR_PHASES = [
|
||||
{ id: 'noctis', label: 'Hora Noctis', start: 21, end: 24 },
|
||||
{ id: 'noctis2', label: 'Hora Noctis', start: 0, end: 5 },
|
||||
{ id: 'aurorae', label: 'Hora Aurorae', start: 6, end: 8 },
|
||||
{ id: 'meridiana', label: 'Hora Meridiana', start: 9, end: 16 },
|
||||
{ id: 'vesperi', label: 'Hora Vesperi', start: 17, end: 20 },
|
||||
];
|
||||
|
||||
// Moon phase definitions
|
||||
const MOON_PHASES = [
|
||||
{ id: 'nova', label: 'Sub luna nova', min: 0, max: 1.84 },
|
||||
{ id: 'crescente', label: 'Sub luna crescente', min: 1.84, max: 7.38 },
|
||||
{ id: 'dimidiata-prima', label: 'Sub luna dimidiata prima', min: 7.38, max: 10.69 },
|
||||
{ id: 'gibbosa-crescens', label: 'Sub luna gibbosa crescens', min: 10.69, max: 18.22 },
|
||||
{ id: 'plena', label: 'Sub luna plena', min: 18.22, max: 22.53 },
|
||||
{ id: 'gibbosa-decrescens', label: 'Sub luna gibbosa decrescens', min: 22.53, max: 25.84 },
|
||||
{ id: 'dimidiata-ultima', label: 'Sub luna dimidiata ultima', min: 25.84, max: 27.38 },
|
||||
{ id: 'decrescens', label: 'Sub luna decrescens', min: 27.38, max: 29.53 },
|
||||
];
|
||||
|
||||
function getSolarPhase(date) {
|
||||
const localHours = date.getHours();
|
||||
const phase = SOLAR_PHASES.find(p =>
|
||||
(p.start <= p.end && localHours >= p.start && localHours <= p.end) ||
|
||||
(p.start > p.end && (localHours >= p.start || localHours <= p.end))
|
||||
);
|
||||
return phase ? phase.label : 'Hora Incerta';
|
||||
}
|
||||
|
||||
function getMoonPhase(date) {
|
||||
const msPerDay = 86400000;
|
||||
const diffMs = date.getTime() - TEMPUS_REF_NEW_MOON.getTime();
|
||||
const diffDays = diffMs / msPerDay;
|
||||
const phaseDay = ((diffDays % TEMPUS_MOON_CYCLE_DAYS) + TEMPUS_MOON_CYCLE_DAYS) % TEMPUS_MOON_CYCLE_DAYS;
|
||||
|
||||
const phase = MOON_PHASES.find(p => phaseDay >= p.min && phaseDay < p.max);
|
||||
return phase ? phase.label : 'Luna Incerta';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a canonical UTC/ISO timestamp into Tempus Caeleste display string.
|
||||
* @param {string} isoString - UTC/ISO timestamp (e.g., "2026-04-29T12:34:56Z")
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.includeMoon=true] - Include moon phase
|
||||
* @param {boolean} [options.includeSolar=true] - Include solar phase
|
||||
* @param {boolean} [options.completed=false] - Append "Actum est" for completed events
|
||||
* @returns {string} Celestial/Latin formatted timestamp
|
||||
*/
|
||||
export function formatTempusCaeleste(isoString, options = {}) {
|
||||
const { includeMoon = true, includeSolar = true, completed = false } = options;
|
||||
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
if (isNaN(date.getTime())) return 'Tempus Incertum';
|
||||
|
||||
const parts = [];
|
||||
|
||||
// Add solar phase
|
||||
if (includeSolar) {
|
||||
parts.push(getSolarPhase(date));
|
||||
}
|
||||
|
||||
// Add moon phase
|
||||
if (includeMoon) {
|
||||
parts.push(getMoonPhase(date));
|
||||
}
|
||||
|
||||
// Add raw local time as fallback (optional, for debug)
|
||||
// parts.push(date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
||||
|
||||
let result = parts.join(' · ');
|
||||
|
||||
// Add completion marker
|
||||
if (completed) {
|
||||
result += ' · Actum est';
|
||||
}
|
||||
|
||||
return result || 'Tempus Caeleste';
|
||||
} catch (e) {
|
||||
return 'Tempus Incertum';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a short celestial timestamp (solar phase only, no moon)
|
||||
*/
|
||||
export function formatTempusBrevis(isoString) {
|
||||
return formatTempusCaeleste(isoString, { includeMoon: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a full celestial timestamp (solar + moon + completion if needed)
|
||||
*/
|
||||
export function formatTempusPlenus(isoString, completed = false) {
|
||||
return formatTempusCaeleste(isoString, { completed });
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* Tempus Caeleste Formatter Tests
|
||||
* Covers solar phases, moon phases, and edge cases.
|
||||
*/
|
||||
|
||||
import { formatTempusCaeleste, formatTempusBrevis, formatTempusPlenus } from '../tempus-caeleste.js';
|
||||
|
||||
// Helper to create ISO string from date components (local time)
|
||||
function makeISO(year, month, day, hour, minute) {
|
||||
const d = new Date(year, month - 1, day, hour, minute);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
// ─── Solar Phase Tests ──────────────────────────────────
|
||||
|
||||
export function testDawnPhase() {
|
||||
// 6:30 AM = Hora Aurorae
|
||||
const iso = makeISO(2026, 4, 29, 6, 30);
|
||||
const result = formatTempusBrevis(iso);
|
||||
console.assert(result.includes('Hora Aurorae'), `Expected Hora Aurorae, got: ${result}`);
|
||||
console.log('✓ Dawn phase test passed');
|
||||
}
|
||||
|
||||
export function testNoonPhase() {
|
||||
// 12:30 PM = Hora Meridiana
|
||||
const iso = makeISO(2026, 4, 29, 12, 30);
|
||||
const result = formatTempusBrevis(iso);
|
||||
console.assert(result.includes('Hora Meridiana'), `Expected Hora Meridiana, got: ${result}`);
|
||||
console.log('✓ Noon phase test passed');
|
||||
}
|
||||
|
||||
export function testDuskPhase() {
|
||||
// 6:30 PM = Hora Vesperi
|
||||
const iso = makeISO(2026, 4, 29, 18, 30);
|
||||
const result = formatTempusBrevis(iso);
|
||||
console.assert(result.includes('Hora Vesperi'), `Expected Hora Vesperi, got: ${result}`);
|
||||
console.log('✓ Dusk phase test passed');
|
||||
}
|
||||
|
||||
export function testNightPhase() {
|
||||
// 11:30 PM = Hora Noctis
|
||||
const iso = makeISO(2026, 4, 29, 23, 30);
|
||||
const result = formatTempusBrevis(iso);
|
||||
console.assert(result.includes('Hora Noctis'), `Expected Hora Noctis, got: ${result}`);
|
||||
console.log('✓ Night phase test passed');
|
||||
}
|
||||
|
||||
// ─── Moon Phase Tests ──────────────────────────────────
|
||||
|
||||
export function testFullMoonPhase() {
|
||||
// 2026-05-05 12:00 is full moon (phase day ~18.57)
|
||||
const iso = makeISO(2026, 5, 5, 12, 0);
|
||||
const result = formatTempusPlenus(iso);
|
||||
console.assert(result.includes('Sub luna plena'), `Expected Sub luna plena, got: ${result}`);
|
||||
console.log('✓ Full moon phase test passed');
|
||||
}
|
||||
|
||||
export function testNewMoonPhase() {
|
||||
// 2026-04-17 is new moon (40 cycles after 2023-01-21: 2023-01-21 + 29.53*40 ≈ 2026-04-17)
|
||||
const iso = makeISO(2026, 4, 17, 12, 0);
|
||||
const result = formatTempusPlenus(iso);
|
||||
console.assert(result.includes('Sub luna nova'), `Expected Sub luna nova, got: ${result}`);
|
||||
console.log('✓ New moon phase test passed');
|
||||
}
|
||||
|
||||
// ─── Edge Cases ───────────────────────────────────────
|
||||
|
||||
export function testInvalidTimestamp() {
|
||||
const result = formatTempusCaeleste('invalid-date');
|
||||
console.assert(result === 'Tempus Incertum', `Expected Tempus Incertum, got: ${result}`);
|
||||
console.log('✓ Invalid timestamp test passed');
|
||||
}
|
||||
|
||||
export function testCompletedEvent() {
|
||||
const iso = makeISO(2026, 4, 29, 12, 0);
|
||||
const result = formatTempusPlenus(iso, true);
|
||||
console.assert(result.includes('Actum est'), `Expected Actum est, got: ${result}`);
|
||||
console.log('✓ Completed event test passed');
|
||||
}
|
||||
|
||||
// ─── Run All Tests ────────────────────────────────────
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const tests = [
|
||||
testDawnPhase,
|
||||
testNoonPhase,
|
||||
testDuskPhase,
|
||||
testNightPhase,
|
||||
testFullMoonPhase,
|
||||
testNewMoonPhase,
|
||||
testInvalidTimestamp,
|
||||
testCompletedEvent,
|
||||
];
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const test of tests) {
|
||||
try {
|
||||
test();
|
||||
passed++;
|
||||
} catch (e) {
|
||||
console.error(`✗ ${test.name} failed:`, e.message);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import sys
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -60,6 +62,54 @@ def browser_page(http_server):
|
||||
browser.close()
|
||||
|
||||
|
||||
def wait_for_port(host, port, timeout=5):
|
||||
"""Wait until a TCP port becomes available."""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=1):
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.2)
|
||||
raise TimeoutError(f"Port {host}:{port} not listening after {timeout}s")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ws_server():
|
||||
"""Start the Nexus WebSocket gateway server for smoke tests."""
|
||||
proc = subprocess.Popen([sys.executable, 'server.py'], cwd=REPO_ROOT,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
try:
|
||||
wait_for_port('127.0.0.1', 8765, timeout=5)
|
||||
yield 'ws://127.0.0.1:8765'
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def evennia_page(http_server, ws_server):
|
||||
"""Launch a browser page with the WS server running and connected."""
|
||||
SCREENSHOT_DIR.mkdir(exist_ok=True)
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True, args=["--no-sandbox", "--disable-gpu"])
|
||||
context = browser.new_context(viewport={"width": 1280, "height": 720}, ignore_https_errors=True)
|
||||
page = context.new_page()
|
||||
console_errors = []
|
||||
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
|
||||
page.goto(http_server, wait_until="domcontentloaded", timeout=30000)
|
||||
try:
|
||||
page.wait_for_function("() => window.wsConnected === true", timeout=10000)
|
||||
except Exception:
|
||||
pass
|
||||
yield page
|
||||
browser.close()
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static asset tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -291,3 +341,56 @@ class TestBrowserProvenance:
|
||||
content = resp.read().decode("utf-8", errors="replace")
|
||||
assert "<title>The Nexus" in content or "Timmy" in content, \
|
||||
"index.html title does not reference The Nexus"
|
||||
|
||||
class TestOfflineState:
|
||||
"""Verify honest offline behavior when the Evennia bridge is absent."""
|
||||
|
||||
def test_ws_connection_absent(self, browser_page):
|
||||
"""WebSocket should not be connected without server running."""
|
||||
time.sleep(2)
|
||||
connected = browser_page.evaluate("() => window.wsConnected === true")
|
||||
assert not connected, "WebSocket should not be connected without bridge"
|
||||
|
||||
def test_evennia_room_panel_offline(self, browser_page):
|
||||
"""ERP panel should show empty/placeholder state when no world feed."""
|
||||
erp_empty = browser_page.query_selector("#erp-empty")
|
||||
if erp_empty and erp_empty.is_visible():
|
||||
return # Expected offline empty indicator visible
|
||||
title_el = browser_page.query_selector("#erp-room-title")
|
||||
if title_el:
|
||||
assert title_el.inner_text().strip() == "", "ERP title should be empty offline"
|
||||
|
||||
|
||||
class TestEvenniaFeed:
|
||||
"""Browser smoke + visual proof for the Evennia-fed Nexus shell."""
|
||||
|
||||
def test_websocket_connection(self, evennia_page):
|
||||
"""Confirm Hermes WebSocket connects when gateway is running."""
|
||||
conn = evennia_page.evaluate("() => window.wsConnected === true")
|
||||
assert conn, "Hermes WebSocket should be connected"
|
||||
|
||||
def test_erp_populated(self, evennia_page):
|
||||
"""Inject a room snapshot and verify Evennia Room Panel displays it."""
|
||||
subprocess.run(
|
||||
[sys.executable, '-m', 'nexus.evennia_ws_bridge', 'inject',
|
||||
'room_snapshot',
|
||||
'--title', 'The Threshold',
|
||||
'--desc', 'You stand at the threshold of the Nexus.',
|
||||
'--ws', 'ws://127.0.0.1:8765'],
|
||||
cwd=REPO_ROOT, capture_output=True, text=True, check=True
|
||||
)
|
||||
time.sleep(1) # propagation
|
||||
|
||||
try:
|
||||
evennia_page.wait_for_selector("#erp-room-title:has-text('The Threshold')", timeout=5000)
|
||||
except Exception:
|
||||
title_el = evennia_page.query_selector("#erp-room-title")
|
||||
assert title_el and "Threshold" in title_el.inner_text()
|
||||
|
||||
def test_screenshot_evennia_fed(self, evennia_page):
|
||||
"""Capture a screenshot showing live Evennia-fed state."""
|
||||
evennia_page.wait_for_selector("#erp-room")
|
||||
path = SCREENSHOT_DIR / "smoke-evennia-fed.png"
|
||||
evennia_page.screenshot(path=str(path))
|
||||
assert path.exists() and path.stat().st_size > 1000
|
||||
|
||||
|
||||
Reference in New Issue
Block a user