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
103 lines
3.6 KiB
Rust
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"));
|
|
});
|
|
}
|