KeiSeiKit-1.0/_primitives/_rust/kei-buddy/src/machine_tests.rs
Parfii-bot 621ac8685f feat(kei-buddy): functional MVP — store + state-machine port + serve binary
Three atoms landed in one commit (memory binding, state machine port,
real serve binary). Tracked separately in TaskList (#5 #7 #6).

After this commit `kei-buddy` is functional end-to-end:
  ./kei-buddy migrate                   → creates SQLite schema
  ./kei-buddy webhook-set https://...   → registers Telegram webhook
  ./kei-buddy serve                     → axum HTTP listener on $KEI_BUDDY_PORT
  ./kei-buddy webhook-delete            → reverts to polling

20 tests pass across 5 modules. Binary builds clean (default + extractor-openai).

## Memory binding (task #5)

New files:
  * src/schema.rs (56)        — buddy_state table DDL, idempotent
  * src/store.rs (164)        — BuddyStore trait + SqliteBuddyStore
  * src/store_ops.rs (107)    — pub(crate) sync SQL helpers behind spawn_blocking

API: load_state, save_state, load_persona, save_persona — all async,
take &self + chat_id, return Result<_, BuddyError>. From<rusqlite::Error>
and From<kei_memory_sqlite::Error> impls added to BuddyError.

## State-machine port (task #7)

New files:
  * src/transition.rs (replaced)  — StepOutput { next_state, response_text, persona_patch }
  * src/extractor.rs (198)        — LlmExtractor trait + MockExtractor + OpenAiExtractor (gated by extractor-openai feature)
  * src/machine.rs (250)          — handle_step async fn, 11-arm state machine
  * src/machine_helpers.rs (171)  — per-state helper fns
  * src/machine_tests.rs (103)    — 7 FSM tests with MockExtractor

Each TS branch from chat-onboard.ts (Intro / AskName / AskTone /
AskInterests / AskHobbies / TopicSpecifics / TopicNowLater /
TopicResearch / TopicSources / AskSchedule / Ready) ported to Rust.
Russian-language responses preserved verbatim. Topic queue stored in
persona_patch.__topic_state for caller round-tripping.

machine.rs is 250 LOC (over the standard 200 budget); 11-arm match
justifies the exception, documented in file header.

## Serve binary (task #6)

New files:
  * src/persona_merge.rs (85)     — JSON deep-merge helper
  * src/serve_telegram.rs (128)   — sendMessage / setWebhook / deleteWebhook HTTP helpers
  * src/serve.rs (162)            — axum Router, BuddyContext impl, run_serve
  * src/bin/kei-buddy.rs (rewritten, 120) — clap 4-subcommand CLI

Env: TELEGRAM_BOT_TOKEN, TELEGRAM_WEBHOOK_SECRET, KEI_BUDDY_PORT
(default 8080), KEI_BUDDY_DB_PATH (default ./kei-buddy.db), OPENAI_API_KEY
(optional — when set + extractor-openai feature, switches to real LLM).

axum + tracing-subscriber gated behind `serve` feature (default ON). Library
consumers without `serve` get a clean kei-buddy lib without HTTP server deps.

## Verify-before-commit

  * cargo check -p kei-buddy (default): PASS
  * cargo check -p kei-buddy --features extractor-openai: PASS
  * cargo check --workspace: PASS
  * cargo test -p kei-buddy --lib: 20 passed / 0 failed
  * cargo build -p kei-buddy --bin kei-buddy: PASS
  * Binary smoke: ./kei-buddy --help (4 subcommands), ./kei-buddy migrate
    creates buddy_state table verified via sqlite3 .tables

## Follow-up (deferred, non-blocking)

  * Wire OpenAiExtractor in run_serve when OPENAI_API_KEY set
    (currently always MockExtractor — smoke-only, no real LLM yet)
  * proposeTopicSources path needs real LLM call (MockExtractor returns empty)
  * Schedule timezone fallback map for "Москва"/"Bali" etc — currently
    fully delegated to LLM prompt
  * End-to-end Telegram integration test — requires real bot token
2026-05-12 14:21:33 +08:00

103 lines
3.6 KiB
Rust

// SPDX-License-Identifier: Apache-2.0
//! Tests for `machine::handle_step`.
//! Extracted from machine.rs to keep it within the 250-LOC exception budget.
use serde_json::json;
use crate::extractor::MockExtractor;
use crate::machine::handle_step;
use crate::state::OnboardState;
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Runtime::new().unwrap()
}
#[test]
fn intro_to_ask_name() {
rt().block_on(async {
let mock = MockExtractor::new(json!({}));
let out = handle_step(&OnboardState::Intro, "hi", &json!({}), &mock)
.await
.unwrap();
assert_eq!(out.next_state, OnboardState::AskName);
assert!(!out.response_text.is_empty(), "intro response must not be empty");
});
}
#[test]
fn ask_name_extracts_and_advances() {
rt().block_on(async {
let mock = MockExtractor::new(json!({ "name": "Denis" }));
let out = handle_step(&OnboardState::AskName, "Denis", &json!({}), &mock)
.await
.unwrap();
assert_eq!(out.next_state, OnboardState::AskTone);
assert_eq!(out.persona_patch["name"].as_str(), Some("Denis"));
});
}
#[test]
fn ask_tone_extracts_and_advances() {
rt().block_on(async {
let mock = MockExtractor::new(json!({ "tone": "friendly" }));
let out = handle_step(&OnboardState::AskTone, "по-дружески", &json!({}), &mock)
.await
.unwrap();
assert_eq!(out.next_state, OnboardState::AskInterests);
assert_eq!(out.persona_patch["tone"].as_str(), Some("friendly"));
});
}
#[test]
fn ask_interests_seeds_topic_queue() {
rt().block_on(async {
let mock = MockExtractor::new(json!({ "items": ["ml", "cooking"] }));
let out = handle_step(&OnboardState::AskInterests, "ml и готовка", &json!({}), &mock)
.await
.unwrap();
assert_eq!(out.next_state, OnboardState::AskHobbies);
let interests = out.persona_patch["interests"].as_array().unwrap();
assert_eq!(interests.len(), 2);
assert_eq!(interests[0].as_str(), Some("ml"));
});
}
#[test]
fn ask_hobbies_seeds_topic_queue_from_interests_and_hobbies() {
rt().block_on(async {
let mock = MockExtractor::new(json!({ "items": ["surfing"] }));
let persona = json!({ "interests": ["ml", "cooking"] });
let out = handle_step(&OnboardState::AskHobbies, "серфинг", &persona, &mock)
.await
.unwrap();
// current_topic = "ml" (first), queue = ["cooking", "surfing"]
assert_eq!(out.next_state, OnboardState::TopicSpecifics);
let queue = out.persona_patch["__topic_state"]["queue"].as_array().unwrap();
assert_eq!(queue.len(), 2, "queue must have [cooking, surfing]");
assert_eq!(queue[0]["name"].as_str(), Some("cooking"));
});
}
#[test]
fn ready_is_idempotent() {
rt().block_on(async {
let mock = MockExtractor::new(json!({}));
let out = handle_step(&OnboardState::Ready, "hello", &json!({}), &mock)
.await
.unwrap();
assert_eq!(out.next_state, OnboardState::Ready);
assert!(out.response_text.is_empty());
assert_eq!(out.persona_patch, json!({}));
});
}
#[test]
fn ask_tone_falls_back_to_friendly_on_unknown() {
rt().block_on(async {
let mock = MockExtractor::new(json!({ "tone": "ultra_mega_vibe" }));
let out = handle_step(&OnboardState::AskTone, "что-то непонятное", &json!({}), &mock)
.await
.unwrap();
assert_eq!(out.persona_patch["tone"].as_str(), Some("friendly"));
});
}