Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/analytics/contract_tests.rs
T
lhk229 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
客户端埋点设置 (#446)
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>
2026-09-23 00:09:12 +08:00

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
);
}