use crate::e2e::codegen::assertion_type_skip::{
streaming_assertion_type_skip_line, streaming_assertion_value_skip_line,
};
use crate::e2e::codegen::field_skip::FieldSkip;
use crate::e2e::config::E2eConfig;
use crate::e2e::fixture::Assertion;
use super::values::json_to_ruby;
pub(super) fn emit_chat_stream_assertion(
out: &mut String,
assertion: &Assertion,
_e2e_config: &E2eConfig,
streaming_item_type: Option<&str>,
stream_complete_is_derived: bool,
) {
if super::assertion_mock_capture::try_render_mock_capture_assertion(out, assertion) {
return;
}
let atype = assertion.assertion_type.as_str();
if atype == "not_error" || atype == "error" {
return;
}
let field = assertion.field.as_deref().unwrap_or("");
if field == "no_chunks_after_done" && atype == "is_true" && stream_complete_is_derived {
out.push_str(&crate::e2e::template_env::render(
"ruby/no_chunks_after_done.jinja",
minijinja::context! {},
));
return;
}
if field == "no_chunks_after_done" {
out.push_str(&format!(
" # skipped: {}; this is not a chat stream, so it has no finish_reason \
and the done chunk is not observable here\n",
FieldSkip::StreamingAssertionOnUnsupportedField.message(field)
));
return;
}
if field == "stream_complete" && !stream_complete_is_derived {
out.push_str(&format!(
" # skipped: {}; this stream's chunks carry no terminal finish_reason, \
so completion is not observable here\n",
FieldSkip::StreamingAssertionOnUnsupportedField.message(field)
));
return;
}
enum Kind {
Chunks,
Bool,
Str,
IntTokens,
Json,
Unsupported,
}
let expr_opt = crate::e2e::codegen::streaming_assertions::StreamingFieldResolver::accessor_with_streaming_context(
field,
"ruby",
"chunks",
None,
streaming_item_type,
);
let (expr, kind) = match (field, expr_opt) {
("chunks", Some(expr)) => (expr, Kind::Chunks),
("chunks.length", Some(expr)) => (expr, Kind::Chunks),
("stream_content", Some(expr)) => (expr, Kind::Str),
("finish_reason", Some(expr)) => (expr, Kind::Str),
("tool_calls", Some(expr)) => (expr, Kind::Json),
("tool_calls[0].function.name", Some(expr)) => (expr, Kind::Str),
("usage.total_tokens", Some(expr)) => (expr, Kind::IntTokens),
("stream_complete", _) => ("stream_complete".to_string(), Kind::Bool),
_ => ("".to_string(), Kind::Unsupported),
};
if matches!(kind, Kind::Unsupported) {
out.push_str(&format!(
" # skipped: {}\n",
FieldSkip::StreamingAssertionOnUnsupportedField.message(field)
));
return;
}
match (atype, &kind) {
("count_min", Kind::Chunks) => {
if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
out.push_str(&format!(" expect({expr}.length).to be >= {n}\n"));
} else {
out.push_str(&format!(
"{}\n",
streaming_assertion_value_skip_line(" ", "#", field, atype)
));
}
}
("count_equals", Kind::Chunks) => {
if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
out.push_str(&format!(" expect({expr}.length).to eq({n})\n"));
} else {
out.push_str(&format!(
"{}\n",
streaming_assertion_value_skip_line(" ", "#", field, atype)
));
}
}
("equals", Kind::Str) => {
if let Some(val) = &assertion.value {
let rb_val = json_to_ruby(val);
out.push_str(&format!(" expect({expr}.to_s.strip).to eq({rb_val}.strip)\n"));
} else {
out.push_str(&format!(
"{}\n",
streaming_assertion_value_skip_line(" ", "#", field, atype)
));
}
}
("contains", Kind::Str) => {
if let Some(val) = &assertion.value {
let rb_val = json_to_ruby(val);
out.push_str(&format!(" expect({expr}.to_s).to include({rb_val})\n"));
} else {
out.push_str(&format!(
"{}\n",
streaming_assertion_value_skip_line(" ", "#", field, atype)
));
}
}
("not_empty", Kind::Str) => {
out.push_str(&format!(" expect({expr}.to_s).not_to be_empty\n"));
}
("not_empty", Kind::Json) => {
out.push_str(&format!(" expect({expr}).not_to be_nil\n"));
}
("is_empty", Kind::Str) => {
out.push_str(&format!(" expect({expr}.to_s).to be_empty\n"));
}
("is_true", Kind::Bool) => {
out.push_str(&format!(" expect({expr}).to be(true)\n"));
}
("is_false", Kind::Bool) => {
out.push_str(&format!(" expect({expr}).to be(false)\n"));
}
("greater_than_or_equal", Kind::IntTokens) => {
if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
out.push_str(&format!(" expect({expr}).to be >= {n}\n"));
} else {
out.push_str(&format!(
"{}\n",
streaming_assertion_value_skip_line(" ", "#", field, atype)
));
}
}
("equals", Kind::IntTokens) => {
if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
out.push_str(&format!(" expect({expr}).to eq({n})\n"));
} else {
out.push_str(&format!(
"{}\n",
streaming_assertion_value_skip_line(" ", "#", field, atype)
));
}
}
_ => {
out.push_str(&format!(
"{}\n",
streaming_assertion_type_skip_line(" ", "#", field, atype)
));
}
}
}
#[cfg(test)]
mod emit_chat_stream_assertion_tests {
use super::emit_chat_stream_assertion;
use crate::e2e::codegen::assertion_type_skip::AssertionTypeSkip;
use crate::e2e::config::E2eConfig;
use crate::e2e::fixture::Assertion;
#[test]
fn count_min_with_unnarrowable_value_emits_a_line_instead_of_vanishing() {
let assertion = Assertion {
assertion_type: "count_min".into(),
field: Some("chunks".into()),
value: Some(serde_json::json!("not-a-number")),
..Assertion::default()
};
let mut out = String::new();
emit_chat_stream_assertion(&mut out, &assertion, &E2eConfig::default(), None, false);
assert_eq!(
out, " # skipped: assertion type 'count_min' has no renderable value for streaming field 'chunks'\n",
"got: {out}"
);
assert_eq!(
AssertionTypeSkip::extract_classified(&out),
Some(("count_min", AssertionTypeSkip::StreamingAssertionValueNotRenderable)),
"the rendered line must round-trip through the assertion-type funnel, got: {out}"
);
}
#[test]
fn equals_with_no_declared_value_emits_a_line_instead_of_vanishing() {
let assertion = Assertion {
assertion_type: "equals".into(),
field: Some("stream_content".into()),
value: None,
..Assertion::default()
};
let mut out = String::new();
emit_chat_stream_assertion(&mut out, &assertion, &E2eConfig::default(), None, false);
assert_eq!(
out,
" # skipped: assertion type 'equals' has no renderable value for streaming field 'stream_content'\n",
"got: {out}"
);
assert_eq!(
AssertionTypeSkip::extract_classified(&out),
Some(("equals", AssertionTypeSkip::StreamingAssertionValueNotRenderable))
);
}
#[test]
fn unsupported_assertion_type_on_a_supported_field_is_recognised_by_the_funnel() {
let assertion = Assertion {
assertion_type: "matches_regex".into(),
field: Some("chunks".into()),
..Assertion::default()
};
let mut out = String::new();
emit_chat_stream_assertion(&mut out, &assertion, &E2eConfig::default(), None, false);
assert_eq!(
out, " # skipped: assertion type 'matches_regex' on field 'chunks' not yet supported for streaming\n",
"got: {out}"
);
assert_eq!(
AssertionTypeSkip::extract_classified(&out),
Some(("matches_regex", AssertionTypeSkip::StreamingAssertionTypeNotSupported)),
"the rendered line must round-trip through the assertion-type funnel, got: {out}"
);
}
#[test]
fn count_min_with_a_narrowable_value_still_renders_a_real_assertion() {
let assertion = Assertion {
assertion_type: "count_min".into(),
field: Some("chunks".into()),
value: Some(serde_json::json!(2)),
..Assertion::default()
};
let mut out = String::new();
emit_chat_stream_assertion(&mut out, &assertion, &E2eConfig::default(), None, false);
assert_eq!(out, " expect(chunks.length).to be >= 2\n", "got: {out}");
}
#[test]
fn no_chunks_after_done_checks_the_chunks_after_the_done_chunk() {
let assertion = Assertion {
assertion_type: "is_true".into(),
field: Some("no_chunks_after_done".into()),
..Assertion::default()
};
let mut out = String::new();
emit_chat_stream_assertion(&mut out, &assertion, &E2eConfig::default(), None, true);
assert!(
out.starts_with(" done_at = chunks.index { |c| c.choices.any? { |ch| !ch.finish_reason.nil? } }\n expect(done_at).not_to be_nil\n expect(done_at == chunks.length - 1).to be true"),
"got: {out}"
);
assert!(!out.contains("skipped"), "got: {out}");
}
#[test]
fn no_chunks_after_done_is_refused_when_the_stream_is_not_a_chat_stream() {
let assertion = Assertion {
assertion_type: "is_true".into(),
field: Some("no_chunks_after_done".into()),
..Assertion::default()
};
let mut out = String::new();
emit_chat_stream_assertion(&mut out, &assertion, &E2eConfig::default(), None, false);
assert!(out.starts_with(" # skipped: "), "got: {out}");
assert!(out.contains("this is not a chat stream"), "got: {out}");
assert!(!out.contains("done_at"), "got: {out}");
}
}