manasight_parser/parsers/draft/
complete.rs1use crate::events::{DraftCompleteEvent, EventMetadata, GameEvent};
16use crate::log::entry::LogEntry;
17use crate::parsers::api_common;
18
19const COMPLETE_DRAFT_MARKER: &str = "DraftCompleteDraft";
25
26pub fn try_parse(
35 entry: &LogEntry,
36 timestamp: Option<chrono::DateTime<chrono::Utc>>,
37) -> Option<GameEvent> {
38 let body = &entry.body;
39
40 if !body.contains(COMPLETE_DRAFT_MARKER) {
41 return None;
42 }
43
44 let parsed = api_common::parse_json_from_body(body, "DraftCompleteDraft")?;
45
46 let draft_id_from_body = extract_draft_id_from_body(body);
47 let payload = build_payload(&parsed, draft_id_from_body.as_deref());
48 let metadata = EventMetadata::new(timestamp, body.as_bytes().to_vec());
49 Some(GameEvent::DraftComplete(DraftCompleteEvent::new(
50 metadata, payload,
51 )))
52}
53
54fn build_payload(
64 parsed: &serde_json::Value,
65 draft_id_from_body: Option<&str>,
66) -> serde_json::Value {
67 let draft_id = parsed
68 .get("DraftId")
69 .or_else(|| parsed.get("draftId"))
70 .or_else(|| parsed.get("id"))
71 .and_then(serde_json::Value::as_str)
72 .or(draft_id_from_body)
73 .unwrap_or("");
74
75 let event_name = api_common::extract_event_name(parsed);
76
77 serde_json::json!({
78 "type": "draft_complete",
79 "draft_id": draft_id,
80 "event_name": event_name,
81 "raw_complete_draft": parsed.clone(),
82 })
83}
84
85fn extract_draft_id_from_body(body: &str) -> Option<String> {
90 let marker = "DraftCompleteDraft(";
91 let start = body.find(marker)? + marker.len();
92 let remaining = body.get(start..)?;
93 let end = remaining.find(')')?;
94 Some(remaining.get(..end)?.to_owned())
95}
96
97#[cfg(test)]
102#[allow(deprecated)]
103mod tests {
104 use super::*;
105 use crate::events::PerformanceClass;
106 use crate::parsers::test_helpers::{
107 draft_complete_payload, test_timestamp, unity_entry, EntryHeader,
108 };
109
110 mod request_format {
113 use super::*;
114
115 #[test]
116 fn test_try_parse_request_basic() {
117 let body = r#"[UnityCrossThreadLogger]==> DraftCompleteDraft {"id":"abc-123-def","request":"{\"EventName\":\"PremierDraft_MKM_20260201\",\"IsBotDraft\":false}"}"#;
118 let entry = unity_entry(body);
119 let result = try_parse(&entry, Some(test_timestamp()));
120
121 assert!(result.is_some());
122 let event = result.as_ref().unwrap_or_else(|| unreachable!());
123 let payload = draft_complete_payload(event);
124
125 assert_eq!(payload["type"], "draft_complete");
126 assert_eq!(payload["draft_id"], "abc-123-def");
127 assert_eq!(payload["event_name"], "PremierDraft_MKM_20260201");
128 }
129
130 #[test]
131 fn test_try_parse_request_traditional_draft() {
132 let body = r#"[UnityCrossThreadLogger]==> DraftCompleteDraft {"id":"trad-456","request":"{\"EventName\":\"TradDraft_DSK_20260115\",\"IsBotDraft\":false}"}"#;
133 let entry = unity_entry(body);
134 let result = try_parse(&entry, Some(test_timestamp()));
135
136 assert!(result.is_some());
137 let event = result.as_ref().unwrap_or_else(|| unreachable!());
138 let payload = draft_complete_payload(event);
139
140 assert_eq!(payload["draft_id"], "trad-456");
141 assert_eq!(payload["event_name"], "TradDraft_DSK_20260115");
142 }
143
144 #[test]
145 fn test_try_parse_request_empty_request_string() {
146 let body = r#"[UnityCrossThreadLogger]==> DraftCompleteDraft {"id":"empty-req","request":"{}"}"#;
147 let entry = unity_entry(body);
148 let result = try_parse(&entry, Some(test_timestamp()));
149
150 assert!(result.is_some());
151 let event = result.as_ref().unwrap_or_else(|| unreachable!());
152 let payload = draft_complete_payload(event);
153
154 assert_eq!(payload["draft_id"], "empty-req");
155 assert_eq!(payload["event_name"], "");
156 }
157 }
158
159 mod response_format {
162 use super::*;
163
164 #[test]
165 fn test_try_parse_response_basic() {
166 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
167 <== DraftCompleteDraft(abc-123-def)\n\
168 {\"CourseId\":\"course-456\",\
169 \"InternalEventName\":\"PremierDraft_MKM_20260201\",\
170 \"CurrentModule\":\"DeckSelect\",\
171 \"CardPool\":[98535,98381]}";
172 let entry = unity_entry(body);
173 let result = try_parse(&entry, Some(test_timestamp()));
174
175 assert!(result.is_some());
176 let event = result.as_ref().unwrap_or_else(|| unreachable!());
177 let payload = draft_complete_payload(event);
178
179 assert_eq!(payload["type"], "draft_complete");
180 assert_eq!(payload["draft_id"], "abc-123-def");
181 assert_eq!(payload["event_name"], "PremierDraft_MKM_20260201");
182 }
183
184 #[test]
185 fn test_try_parse_response_preserves_card_pool() {
186 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
187 <== DraftCompleteDraft(pool-test)\n\
188 {\"InternalEventName\":\"PremierDraft_ECL_20260120\",\
189 \"CardPool\":[98535,98381,98366]}";
190 let entry = unity_entry(body);
191 let result = try_parse(&entry, Some(test_timestamp()));
192
193 assert!(result.is_some());
194 let event = result.as_ref().unwrap_or_else(|| unreachable!());
195 let payload = draft_complete_payload(event);
196
197 let raw = &payload["raw_complete_draft"];
198 assert_eq!(raw["CardPool"][0], 98535);
199 assert_eq!(raw["CardPool"][2], 98366);
200 }
201
202 #[test]
203 fn test_try_parse_response_event_name_fallback() {
204 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
205 <== DraftCompleteDraft(fallback-test)\n\
206 {\"EventName\":\"QuickDraft_MKM_20260201\"}";
207 let entry = unity_entry(body);
208 let result = try_parse(&entry, Some(test_timestamp()));
209
210 assert!(result.is_some());
211 let event = result.as_ref().unwrap_or_else(|| unreachable!());
212 let payload = draft_complete_payload(event);
213
214 assert_eq!(payload["event_name"], "QuickDraft_MKM_20260201");
215 }
216 }
217
218 mod flat_format {
221 use super::*;
222
223 #[test]
224 fn test_try_parse_flat_basic() {
225 let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
226 {\n\
227 \"DraftId\": \"abc-123-def\",\n\
228 \"EventName\": \"PremierDraft_MKM_20260201\"\n\
229 }";
230 let entry = unity_entry(body);
231 let result = try_parse(&entry, Some(test_timestamp()));
232
233 assert!(result.is_some());
234 let event = result.as_ref().unwrap_or_else(|| unreachable!());
235 let payload = draft_complete_payload(event);
236
237 assert_eq!(payload["type"], "draft_complete");
238 assert_eq!(payload["draft_id"], "abc-123-def");
239 assert_eq!(payload["event_name"], "PremierDraft_MKM_20260201");
240 }
241
242 #[test]
243 fn test_try_parse_flat_traditional() {
244 let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
245 {\n\
246 \"DraftId\": \"trad-456\",\n\
247 \"EventName\": \"TradDraft_DSK_20260115\"\n\
248 }";
249 let entry = unity_entry(body);
250 let result = try_parse(&entry, Some(test_timestamp()));
251
252 assert!(result.is_some());
253 let event = result.as_ref().unwrap_or_else(|| unreachable!());
254 let payload = draft_complete_payload(event);
255
256 assert_eq!(payload["draft_id"], "trad-456");
257 assert_eq!(payload["event_name"], "TradDraft_DSK_20260115");
258 }
259
260 #[test]
261 fn test_try_parse_flat_quick_draft() {
262 let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
263 {\n\
264 \"DraftId\": \"quick-789\",\n\
265 \"EventName\": \"QuickDraft_MKM_20260201\"\n\
266 }";
267 let entry = unity_entry(body);
268 let result = try_parse(&entry, Some(test_timestamp()));
269
270 assert!(result.is_some());
271 let event = result.as_ref().unwrap_or_else(|| unreachable!());
272 let payload = draft_complete_payload(event);
273
274 assert_eq!(payload["draft_id"], "quick-789");
275 assert_eq!(payload["event_name"], "QuickDraft_MKM_20260201");
276 }
277
278 #[test]
279 fn test_try_parse_flat_lowercase_draft_id() {
280 let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
281 {\n\
282 \"draftId\": \"lowercase-123\",\n\
283 \"EventName\": \"PremierDraft_MKM_20260201\"\n\
284 }";
285 let entry = unity_entry(body);
286 let result = try_parse(&entry, Some(test_timestamp()));
287
288 assert!(result.is_some());
289 let event = result.as_ref().unwrap_or_else(|| unreachable!());
290 let payload = draft_complete_payload(event);
291
292 assert_eq!(payload["draft_id"], "lowercase-123");
293 }
294
295 #[test]
296 fn test_try_parse_flat_internal_event_name() {
297 let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
298 {\n\
299 \"DraftId\": \"intern-456\",\n\
300 \"InternalEventName\": \"PremierDraft_MKM_20260201\"\n\
301 }";
302 let entry = unity_entry(body);
303 let result = try_parse(&entry, Some(test_timestamp()));
304
305 assert!(result.is_some());
306 let event = result.as_ref().unwrap_or_else(|| unreachable!());
307 let payload = draft_complete_payload(event);
308
309 assert_eq!(payload["event_name"], "PremierDraft_MKM_20260201");
310 }
311 }
312
313 mod missing_fields {
316 use super::*;
317
318 #[test]
319 fn test_try_parse_missing_draft_id() {
320 let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
321 {\n\
322 \"EventName\": \"PremierDraft_MKM_20260201\"\n\
323 }";
324 let entry = unity_entry(body);
325 let result = try_parse(&entry, Some(test_timestamp()));
326
327 assert!(result.is_some());
328 let event = result.as_ref().unwrap_or_else(|| unreachable!());
329 let payload = draft_complete_payload(event);
330
331 assert_eq!(payload["draft_id"], "");
332 assert_eq!(payload["event_name"], "PremierDraft_MKM_20260201");
333 }
334
335 #[test]
336 fn test_try_parse_missing_event_name() {
337 let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
338 {\n\
339 \"DraftId\": \"no-event-name\"\n\
340 }";
341 let entry = unity_entry(body);
342 let result = try_parse(&entry, Some(test_timestamp()));
343
344 assert!(result.is_some());
345 let event = result.as_ref().unwrap_or_else(|| unreachable!());
346 let payload = draft_complete_payload(event);
347
348 assert_eq!(payload["draft_id"], "no-event-name");
349 assert_eq!(payload["event_name"], "");
350 }
351
352 #[test]
353 fn test_try_parse_minimal_payload() {
354 let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
355 {}";
356 let entry = unity_entry(body);
357 let result = try_parse(&entry, Some(test_timestamp()));
358
359 assert!(result.is_some());
360 let event = result.as_ref().unwrap_or_else(|| unreachable!());
361 let payload = draft_complete_payload(event);
362
363 assert_eq!(payload["draft_id"], "");
364 assert_eq!(payload["event_name"], "");
365 }
366 }
367
368 mod metadata {
371 use super::*;
372
373 #[test]
374 fn test_try_parse_preserves_raw_bytes() {
375 let body = r#"[UnityCrossThreadLogger]==> DraftCompleteDraft {"id":"raw-test","request":"{\"EventName\":\"PremierDraft_MKM_20260201\"}"}"#;
376 let entry = unity_entry(body);
377 let result = try_parse(&entry, Some(test_timestamp()));
378
379 assert!(result.is_some());
380 let event = result.as_ref().unwrap_or_else(|| unreachable!());
381 assert_eq!(event.metadata().raw_bytes(), body.as_bytes());
382 }
383
384 #[test]
385 fn test_try_parse_stores_timestamp() {
386 let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
387 {\"DraftId\": \"ts-test\"}";
388 let entry = unity_entry(body);
389 let ts = Some(test_timestamp());
390 let result = try_parse(&entry, ts);
391
392 assert!(result.is_some());
393 let event = result.as_ref().unwrap_or_else(|| unreachable!());
394 assert_eq!(event.metadata().timestamp(), ts);
395 }
396
397 #[test]
398 fn test_try_parse_preserves_raw_payload() {
399 let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
400 {\n\
401 \"DraftId\": \"raw-payload\",\n\
402 \"ExtraField\": \"preserved\"\n\
403 }";
404 let entry = unity_entry(body);
405 let result = try_parse(&entry, Some(test_timestamp()));
406
407 assert!(result.is_some());
408 let event = result.as_ref().unwrap_or_else(|| unreachable!());
409 let payload = draft_complete_payload(event);
410
411 assert_eq!(payload["raw_complete_draft"]["ExtraField"], "preserved");
412 }
413 }
414
415 mod non_matching {
418 use super::*;
419
420 #[test]
421 fn test_try_parse_unrelated_entry_returns_none() {
422 let body = "[UnityCrossThreadLogger]greToClientEvent\n{\"data\": 1}";
423 let entry = unity_entry(body);
424 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
425 }
426
427 #[test]
428 fn test_try_parse_empty_body_returns_none() {
429 let body = "[UnityCrossThreadLogger]";
430 let entry = unity_entry(body);
431 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
432 }
433
434 #[test]
435 fn test_try_parse_bot_draft_entry_returns_none() {
436 let body = "[UnityCrossThreadLogger]BotDraft_DraftPick\n\
437 {\"PickInfo\": {\"CardId\": 12345}}";
438 let entry = unity_entry(body);
439 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
440 }
441
442 #[test]
443 fn test_try_parse_human_draft_entry_returns_none() {
444 let body = "[UnityCrossThreadLogger]Draft.Notify\n\
445 {\"SelfPack\": 0, \"SelfPick\": 0, \
446 \"PackCards\": \"12345\"}";
447 let entry = unity_entry(body);
448 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
449 }
450
451 #[test]
452 fn test_try_parse_malformed_json_returns_none() {
453 let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
454 {broken json!!!}";
455 let entry = unity_entry(body);
456 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
457 }
458
459 #[test]
460 fn test_try_parse_marker_only_no_json_returns_none() {
461 let body = "[UnityCrossThreadLogger]DraftCompleteDraft";
462 let entry = unity_entry(body);
463 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
464 }
465
466 #[test]
467 fn test_try_parse_connection_manager_header_returns_none() {
468 let entry = LogEntry {
469 header: EntryHeader::ConnectionManager,
470 body: "[ConnectionManager]some connection message".to_owned(),
471 };
472 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
473 }
474
475 #[test]
476 fn test_try_parse_old_underscore_marker_returns_none() {
477 let body = "[UnityCrossThreadLogger]Draft_CompleteDraft\n\
478 {\"DraftId\": \"old-marker\", \
479 \"EventName\": \"PremierDraft_MKM_20260201\"}";
480 let entry = unity_entry(body);
481 assert!(try_parse(&entry, Some(test_timestamp())).is_none());
482 }
483 }
484
485 mod timestamp_in_header {
488 use super::*;
489
490 #[test]
491 fn test_try_parse_with_timestamp_prefix() {
492 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM \
493 DraftCompleteDraft\n\
494 {\"DraftId\": \"ts-prefix\", \
495 \"EventName\": \"PremierDraft_MKM_20260201\"}";
496 let entry = unity_entry(body);
497 let result = try_parse(&entry, Some(test_timestamp()));
498
499 assert!(result.is_some());
500 let event = result.as_ref().unwrap_or_else(|| unreachable!());
501 let payload = draft_complete_payload(event);
502
503 assert_eq!(payload["type"], "draft_complete");
504 assert_eq!(payload["draft_id"], "ts-prefix");
505 }
506 }
507
508 mod performance_class {
511 use super::*;
512
513 #[test]
514 fn test_draft_complete_event_is_durable_per_event() {
515 let body = "[UnityCrossThreadLogger]DraftCompleteDraft\n\
516 {\"DraftId\": \"perf-test\"}";
517 let entry = unity_entry(body);
518 let result = try_parse(&entry, Some(test_timestamp()));
519
520 assert!(result.is_some());
521 let event = result.as_ref().unwrap_or_else(|| unreachable!());
522 assert_eq!(event.performance_class(), PerformanceClass::DurablePerEvent);
523 }
524 }
525
526 mod helpers {
529 use super::*;
530
531 #[test]
532 fn test_build_payload_request_format() {
533 let parsed = serde_json::json!({
534 "id": "req-123",
535 "request": "{\"EventName\":\"PremierDraft_MKM_20260201\",\"IsBotDraft\":false}"
536 });
537 let payload = build_payload(&parsed, None);
538 assert_eq!(payload["type"], "draft_complete");
539 assert_eq!(payload["draft_id"], "req-123");
540 assert_eq!(payload["event_name"], "PremierDraft_MKM_20260201");
541 }
542
543 #[test]
544 fn test_build_payload_response_format() {
545 let parsed = serde_json::json!({
546 "CourseId": "course-456",
547 "InternalEventName": "PremierDraft_MKM_20260201",
548 "CurrentModule": "DeckSelect"
549 });
550 let payload = build_payload(&parsed, Some("resp-789"));
551 assert_eq!(payload["draft_id"], "resp-789");
552 assert_eq!(payload["event_name"], "PremierDraft_MKM_20260201");
553 }
554
555 #[test]
556 fn test_build_payload_flat_format() {
557 let parsed = serde_json::json!({
558 "DraftId": "test-id",
559 "EventName": "PremierDraft_MKM_20260201"
560 });
561 let payload = build_payload(&parsed, None);
562 assert_eq!(payload["type"], "draft_complete");
563 assert_eq!(payload["draft_id"], "test-id");
564 assert_eq!(payload["event_name"], "PremierDraft_MKM_20260201");
565 }
566
567 #[test]
568 fn test_build_payload_empty() {
569 let parsed = serde_json::json!({});
570 let payload = build_payload(&parsed, None);
571 assert_eq!(payload["draft_id"], "");
572 assert_eq!(payload["event_name"], "");
573 }
574
575 #[test]
576 fn test_extract_draft_id_from_body_response() {
577 let body = "<== DraftCompleteDraft(abc-123-def)\n{\"some\":\"json\"}";
578 assert_eq!(
579 extract_draft_id_from_body(body),
580 Some("abc-123-def".to_owned()),
581 );
582 }
583
584 #[test]
585 fn test_extract_draft_id_from_body_request_returns_none() {
586 let body = r#"==> DraftCompleteDraft {"id":"abc-123-def"}"#;
587 assert!(extract_draft_id_from_body(body).is_none());
588 }
589
590 #[test]
591 fn test_extract_draft_id_from_body_no_marker() {
592 assert!(extract_draft_id_from_body("no marker here").is_none());
593 }
594 }
595}