1e186369c9
Project CI / AI game creator shell Rust crates (push) Successful in 1m24s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m56s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/446 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
278 lines
8.7 KiB
Rust
278 lines
8.7 KiB
Rust
use super::*;
|
|
use serde_json::json;
|
|
|
|
fn context() -> Context {
|
|
Context {
|
|
route: Route::from_identity(Some("123".into()), Some("https://example.com/api")),
|
|
editor_session_id: Uuid::new_v4().to_string(),
|
|
client_version: "0.1.67".into(),
|
|
}
|
|
}
|
|
|
|
fn run() -> RunFinished {
|
|
RunFinished {
|
|
agent_type: AgentType::GameAgent,
|
|
run_source: RunSource::UserSubmit,
|
|
duration_ms: None,
|
|
retry_index: 0,
|
|
output_change_detected: None,
|
|
revision_id: None,
|
|
end_reason: RunEndReason::Finished,
|
|
}
|
|
}
|
|
|
|
fn samples() -> Vec<Event> {
|
|
let mut failed = run();
|
|
failed.end_reason = RunEndReason::Failed;
|
|
let values = vec![
|
|
(
|
|
EventData::EditorSessionStart(SessionStart {
|
|
entry_source: EntrySource::DirectLaunch,
|
|
first_project_id: None,
|
|
}),
|
|
None,
|
|
Source::Editor,
|
|
),
|
|
(
|
|
EventData::EditorSessionEnd(SessionEnd {
|
|
end_reason: SessionEndReason::UserExit,
|
|
session_duration_ms: Some(10),
|
|
last_project_id: None,
|
|
}),
|
|
None,
|
|
Source::Editor,
|
|
),
|
|
(
|
|
EventData::EditorFocusStart(FocusStart {
|
|
focus_interval_id: Uuid::new_v4().to_string(),
|
|
focus_reason: FocusReason::InitialFocus,
|
|
active_project_id: None,
|
|
}),
|
|
None,
|
|
Source::Editor,
|
|
),
|
|
(
|
|
EventData::EditorFocusEnd(FocusEnd {
|
|
focus_interval_id: Uuid::new_v4().to_string(),
|
|
blur_reason: BlurReason::WindowBlur,
|
|
focus_duration_ms: None,
|
|
active_project_id: None,
|
|
}),
|
|
None,
|
|
Source::Editor,
|
|
),
|
|
(
|
|
EventData::ProjectCreateSuccess(ProjectCreated {
|
|
creation_source: CreationSource::HomeGame,
|
|
project_template_id: None,
|
|
}),
|
|
Some("project".into()),
|
|
Source::Editor,
|
|
),
|
|
(
|
|
EventData::ProjectOpen(ProjectOpened {
|
|
open_source: OpenSource::Recent,
|
|
is_first_open: None,
|
|
}),
|
|
Some("project".into()),
|
|
Source::Editor,
|
|
),
|
|
(
|
|
EventData::CreativeTaskSubmit(EmptyProperties {}),
|
|
Some("project".into()),
|
|
Source::Direct,
|
|
),
|
|
(
|
|
EventData::AgentRunCompleted(run()),
|
|
Some("project".into()),
|
|
Source::Direct,
|
|
),
|
|
(
|
|
EventData::AgentRunFailed(failed),
|
|
Some("project".into()),
|
|
Source::Direct,
|
|
),
|
|
(
|
|
EventData::ProjectRevisionCreated(RevisionCreated {
|
|
revision_id: "42".into(),
|
|
revision_source: RevisionSource::UiEditor,
|
|
change_kind: ChangeKind::Ui,
|
|
files_changed_count: Some(1),
|
|
}),
|
|
Some("project".into()),
|
|
Source::UiEditor,
|
|
),
|
|
(
|
|
EventData::PreviewReady(PreviewReady {
|
|
preview_source: PreviewSource::User,
|
|
preview_version: "42".into(),
|
|
ready_duration_ms: None,
|
|
}),
|
|
Some("project".into()),
|
|
Source::Editor,
|
|
),
|
|
(
|
|
EventData::ProjectSave(ProjectSaved {
|
|
save_source: SaveSource::Checkpoint,
|
|
revision_id: Some("42".into()),
|
|
}),
|
|
Some("project".into()),
|
|
Source::Manual,
|
|
),
|
|
];
|
|
values
|
|
.into_iter()
|
|
.map(|(data, project, source)| {
|
|
let identity = matches!(
|
|
data,
|
|
EventData::AgentRunCompleted(_) | EventData::AgentRunFailed(_)
|
|
)
|
|
.then(|| RunIdentity {
|
|
run_id: Uuid::new_v4().to_string(),
|
|
turn_id: Some("real-turn".into()),
|
|
error_code: matches!(data, EventData::AgentRunFailed(_))
|
|
.then_some(ErrorCode::RuntimeErrorUnclassified),
|
|
});
|
|
context().capture(data, project, source, identity).unwrap()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn all_events_round_trip_with_explicit_nullable_envelopes() {
|
|
let events = samples();
|
|
assert_eq!(events.len(), 12);
|
|
for event in events {
|
|
let value = serde_json::to_value(&event).unwrap();
|
|
assert_eq!(value.as_object().unwrap().len(), 15);
|
|
let decoded: Event = serde_json::from_value(value).unwrap();
|
|
decoded.validate().unwrap();
|
|
assert_eq!(decoded.event_id, event.event_id);
|
|
assert_eq!(decoded.event_time, event.event_time);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn missing_top_level_fields_and_extra_fields_are_rejected() {
|
|
for event in samples() {
|
|
let value = serde_json::to_value(event).unwrap();
|
|
for field in value.as_object().unwrap().keys() {
|
|
let mut missing = value.clone();
|
|
missing.as_object_mut().unwrap().remove(field);
|
|
assert!(
|
|
serde_json::from_value::<Event>(missing).is_err(),
|
|
"missing {field}"
|
|
);
|
|
}
|
|
let mut extra = value;
|
|
extra["prompt"] = json!("must not be accepted");
|
|
assert!(serde_json::from_value::<Event>(extra).is_err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn event_properties_are_closed_and_nullable_fields_are_required() {
|
|
for event in samples() {
|
|
let mut extra = event.clone();
|
|
extra.properties["access_token"] = json!("never collected");
|
|
assert!(extra.validate().is_err());
|
|
for (field, value) in event.properties.as_object().unwrap() {
|
|
if !value.is_null() {
|
|
continue;
|
|
}
|
|
let mut missing = event.clone();
|
|
missing.properties.as_object_mut().unwrap().remove(field);
|
|
assert!(
|
|
missing.validate().is_err(),
|
|
"{} missing {field}",
|
|
event.event_name
|
|
);
|
|
}
|
|
}
|
|
let mut nested = samples().pop().unwrap();
|
|
nested.properties["pending_approval"] =
|
|
json!({"request_id":"a", "phase":"concept", "question":"private"});
|
|
assert!(nested.validate().is_err());
|
|
nested.properties["pending_approval"] = Value::Null;
|
|
nested.properties["pending_clarification"] = json!({"request_id":"a", "options":[]});
|
|
assert!(nested.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn inconsistent_identity_status_and_numbers_are_rejected() {
|
|
let event = samples()
|
|
.into_iter()
|
|
.find(|e| e.event_name == "agent_run_completed")
|
|
.unwrap();
|
|
let mutate: Vec<(&str, Value)> = vec![
|
|
("creative_task_id", json!("other-project")),
|
|
("project_id", Value::Null),
|
|
("agent_run_id", json!("project")),
|
|
("status", json!("failed")),
|
|
("source", json!("design_agent")),
|
|
("error_code", json!("runtime_failed")),
|
|
("schema_version", json!(2)),
|
|
("event_time", json!("2026-09-21T00:00:00Z")),
|
|
("event_id", json!("not-a-uuid")),
|
|
("user_id", json!("")),
|
|
];
|
|
for (key, value) in mutate {
|
|
let mut raw = serde_json::to_value(&event).unwrap();
|
|
raw[key] = value;
|
|
let invalid: Event = serde_json::from_value(raw).unwrap();
|
|
assert!(invalid.validate().is_err(), "{key}");
|
|
}
|
|
for (key, value) in [
|
|
("duration_ms", json!(-1)),
|
|
("retry_index", json!(MAX_SAFE_INTEGER + 1)),
|
|
("duration_ms", json!(1.5)),
|
|
] {
|
|
let mut invalid = event.clone();
|
|
invalid.properties[key] = value;
|
|
assert!(invalid.validate().is_err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn frozen_context_and_origin_do_not_inherit_new_account() {
|
|
let a = context();
|
|
let mut b = a.clone();
|
|
b.route = Route::from_identity(
|
|
Some("456".into()),
|
|
Some("https://other.example.com/api?token=private"),
|
|
);
|
|
let event = a
|
|
.capture(
|
|
EventData::CreativeTaskSubmit(EmptyProperties {}),
|
|
Some("p".into()),
|
|
Source::Direct,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(event.user_id.as_deref(), Some("123"));
|
|
assert_eq!(
|
|
a.route.destination_origin.as_deref(),
|
|
Some("https://example.com")
|
|
);
|
|
assert_eq!(
|
|
b.route.destination_origin.as_deref(),
|
|
Some("https://other.example.com")
|
|
);
|
|
assert_eq!(
|
|
Route::from_identity(None, Some("file:///private")).destination_origin,
|
|
None
|
|
);
|
|
assert_eq!(
|
|
Route::from_identity(None, Some("https://user:secret@example.com")).destination_origin,
|
|
None
|
|
);
|
|
assert!(
|
|
Route {
|
|
user_id: None,
|
|
destination_origin: Some("https://example.com/api".into())
|
|
}
|
|
.validate()
|
|
== false
|
|
);
|
|
}
|