550c98f275
Reorganización física de crates/: - core/ (mezclaba 6 propósitos) se divide en protocol/, init/, runtime/, compat/ - shared/ (3 crates) se redistribuye en protocol/ e init/ - lapaloma (sub-módulo de ui_engine) se promueve a modules/pineal/ Renames de proyectos: - shipote → shuma (runtime de sandboxes) - nouser → akasha (explorador de Mónadas) - yahweh → nahual (motor GPUI, antes ui_engine/) - lapaloma → pineal (data-viz agnóstica) Fraccionamiento UI → core agnóstico: - vista-core (DeckState + snap, 175 LOC, 5 tests verdes) - barra-core (Task + render_html + sanitize, 90 LOC, 5 tests verdes) - vista-web y barra-web ahora son thin DOM bindings Documentación nueva: - 16 SDDs por subdirectorio (≤80 LOC c/u): protocol/init/runtime/compat + 10 módulos + apps/ - docs/STATUS.md con cifras reales por proyecto - docs/ROADMAP.md con plan a finalización (6 hitos, ~6-8 semanas) - CHANGELOG.md particionado en docs/changelog/<proyecto>.md (7 buckets) Automatización: - scripts/reorg.py — script idempotente que: git mv directorios, renombra package names, recomputa path = refs, reescribe imports rust, actualiza workspace Cargo.toml. Soporta --dry-run. - scripts/split-changelog.py — particiona CHANGELOG por componente. Validación: - cargo check --workspace pasa (124 crates + 2 nuevos cores). - 10 tests adicionales (5 en vista-core + 5 en barra-core) verdes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
623 lines
22 KiB
Rust
623 lines
22 KiB
Rust
//! `brahman-broker-explorer` — probe GUI del broker brahman.
|
|
//!
|
|
//! Cada [`POLL_INTERVAL`] arma un Card observer agnóstico y lo
|
|
//! manda al broker via `brahman_sidecar::await_provider_blocking`
|
|
//! (que internamente abre tokio runtime + Unix socket + handshake).
|
|
//! Reporta 3 estados:
|
|
//!
|
|
//! - **Down**: connect failed (broker no escucha en el socket).
|
|
//! - **Up sin provider**: connect OK, pero el broker no encontró
|
|
//! productor para el flow probado dentro del timeout.
|
|
//! - **Up con provider**: connect OK + el broker matcheó algo →
|
|
//! muestra el `producer_service_socket` recibido.
|
|
//!
|
|
//! Configuración via env:
|
|
//! - `BRAHMAN_INIT_SOCKET` — path del socket del broker (default
|
|
//! resuelto por `brahman_handshake::transport`).
|
|
//! - `BRAHMAN_BROKER_PROBE_FLOW` — nombre del flow probe (default
|
|
//! `broker-health`).
|
|
//! - `BRAHMAN_BROKER_PROBE_TYPE` — type name del flow probe
|
|
//! (default `ping`).
|
|
//!
|
|
//! Usá un type name probable (ej. `monad-list:json`,
|
|
//! `event-log:tail`) para detectar productores específicos del
|
|
//! ecosistema.
|
|
|
|
use std::path::PathBuf;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use std::collections::HashSet;
|
|
|
|
use brahman_handshake::messages::SessionList;
|
|
use brahman_handshake::transport;
|
|
use brahman_sidecar::{
|
|
await_provider_blocking, build_consumer_card, list_matches_blocking, list_sessions_blocking,
|
|
ConsumerError,
|
|
};
|
|
use ulid::Ulid;
|
|
use gpui::{
|
|
div, prelude::*, px, Context, IntoElement, Render, SharedString, Window,
|
|
};
|
|
use nahual_launcher::launch_app;
|
|
use nahual_theme::Theme;
|
|
use nahual_widget_app_header::app_header;
|
|
use nahual_widget_banner::{banner_themed, Banner};
|
|
use nahual_widget_stat_card::stat_card;
|
|
|
|
const POLL_INTERVAL: Duration = Duration::from_secs(5);
|
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(1);
|
|
|
|
/// Cap del buffer del timeline. Mantenemos las últimas N entries —
|
|
/// más viejo se descarta. 50 cubre ~4 minutos de actividad densa
|
|
/// sin saturar el panel; subir si hace falta historia más larga.
|
|
const TIMELINE_CAP: usize = 50;
|
|
|
|
fn main() {
|
|
launch_app("Brahman Broker — Probe", (720., 480.), Explorer::new);
|
|
}
|
|
|
|
/// Snapshot de un probe.
|
|
#[derive(Clone, Debug)]
|
|
enum ProbeState {
|
|
/// Aún no probó (boot, primer ciclo).
|
|
Pending,
|
|
/// Connect failed → broker no responde en el path.
|
|
Down { reason: String },
|
|
/// Connect OK, sin matching producer dentro del timeout.
|
|
UpNoProvider { flow: String },
|
|
/// Connect OK, broker matcheó al menos un producer.
|
|
UpWithProvider {
|
|
flow: String,
|
|
producer_socket: PathBuf,
|
|
},
|
|
}
|
|
|
|
struct Explorer {
|
|
socket_path: PathBuf,
|
|
flow: String,
|
|
type_name: String,
|
|
state: ProbeState,
|
|
last_probe_ms: u64,
|
|
last_probe_at: Option<Instant>,
|
|
/// Última `SessionList` recibida del broker (None = aún sin pedir
|
|
/// o último intento falló).
|
|
sessions: Option<SessionList>,
|
|
/// Set de matches presentes en el último snapshot del broker.
|
|
/// Identificado por `(consumer.session, consumer.flow,
|
|
/// producer.session, producer.flow)` para que la diff entre
|
|
/// ticks distinga "nuevo match" vs "match perdido". Un cambio
|
|
/// de producer (otro session/flow para mismo consumer) cuenta
|
|
/// como Lost del previo + Available del nuevo.
|
|
last_match_keys: HashSet<MatchKey>,
|
|
/// Timeline FIFO: los más nuevos al frente. Cada entry tiene un
|
|
/// timestamp local + el evento sintético (Available/Lost) que
|
|
/// surgió de la diff del tick.
|
|
timeline: std::collections::VecDeque<TimelineEntry>,
|
|
}
|
|
|
|
/// Key estable para un match. Tupla porque (consumer, producer)
|
|
/// determina el match unívocamente; los campos derivados (`label`,
|
|
/// `via`, `pinned`) viajan en la entry pero no en la key.
|
|
type MatchKey = (Ulid, String, Ulid, String);
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct TimelineEntry {
|
|
/// Cuándo lo observó el explorer. Es tiempo local de wall-clock,
|
|
/// no del broker — el broker no timestampa los matches.
|
|
at: std::time::SystemTime,
|
|
/// Available = nuevo en este tick. Lost = estaba en el tick
|
|
/// anterior y desapareció.
|
|
kind: brahman_handshake::messages::MatchEventKind,
|
|
consumer_label: String,
|
|
consumer_flow: String,
|
|
producer_label: String,
|
|
producer_flow: String,
|
|
via: brahman_broker::MatchStrategy,
|
|
pinned: bool,
|
|
}
|
|
|
|
impl Explorer {
|
|
fn new(cx: &mut Context<Self>) -> Self {
|
|
let socket_path = transport::default_socket_path();
|
|
let flow = std::env::var("BRAHMAN_BROKER_PROBE_FLOW")
|
|
.unwrap_or_else(|_| "broker-health".to_string());
|
|
let type_name = std::env::var("BRAHMAN_BROKER_PROBE_TYPE")
|
|
.unwrap_or_else(|_| "ping".to_string());
|
|
|
|
let flow_for_loop = flow.clone();
|
|
let type_for_loop = type_name.clone();
|
|
cx.spawn(async move |this, cx| {
|
|
let timer = cx.background_executor().clone();
|
|
let bg = cx.background_executor().clone();
|
|
loop {
|
|
let card = build_consumer_card(
|
|
"brahman-broker-explorer",
|
|
flow_for_loop.clone(),
|
|
type_for_loop.clone(),
|
|
);
|
|
let started = Instant::now();
|
|
// El probe es bloqueante (interno tokio runtime); va
|
|
// al background executor para no congelar el main.
|
|
let probe_flow = flow_for_loop.clone();
|
|
let result = bg
|
|
.spawn(async move { await_provider_blocking(card, PROBE_TIMEOUT) })
|
|
.await;
|
|
let elapsed = started.elapsed().as_millis() as u64;
|
|
|
|
let new_state = match result {
|
|
Ok(socket) => ProbeState::UpWithProvider {
|
|
flow: probe_flow.clone(),
|
|
producer_socket: socket,
|
|
},
|
|
Err(ConsumerError::NoProvider { .. }) => ProbeState::UpNoProvider {
|
|
flow: probe_flow.clone(),
|
|
},
|
|
Err(e) => ProbeState::Down {
|
|
reason: e.to_string(),
|
|
},
|
|
};
|
|
|
|
// Si el broker está reachable (UP*), aprovechar el
|
|
// round-trip para pedir la lista de sesiones + matches.
|
|
// Si está DOWN, ni intentar — la lista serviría de nada
|
|
// con connect failed igual.
|
|
let (sessions_snapshot, matches_snapshot) = match &new_state {
|
|
ProbeState::Down { .. } | ProbeState::Pending => (None, None),
|
|
_ => {
|
|
let s = bg
|
|
.spawn(async move {
|
|
list_sessions_blocking("brahman-broker-explorer").ok()
|
|
})
|
|
.await;
|
|
let m = bg
|
|
.spawn(async move {
|
|
list_matches_blocking("brahman-broker-explorer").ok()
|
|
})
|
|
.await;
|
|
(s, m)
|
|
}
|
|
};
|
|
|
|
let _ = this.update(cx, |me, cx| {
|
|
me.state = new_state;
|
|
me.sessions = sessions_snapshot;
|
|
if let Some(matches) = matches_snapshot {
|
|
me.diff_matches_into_timeline(&matches);
|
|
}
|
|
me.last_probe_ms = elapsed;
|
|
me.last_probe_at = Some(Instant::now());
|
|
cx.notify();
|
|
});
|
|
|
|
timer.timer(POLL_INTERVAL).await;
|
|
}
|
|
})
|
|
.detach();
|
|
|
|
Self {
|
|
socket_path,
|
|
flow,
|
|
type_name,
|
|
state: ProbeState::Pending,
|
|
last_probe_ms: 0,
|
|
last_probe_at: None,
|
|
sessions: None,
|
|
last_match_keys: HashSet::new(),
|
|
timeline: std::collections::VecDeque::new(),
|
|
}
|
|
}
|
|
|
|
/// Diffea el snapshot recibido contra el último set de keys.
|
|
/// Genera entries `Available` para keys nuevas y `Lost` para
|
|
/// keys que estaban antes y no están ahora. Cada entry se
|
|
/// prepende al timeline; el cap se aplica desde la cola.
|
|
///
|
|
/// El primer tick del explorer (cuando `last_match_keys` está
|
|
/// vacío) hace que TODOS los matches actuales aparezcan como
|
|
/// `Available` — es el comportamiento querido (UI muestra el
|
|
/// estado al boot sin que parezca "no pasa nada").
|
|
fn diff_matches_into_timeline(
|
|
&mut self,
|
|
list: &brahman_handshake::messages::MatchList,
|
|
) {
|
|
let (new_entries, new_keys) = diff_matches(&self.last_match_keys, list);
|
|
for entry in new_entries {
|
|
self.push_timeline(entry);
|
|
}
|
|
self.last_match_keys = new_keys;
|
|
}
|
|
|
|
fn push_timeline(&mut self, entry: TimelineEntry) {
|
|
self.timeline.push_front(entry);
|
|
while self.timeline.len() > TIMELINE_CAP {
|
|
self.timeline.pop_back();
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Render for Explorer {
|
|
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
let theme = Theme::global(cx).clone();
|
|
let bg = theme.bg_app.clone();
|
|
let text = theme.fg_text;
|
|
let text_dim = theme.fg_muted;
|
|
// Acentos por estado: verde = arriba, amber = arriba sin
|
|
// provider, rojo = abajo, gris = pending.
|
|
let accent_up = gpui::rgb(0xa3be8c);
|
|
let accent_partial = gpui::rgb(0xebcb8b);
|
|
let accent_down = gpui::rgb(0xbf616a);
|
|
let accent_pending = gpui::rgb(0x6a7280);
|
|
|
|
let header_text = format!(
|
|
"Probe: {} · flow: {}/{} · reload {} ms",
|
|
self.socket_path.display(),
|
|
self.flow,
|
|
self.type_name,
|
|
self.last_probe_ms,
|
|
);
|
|
|
|
// Header standard via widget compartido.
|
|
let header = app_header(cx, header_text);
|
|
|
|
// Banner permanente debajo del header con el estado actual.
|
|
// Severidad acorde al kind.
|
|
let status_banner = match &self.state {
|
|
ProbeState::Pending => None,
|
|
ProbeState::Down { reason } => Some(banner_themed(
|
|
cx,
|
|
Banner::Error,
|
|
SharedString::from(format!("Broker DOWN — {reason}")),
|
|
)),
|
|
ProbeState::UpNoProvider { .. } => Some(banner_themed(
|
|
cx,
|
|
Banner::Warning,
|
|
SharedString::from("Broker UP, sin provider para el flow"),
|
|
)),
|
|
ProbeState::UpWithProvider { .. } => Some(banner_themed(
|
|
cx,
|
|
Banner::Success,
|
|
SharedString::from("Broker UP, provider matcheado"),
|
|
)),
|
|
};
|
|
|
|
let sessions_items: Vec<String> = self
|
|
.sessions
|
|
.as_ref()
|
|
.map(|list| {
|
|
let mut entries: Vec<_> = list.entries.iter().collect();
|
|
// Orden estable por session id (Ulid es ordenable
|
|
// temporal); útil para que la UI no se reordene
|
|
// entre ticks aunque el HashMap del server sí.
|
|
entries.sort_by_key(|e| e.session);
|
|
entries
|
|
.iter()
|
|
.map(|e| {
|
|
format!(
|
|
"{} · in:[{}] out:[{}]{}",
|
|
e.label,
|
|
e.inputs.join(","),
|
|
e.outputs.join(","),
|
|
if e.conscious { " (wit)" } else { "" }
|
|
)
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let sessions_count_value = self
|
|
.sessions
|
|
.as_ref()
|
|
.map(|l| l.entries.len().to_string())
|
|
.unwrap_or_else(|| "—".into());
|
|
let sessions_descr = match &self.sessions {
|
|
None => "lista no disponible (broker DOWN o pendiente)".to_string(),
|
|
Some(l) if l.entries.is_empty() => "sin sesiones registradas en el broker".into(),
|
|
Some(_) => "labels visibles + flows in/out · (wit) = consciente".into(),
|
|
};
|
|
|
|
let timeline_items: Vec<String> = self
|
|
.timeline
|
|
.iter()
|
|
.take(20)
|
|
.map(|e| format_timeline_entry(e))
|
|
.collect();
|
|
let timeline_value = self.timeline.len().to_string();
|
|
let timeline_descr = if self.timeline.is_empty() {
|
|
"esperando primer match…".to_string()
|
|
} else {
|
|
"↑ más reciente · ↓ más viejo · cap 50 entries".to_string()
|
|
};
|
|
|
|
let body = div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap(px(8.))
|
|
.px(px(16.))
|
|
.py(px(16.))
|
|
.child(state_card(cx, &self.state, text, text_dim, accent_up,
|
|
accent_partial, accent_down, accent_pending))
|
|
.child(stat_card(
|
|
cx,
|
|
"Sesiones activas",
|
|
sessions_count_value,
|
|
&sessions_descr,
|
|
accent_up,
|
|
text,
|
|
text_dim,
|
|
&sessions_items,
|
|
))
|
|
.child(stat_card(
|
|
cx,
|
|
"Timeline de matches",
|
|
timeline_value,
|
|
&timeline_descr,
|
|
accent_partial,
|
|
text,
|
|
text_dim,
|
|
&timeline_items,
|
|
));
|
|
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.size_full()
|
|
.bg(bg)
|
|
.child(header)
|
|
.when_some(status_banner, |d, b| d.child(b))
|
|
.child(body)
|
|
}
|
|
}
|
|
|
|
/// Wrap del `stat_card` compartido con el mapeo de
|
|
/// `ProbeState` → (label/accent/value/description). Mantenemos
|
|
/// este helper local porque la traducción del enum a strings es
|
|
/// específica del explorer (no es un patrón cross-app).
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn state_card(
|
|
cx: &mut Context<Explorer>,
|
|
state: &ProbeState,
|
|
text: gpui::Hsla,
|
|
text_dim: gpui::Hsla,
|
|
accent_up: gpui::Rgba,
|
|
accent_partial: gpui::Rgba,
|
|
accent_down: gpui::Rgba,
|
|
accent_pending: gpui::Rgba,
|
|
) -> impl IntoElement {
|
|
let (accent, value, description): (gpui::Rgba, String, String) = match state {
|
|
ProbeState::Pending => (
|
|
accent_pending,
|
|
"PENDING".into(),
|
|
"esperando primer probe…".into(),
|
|
),
|
|
ProbeState::Down { reason } => (
|
|
accent_down,
|
|
"DOWN".into(),
|
|
format!("connect failed: {reason}"),
|
|
),
|
|
ProbeState::UpNoProvider { flow } => (
|
|
accent_partial,
|
|
"UP / NO PROVIDER".into(),
|
|
format!("broker reachable; sin productor para flow `{flow}`"),
|
|
),
|
|
ProbeState::UpWithProvider {
|
|
flow,
|
|
producer_socket,
|
|
} => (
|
|
accent_up,
|
|
"UP / PROVIDER".into(),
|
|
format!(
|
|
"flow `{flow}` matcheado en producer socket: {}",
|
|
producer_socket.display()
|
|
),
|
|
),
|
|
};
|
|
|
|
stat_card(cx, "Estado", value, &description, accent, text, text_dim, &[])
|
|
}
|
|
|
|
/// Diff puro entre snapshots de matches. Devuelve la lista de
|
|
/// entries nuevas (Available + Lost) en orden Available-primero, y
|
|
/// el set actualizado de keys. Extraído como free fn para que sea
|
|
/// testeable sin instanciar `Explorer`.
|
|
///
|
|
/// El primer tick (last_keys vacío) marca todos los matches como
|
|
/// Available. Esto es deliberado: la UI muestra el estado al boot
|
|
/// como "todo recién apareció" en vez de quedarse vacía.
|
|
fn diff_matches(
|
|
last_keys: &HashSet<MatchKey>,
|
|
list: &brahman_handshake::messages::MatchList,
|
|
) -> (Vec<TimelineEntry>, HashSet<MatchKey>) {
|
|
use brahman_handshake::messages::MatchEventKind;
|
|
let now = std::time::SystemTime::now();
|
|
let current_keys: HashSet<MatchKey> = list
|
|
.matches
|
|
.iter()
|
|
.map(|m| {
|
|
(
|
|
m.consumer.session,
|
|
m.consumer.flow_name.clone(),
|
|
m.producer.session,
|
|
m.producer.flow_name.clone(),
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
let mut entries = Vec::new();
|
|
for m in &list.matches {
|
|
let key = (
|
|
m.consumer.session,
|
|
m.consumer.flow_name.clone(),
|
|
m.producer.session,
|
|
m.producer.flow_name.clone(),
|
|
);
|
|
if !last_keys.contains(&key) {
|
|
entries.push(TimelineEntry {
|
|
at: now,
|
|
kind: MatchEventKind::Available,
|
|
consumer_label: m.consumer_label.clone(),
|
|
consumer_flow: m.consumer.flow_name.clone(),
|
|
producer_label: m.producer_label.clone(),
|
|
producer_flow: m.producer.flow_name.clone(),
|
|
via: m.via,
|
|
pinned: m.pinned,
|
|
});
|
|
}
|
|
}
|
|
for key in last_keys.iter() {
|
|
if !current_keys.contains(key) {
|
|
entries.push(TimelineEntry {
|
|
at: now,
|
|
kind: MatchEventKind::Lost,
|
|
consumer_label: String::new(),
|
|
consumer_flow: key.1.clone(),
|
|
producer_label: String::new(),
|
|
producer_flow: key.3.clone(),
|
|
via: brahman_broker::MatchStrategy::Exact,
|
|
pinned: false,
|
|
});
|
|
}
|
|
}
|
|
(entries, current_keys)
|
|
}
|
|
|
|
/// Renderiza una entry del timeline en una sola línea: `HH:MM:SS
|
|
/// {kind} consumer.flow ← producer.flow [via]`. Compact por diseño
|
|
/// — el panel es vertical y las líneas largas se cortan.
|
|
fn format_timeline_entry(e: &TimelineEntry) -> String {
|
|
use brahman_handshake::messages::MatchEventKind;
|
|
// Wall-clock local en HH:MM:SS — sin zoneinfo (chrono es heavy).
|
|
// Aproximación: total_seconds % 86400 → hora del día UTC.
|
|
let secs_today = e
|
|
.at
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs() % 86_400)
|
|
.unwrap_or(0);
|
|
let h = secs_today / 3600;
|
|
let m = (secs_today % 3600) / 60;
|
|
let s = secs_today % 60;
|
|
let kind = match e.kind {
|
|
MatchEventKind::Available => "+",
|
|
MatchEventKind::Lost => "-",
|
|
};
|
|
let pinned = if e.pinned { " (pinned)" } else { "" };
|
|
match e.kind {
|
|
MatchEventKind::Available => format!(
|
|
"{:02}:{:02}:{:02} {} {}.{} ← {}.{} [{:?}]{}",
|
|
h, m, s, kind,
|
|
e.consumer_label, e.consumer_flow,
|
|
e.producer_label, e.producer_flow,
|
|
e.via, pinned,
|
|
),
|
|
MatchEventKind::Lost => format!(
|
|
"{:02}:{:02}:{:02} {} ?.{} ← ?.{} (lost)",
|
|
h, m, s, kind, e.consumer_flow, e.producer_flow,
|
|
),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn pending_is_default_state_at_boot() {
|
|
// El estado inicial DEBE ser Pending — sino el banner del
|
|
// header arrancaría con un Error o Warning sin haber probado
|
|
// (mensaje engañoso).
|
|
let s = ProbeState::Pending;
|
|
assert!(matches!(s, ProbeState::Pending));
|
|
}
|
|
|
|
#[test]
|
|
fn poll_and_probe_constants_are_sane() {
|
|
// Sanity: el timeout del probe DEBE ser menor que el
|
|
// intervalo de polling, sino los probes se solapan.
|
|
assert!(PROBE_TIMEOUT < POLL_INTERVAL);
|
|
// El intervalo no debería ser tan corto que sature al broker.
|
|
assert!(POLL_INTERVAL >= Duration::from_secs(2));
|
|
}
|
|
|
|
fn synth_match(
|
|
consumer_label: &str,
|
|
consumer_flow: &str,
|
|
producer_label: &str,
|
|
producer_flow: &str,
|
|
) -> brahman_broker::Match {
|
|
use brahman_broker::{Endpoint, Match, MatchStrategy};
|
|
use brahman_card::TypeRef;
|
|
Match {
|
|
consumer: Endpoint {
|
|
session: Ulid::new(),
|
|
flow_name: consumer_flow.into(),
|
|
},
|
|
consumer_label: consumer_label.into(),
|
|
producer: Endpoint {
|
|
session: Ulid::new(),
|
|
flow_name: producer_flow.into(),
|
|
},
|
|
producer_label: producer_label.into(),
|
|
ty: TypeRef::Primitive { name: "json".into() },
|
|
via: MatchStrategy::Exact,
|
|
pinned: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn diff_matches_first_snapshot_marks_everything_available() {
|
|
use brahman_handshake::messages::{MatchEventKind, MatchList};
|
|
let list = MatchList {
|
|
matches: vec![
|
|
synth_match("a", "x", "b", "x"),
|
|
synth_match("c", "y", "d", "y"),
|
|
],
|
|
};
|
|
let last = HashSet::new();
|
|
let (entries, keys) = diff_matches(&last, &list);
|
|
assert_eq!(entries.len(), 2);
|
|
assert!(entries
|
|
.iter()
|
|
.all(|e| matches!(e.kind, MatchEventKind::Available)));
|
|
assert_eq!(keys.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn diff_matches_emits_lost_when_match_disappears() {
|
|
use brahman_handshake::messages::{MatchEventKind, MatchList};
|
|
let m = synth_match("a", "x", "b", "x");
|
|
let prev_key = (
|
|
m.consumer.session,
|
|
m.consumer.flow_name.clone(),
|
|
m.producer.session,
|
|
m.producer.flow_name.clone(),
|
|
);
|
|
let last: HashSet<_> = std::iter::once(prev_key.clone()).collect();
|
|
let list = MatchList { matches: vec![] };
|
|
let (entries, keys) = diff_matches(&last, &list);
|
|
assert_eq!(entries.len(), 1);
|
|
assert!(matches!(entries[0].kind, MatchEventKind::Lost));
|
|
assert_eq!(entries[0].consumer_flow, "x");
|
|
assert_eq!(entries[0].producer_flow, "x");
|
|
assert!(keys.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn diff_matches_no_change_emits_nothing() {
|
|
use brahman_handshake::messages::MatchList;
|
|
let m = synth_match("a", "x", "b", "x");
|
|
let key = (
|
|
m.consumer.session,
|
|
m.consumer.flow_name.clone(),
|
|
m.producer.session,
|
|
m.producer.flow_name.clone(),
|
|
);
|
|
let last: HashSet<_> = std::iter::once(key.clone()).collect();
|
|
let list = MatchList {
|
|
matches: vec![m.clone()],
|
|
};
|
|
let (entries, keys) = diff_matches(&last, &list);
|
|
assert!(entries.is_empty(), "match unchanged → no events");
|
|
assert_eq!(keys.len(), 1);
|
|
assert!(keys.contains(&key));
|
|
}
|
|
}
|