First crate of the Pet UI v1 line (feat/pet-ui-v1 branch). Ships: ## Schema (src/schema.rs — 213 LOC) Strongly-typed `PetManifest` covering: - identity (pet_name, user_name, addressing, languages) - voice (tone primary/secondary, humor style + frequency) - edge (profanity, directness, initiative) - appearance (base_shape, size, colours, eyes, expression, accessories) - room (theme, lighting, decor, time_sync) - privacy (public_profile, publish_allowed, share_dreams, share_garden) - interests[] (topic, depth, freshness, vault_path, last_refresh) - routines[] (kind, schedule, template, enabled) - forbidden (topics, tone_patterns) - meta (schema version, timestamps, tune_count) All enums serde-renamed to kebab-case for TOML-native feel. ## Validation (src/validate.rs — 180 LOC) 19-rule validator (R1–R19 per earlier spec). Errors **accumulate** — single validate() call surfaces every issue, not just the first. Covers: - schema version (R1) - name bounds (R2, R2) - languages ISO-639-1 (R4) - tone_secondary cardinality + no-primary-dup (R6) - profanity/language consistency (R10) - interest topic slug-safety (R12) - interest/forbidden contradiction (R14) - schedule grammar: HH:MM, dow-HH:MM, every-Nh, no-commit-for-Nh, N-errors-in-N-calls (R16) - empty-string guards (R18) - ISO-8601 timestamps (R19) - hex-colour sanity on appearance ## Overlay rendering (src/overlay.rs — 128 LOC) Pure function `system_prompt(&PetManifest) -> String`. Deterministic — same manifest → same bytes. Used as prompt-prefix by the runtime at spawn time. ## Identity (src/identity.rs — 117 LOC incl. 5 unit tests) Standard Ed25519 (RFC 8032) via ed25519-dalek. `user_id` = first 16 hex chars of blake3(public_key) — deterministic, 64-bit, URL-safe. Hex-string API for cross-boundary verify. No proprietary crypto, no matrix math. ## CLI (src/bin/kei-pet.rs — 110 LOC) - `kei-pet validate <path>` — parse + run R1–R19 - `kei-pet show <path>` — print rendered overlay - `kei-pet identity new` — generate + store ~/.keisei/identity.key (0600) - `kei-pet identity show` — print public key + user_id - `kei-pet tune` stub (Day 2 — /pet-tune skill lands full implementation) ## Tests - 23/23 integration (tests/validation_tests.rs) — one rejector per rule + accept cases for examples/minimal.toml and examples/full.toml + overlay smoke + multi-error accumulation guard - 5/5 unit (identity module) — keypair roundtrip, user_id determinism, sign/verify, hex API boundary - cargo test -p kei-pet --release: all green ## Examples - examples/minimal.toml — smallest valid manifest - examples/full.toml — every optional section populated ## Scope boundary (enforced by in-file doc comment in lib.rs) NO imports, references, or conceptual mentions of sibling research-grade IP. Identity is standard Ed25519. Cache/projection is standard CQRS. This crate ships as a clean MIT-licensable unit of the KeiSeiKit public surface. Day 2: /pet-setup 7-phase wizard skill that drives this crate via the CLI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
271 lines
9.5 KiB
Rust
271 lines
9.5 KiB
Rust
//! Integration tests for the R1–R19 validator.
|
||
//!
|
||
//! Each `reject_*` test asserts a specific rule fires with a specific variant.
|
||
//! Accepting cases parse `examples/minimal.toml` and `examples/full.toml`
|
||
//! unmodified.
|
||
|
||
use kei_pet::{parse, validate};
|
||
use kei_pet::schema::*;
|
||
use kei_pet::validate::ValidationError;
|
||
|
||
const MINIMAL: &str = include_str!("../examples/minimal.toml");
|
||
const FULL: &str = include_str!("../examples/full.toml");
|
||
|
||
fn base() -> PetManifest {
|
||
parse(MINIMAL).expect("minimal.toml must validate")
|
||
}
|
||
|
||
#[test]
|
||
fn accept_minimal_example() {
|
||
let m = parse(MINIMAL).unwrap();
|
||
assert_eq!(m.schema, 1);
|
||
assert_eq!(m.identity.pet_name, "Kei");
|
||
}
|
||
|
||
#[test]
|
||
fn accept_full_example() {
|
||
let m = parse(FULL).unwrap();
|
||
assert_eq!(m.interests.len(), 2);
|
||
assert_eq!(m.routines.len(), 4);
|
||
assert!(m.appearance.is_some());
|
||
assert!(m.room.is_some());
|
||
assert!(m.privacy.is_some());
|
||
}
|
||
|
||
// ─────────────────────────── R1 schema version ───────────────────────────
|
||
|
||
#[test]
|
||
fn r1_wrong_schema() {
|
||
let mut m = base();
|
||
m.schema = 99;
|
||
let errs = validate(&m).unwrap_err();
|
||
assert!(errs.iter().any(|e| matches!(e, ValidationError::SchemaVersion(99))));
|
||
}
|
||
|
||
// ──────────────────────────── R2 name bounds ─────────────────────────────
|
||
|
||
#[test]
|
||
fn r2_empty_pet_name() {
|
||
let mut m = base();
|
||
m.identity.pet_name.clear();
|
||
assert!(validate(&m).unwrap_err().contains(&ValidationError::PetNameInvalid));
|
||
}
|
||
|
||
#[test]
|
||
fn r2_pet_name_too_long() {
|
||
let mut m = base();
|
||
m.identity.pet_name = "a".repeat(25);
|
||
assert!(validate(&m).unwrap_err().contains(&ValidationError::PetNameInvalid));
|
||
}
|
||
|
||
#[test]
|
||
fn r2_empty_user_name() {
|
||
let mut m = base();
|
||
m.identity.user_name.clear();
|
||
assert!(validate(&m).unwrap_err().contains(&ValidationError::UserNameInvalid));
|
||
}
|
||
|
||
// ───────────────────────────── R4 languages ──────────────────────────────
|
||
|
||
#[test]
|
||
fn r4_empty_languages() {
|
||
let mut m = base();
|
||
m.identity.languages.clear();
|
||
assert!(validate(&m).unwrap_err().contains(&ValidationError::LanguagesEmpty));
|
||
}
|
||
|
||
#[test]
|
||
fn r4_non_iso_language() {
|
||
let mut m = base();
|
||
m.identity.languages = vec!["english".to_string()];
|
||
let errs = validate(&m).unwrap_err();
|
||
assert!(errs.iter().any(|e| matches!(e, ValidationError::LanguageNotIso(0, s) if s == "english")));
|
||
}
|
||
|
||
// ─────────────────────────────── R6 tones ────────────────────────────────
|
||
|
||
#[test]
|
||
fn r6_too_many_secondary_tones() {
|
||
let mut m = base();
|
||
m.voice.tone_secondary = vec![Tone::Warm, Tone::Sarcastic, Tone::Supportive];
|
||
let errs = validate(&m).unwrap_err();
|
||
assert!(errs.iter().any(|e| matches!(e, ValidationError::ToneSecondaryTooMany(3))));
|
||
}
|
||
|
||
#[test]
|
||
fn r6_primary_duplicated_in_secondary() {
|
||
let mut m = base();
|
||
m.voice.tone_primary = Tone::Warm;
|
||
m.voice.tone_secondary = vec![Tone::Warm];
|
||
let errs = validate(&m).unwrap_err();
|
||
assert!(errs.iter().any(|e| matches!(e, ValidationError::ToneSecondaryDuplicatePrimary(Tone::Warm))));
|
||
}
|
||
|
||
// ───────────────────────── R10 profanity/languages ───────────────────────
|
||
|
||
#[test]
|
||
fn r10_never_with_language_list_populated() {
|
||
let mut m = base();
|
||
m.edge.profanity = Profanity::Never;
|
||
m.edge.profanity_languages = vec!["en".to_string()];
|
||
assert!(validate(&m).unwrap_err().contains(&ValidationError::ProfanityLanguagesWhenNever));
|
||
}
|
||
|
||
#[test]
|
||
fn r10_profanity_language_not_in_identity() {
|
||
let mut m = base();
|
||
m.edge.profanity = Profanity::MirrorUser;
|
||
m.edge.profanity_languages = vec!["de".to_string()];
|
||
let errs = validate(&m).unwrap_err();
|
||
assert!(errs.iter().any(|e| matches!(e, ValidationError::ProfanityLanguageNotDeclared(s) if s == "de")));
|
||
}
|
||
|
||
// ──────────────────────────── R12 slug-safe ──────────────────────────────
|
||
|
||
#[test]
|
||
fn r12_non_slug_topic() {
|
||
let mut m = base();
|
||
m.interests = vec![Interest {
|
||
topic: "Distributed Systems".into(),
|
||
depth: Depth::Expert,
|
||
freshness: Freshness::Weekly,
|
||
vault_path: String::new(),
|
||
last_refresh: String::new(),
|
||
}];
|
||
let errs = validate(&m).unwrap_err();
|
||
assert!(errs.iter().any(|e| matches!(e, ValidationError::InterestTopicNotSlug(0, _))));
|
||
}
|
||
|
||
#[test]
|
||
fn r12_leading_dash() {
|
||
let mut m = base();
|
||
m.interests = vec![Interest {
|
||
topic: "-bad".into(),
|
||
depth: Depth::Expert,
|
||
freshness: Freshness::Weekly,
|
||
vault_path: String::new(),
|
||
last_refresh: String::new(),
|
||
}];
|
||
assert!(validate(&m).is_err());
|
||
}
|
||
|
||
// ──────────────────────── R14 interest/forbidden overlap ─────────────────
|
||
|
||
#[test]
|
||
fn r14_interest_in_forbidden() {
|
||
let mut m = base();
|
||
m.interests = vec![Interest {
|
||
topic: "ai-hype".into(),
|
||
depth: Depth::Shallow,
|
||
freshness: Freshness::OnDemand,
|
||
vault_path: String::new(),
|
||
last_refresh: String::new(),
|
||
}];
|
||
m.forbidden.topics = vec!["ai-hype".into()];
|
||
let errs = validate(&m).unwrap_err();
|
||
assert!(errs.iter().any(|e| matches!(e, ValidationError::InterestForbiddenContradiction(0, _))));
|
||
}
|
||
|
||
// ────────────────────────────── R16 schedules ────────────────────────────
|
||
|
||
#[test]
|
||
fn r16_valid_schedules_accepted() {
|
||
let mut m = base();
|
||
for sched in &[
|
||
"09:00", "23:59", "00:00",
|
||
"sun-10:00", "mon-08:30",
|
||
"every-4h", "no-commit-for-3h",
|
||
"3-errors-in-20-calls",
|
||
] {
|
||
m.routines = vec![Routine {
|
||
kind: RoutineKind::Custom,
|
||
schedule: (*sched).to_string(),
|
||
template: "pet-routine-morning".to_string(),
|
||
enabled: true,
|
||
}];
|
||
validate(&m).unwrap_or_else(|e| panic!("schedule '{sched}' rejected: {e:?}"));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn r16_invalid_schedule() {
|
||
let mut m = base();
|
||
m.routines = vec![Routine {
|
||
kind: RoutineKind::Custom,
|
||
schedule: "whenever".into(),
|
||
template: "pet-routine-morning".into(),
|
||
enabled: true,
|
||
}];
|
||
let errs = validate(&m).unwrap_err();
|
||
assert!(errs.iter().any(|e| matches!(e, ValidationError::RoutineScheduleInvalid(0, s) if s == "whenever")));
|
||
}
|
||
|
||
#[test]
|
||
fn r16_invalid_hour() {
|
||
let mut m = base();
|
||
m.routines = vec![Routine {
|
||
kind: RoutineKind::Custom,
|
||
schedule: "25:00".into(),
|
||
template: "x".into(),
|
||
enabled: true,
|
||
}];
|
||
assert!(validate(&m).is_err());
|
||
}
|
||
|
||
// ────────────────────────────── R18 empty strings ────────────────────────
|
||
|
||
#[test]
|
||
fn r18_empty_forbidden_topic() {
|
||
let mut m = base();
|
||
m.forbidden.topics = vec![" ".into()];
|
||
let errs = validate(&m).unwrap_err();
|
||
assert!(errs.iter().any(|e| matches!(e, ValidationError::ForbiddenTopicEmpty(0))));
|
||
}
|
||
|
||
// ──────────────────────────────── R19 ISO-8601 ───────────────────────────
|
||
|
||
#[test]
|
||
fn r19_bad_timestamp() {
|
||
let mut m = base();
|
||
m.meta.created_at = "yesterday".into();
|
||
let errs = validate(&m).unwrap_err();
|
||
assert!(errs.iter().any(|e| matches!(e, ValidationError::MetaTimestampInvalid("created_at", _))));
|
||
}
|
||
|
||
// ─────────────────────────────── hex colours ─────────────────────────────
|
||
|
||
#[test]
|
||
fn hex_color_invalid() {
|
||
let mut m = parse(FULL).unwrap();
|
||
if let Some(ref mut app) = m.appearance {
|
||
app.color_primary = "brown".into();
|
||
}
|
||
let errs = validate(&m).unwrap_err();
|
||
assert!(errs.iter().any(|e| matches!(e, ValidationError::HexColorInvalid(s) if s == "brown")));
|
||
}
|
||
|
||
// ─────────────────────── multiple errors accumulate ──────────────────────
|
||
|
||
#[test]
|
||
fn errors_accumulate_not_fail_fast() {
|
||
let mut m = base();
|
||
m.schema = 99; // R1
|
||
m.identity.pet_name.clear(); // R2
|
||
m.identity.languages.clear(); // R4
|
||
let errs = validate(&m).unwrap_err();
|
||
// Ensure we got ≥3 distinct errors, proving we accumulated rather than
|
||
// short-circuited on the first.
|
||
assert!(errs.len() >= 3, "expected ≥3 accumulated errors, got {}: {errs:?}", errs.len());
|
||
}
|
||
|
||
// ─────────────────────────────── overlay smoke ───────────────────────────
|
||
|
||
#[test]
|
||
fn overlay_contains_names() {
|
||
let m = parse(FULL).unwrap();
|
||
let overlay = kei_pet::system_prompt(&m);
|
||
assert!(overlay.contains("Kei"));
|
||
assert!(overlay.contains("Denis"));
|
||
assert!(overlay.contains("distributed-systems"));
|
||
assert!(overlay.contains("politics"));
|
||
}
|