|
14 | 14 |
|
15 | 15 | from __future__ import annotations |
16 | 16 |
|
17 | | -"""Unit tests for the helper methods on the Event class.""" |
| 17 | +"""Unit tests for the Event class.""" |
18 | 18 |
|
19 | 19 | from google.adk.events.event import Event |
| 20 | +from google.adk.events.event import NodeInfo |
20 | 21 | from google.adk.events.event_actions import EventActions |
| 22 | +from google.adk.events.request_input import RequestInput |
21 | 23 | from google.genai import types |
| 24 | +import pytest |
22 | 25 |
|
23 | 26 |
|
24 | 27 | def _text_part(text: str = 'hello') -> types.Part: |
@@ -188,3 +191,237 @@ def test_event_ids_are_unique(): |
188 | 191 |
|
189 | 192 | def test_event_id_preserved_when_provided(): |
190 | 193 | assert _event(id='fixed-id').id == 'fixed-id' |
| 194 | + |
| 195 | + |
| 196 | +# --- state initialization ---------------------------------------------------- |
| 197 | + |
| 198 | + |
| 199 | +def test_event_constructor_with_state(): |
| 200 | + """Tests that the event constructor handles the state argument.""" |
| 201 | + my_event = Event(state={'key': 'value'}) |
| 202 | + assert my_event.actions is not None |
| 203 | + assert my_event.actions.state_delta == {'key': 'value'} |
| 204 | + |
| 205 | + |
| 206 | +def test_event_constructor_without_state(): |
| 207 | + """Tests that the event constructor works without the state argument.""" |
| 208 | + my_event = Event() |
| 209 | + assert my_event.actions is not None |
| 210 | + assert my_event.actions.state_delta == {} |
| 211 | + |
| 212 | + |
| 213 | +# --- isolation scope --------------------------------------------------------- |
| 214 | + |
| 215 | + |
| 216 | +def test_event_isolation_scope(): |
| 217 | + """Tests Event.isolation_scope default value and serialization.""" |
| 218 | + ev = Event() |
| 219 | + assert ev.isolation_scope is None |
| 220 | + |
| 221 | + ev2 = Event(isolation_scope='task:fc-123') |
| 222 | + dumped = ev2.model_dump(mode='json', by_alias=True, exclude_none=True) |
| 223 | + assert dumped['isolationScope'] == 'task:fc-123' |
| 224 | + |
| 225 | + |
| 226 | +# --- serialization ----------------------------------------------------------- |
| 227 | + |
| 228 | + |
| 229 | +def test_event_serialization_always_camel_case(): |
| 230 | + """Tests that Event serialization produces camelCase keys.""" |
| 231 | + request_input = RequestInput(interrupt_id='fc-1', message='test') |
| 232 | + |
| 233 | + # Create an event with fields that would produce snake_case if not dumped by alias |
| 234 | + event = Event( |
| 235 | + invocation_id='i-1', |
| 236 | + node_info=NodeInfo( |
| 237 | + path='a/b', |
| 238 | + output_for=['c'], |
| 239 | + message_as_output=True, |
| 240 | + ), |
| 241 | + output=request_input, |
| 242 | + ) |
| 243 | + |
| 244 | + dumped = event.model_dump(by_alias=True) |
| 245 | + |
| 246 | + def check_no_snake_case_keys(data): |
| 247 | + if isinstance(data, dict): |
| 248 | + for key, value in data.items(): |
| 249 | + assert '_' not in key, f'Found snake_case key: {key} in {data}' |
| 250 | + check_no_snake_case_keys(value) |
| 251 | + elif isinstance(data, list): |
| 252 | + for item in data: |
| 253 | + check_no_snake_case_keys(item) |
| 254 | + |
| 255 | + check_no_snake_case_keys(dumped) |
| 256 | + |
| 257 | + # Also verify that expected keys are indeed camelCased |
| 258 | + assert 'invocationId' in dumped |
| 259 | + assert 'nodeInfo' in dumped |
| 260 | + assert 'outputFor' in dumped['nodeInfo'] |
| 261 | + assert 'messageAsOutput' in dumped['nodeInfo'] |
| 262 | + |
| 263 | + # Verify RequestInput fields are camelCased |
| 264 | + assert 'output' in dumped |
| 265 | + assert 'interruptId' in dumped['output'] |
| 266 | + |
| 267 | + |
| 268 | +# --- message alias for content ----------------------------------------------- |
| 269 | + |
| 270 | + |
| 271 | +class TestMessageConstructor: |
| 272 | + """Tests for Event(message=...) constructor parameter.""" |
| 273 | + |
| 274 | + def test_message_str_sets_content(self): |
| 275 | + event = Event(message='Hello!') |
| 276 | + assert event.content is not None |
| 277 | + assert event.content.parts[0].text == 'Hello!' |
| 278 | + |
| 279 | + def test_message_content_passes_through(self): |
| 280 | + content = types.Content( |
| 281 | + parts=[types.Part(text='from Content')], role='model' |
| 282 | + ) |
| 283 | + event = Event(message=content) |
| 284 | + assert event.content is content |
| 285 | + |
| 286 | + def test_message_part_converts_to_content(self): |
| 287 | + part = types.Part(text='from Part') |
| 288 | + event = Event(message=part) |
| 289 | + assert event.content is not None |
| 290 | + assert event.content.parts[0].text == 'from Part' |
| 291 | + |
| 292 | + def test_message_list_of_parts(self): |
| 293 | + parts = [types.Part(text='part1'), types.Part(text='part2')] |
| 294 | + event = Event(message=parts) |
| 295 | + assert event.content is not None |
| 296 | + assert len(event.content.parts) == 2 |
| 297 | + assert event.content.parts[0].text == 'part1' |
| 298 | + assert event.content.parts[1].text == 'part2' |
| 299 | + |
| 300 | + def test_message_and_content_raises(self): |
| 301 | + with pytest.raises(ValueError, match='mutually exclusive'): |
| 302 | + Event( |
| 303 | + message='hello', |
| 304 | + content=types.Content(parts=[types.Part(text='world')]), |
| 305 | + ) |
| 306 | + |
| 307 | + def test_content_still_works(self): |
| 308 | + content = types.Content( |
| 309 | + parts=[types.Part(text='via content')], role='model' |
| 310 | + ) |
| 311 | + event = Event(content=content) |
| 312 | + assert event.content is content |
| 313 | + assert event.content.parts[0].text == 'via content' |
| 314 | + |
| 315 | + def test_neither_message_nor_content(self): |
| 316 | + event = Event() |
| 317 | + assert event.content is None |
| 318 | + |
| 319 | + |
| 320 | +class TestMessageProperty: |
| 321 | + """Tests for Event.message property getter and setter.""" |
| 322 | + |
| 323 | + def test_message_getter_aliases_content(self): |
| 324 | + content = types.Content(parts=[types.Part(text='hello')], role='model') |
| 325 | + event = Event(content=content) |
| 326 | + assert event.message is event.content |
| 327 | + |
| 328 | + def test_message_getter_none_when_no_content(self): |
| 329 | + event = Event() |
| 330 | + assert event.message is None |
| 331 | + |
| 332 | + def test_message_setter_updates_content(self): |
| 333 | + event = Event() |
| 334 | + new_content = types.Content( |
| 335 | + parts=[types.Part(text='updated')], role='model' |
| 336 | + ) |
| 337 | + event.message = new_content |
| 338 | + assert event.content is new_content |
| 339 | + |
| 340 | + def test_message_setter_accepts_str(self): |
| 341 | + event = Event() |
| 342 | + event.message = 'updated via setter' |
| 343 | + assert event.content is not None |
| 344 | + assert event.content.parts[0].text == 'updated via setter' |
| 345 | + |
| 346 | + def test_message_setter_none_clears_content(self): |
| 347 | + event = Event(message='hello') |
| 348 | + event.message = None |
| 349 | + assert event.content is None |
| 350 | + |
| 351 | + def test_message_from_constructor_readable_via_property(self): |
| 352 | + event = Event(message='Hello!') |
| 353 | + assert event.message is not None |
| 354 | + assert event.message.parts[0].text == 'Hello!' |
| 355 | + |
| 356 | + |
| 357 | +class TestMessageSerialization: |
| 358 | + """Tests that serialization uses 'content', not 'message'.""" |
| 359 | + |
| 360 | + def test_serialized_uses_content_field(self): |
| 361 | + event = Event(message='Hello!') |
| 362 | + data = event.model_dump(exclude_none=True) |
| 363 | + assert 'content' in data |
| 364 | + assert 'message' not in data |
| 365 | + |
| 366 | + def test_round_trip_via_content(self): |
| 367 | + event = Event(message='Hello!') |
| 368 | + data = event.model_dump() |
| 369 | + restored = Event.model_validate(data) |
| 370 | + assert restored.content is not None |
| 371 | + assert restored.content.parts[0].text == 'Hello!' |
| 372 | + assert restored.message is not None |
| 373 | + assert restored.message.parts[0].text == 'Hello!' |
| 374 | + |
| 375 | + |
| 376 | +class TestMessageWithOtherKwargs: |
| 377 | + """Tests message combined with other convenience kwargs.""" |
| 378 | + |
| 379 | + def test_message_with_state(self): |
| 380 | + event = Event(message='hello', state={'key': 'val'}) |
| 381 | + assert event.content is not None |
| 382 | + assert event.content.parts[0].text == 'hello' |
| 383 | + assert event.actions.state_delta == {'key': 'val'} |
| 384 | + |
| 385 | + def test_message_with_route(self): |
| 386 | + event = Event(message='hello', route='next') |
| 387 | + assert event.content is not None |
| 388 | + assert event.actions.route == 'next' |
| 389 | + |
| 390 | + |
| 391 | +class TestMessageSubclassField: |
| 392 | + """Tests that a subclass declaring `message` as a real field is honored. |
| 393 | +
|
| 394 | + `_accept_convenience_kwargs` already routes construction kwargs to such a |
| 395 | + field; the `message` property/setter must defer to it too instead of |
| 396 | + aliasing `content`. |
| 397 | + """ |
| 398 | + |
| 399 | + def test_subclass_field_readable_via_property(self): |
| 400 | + class _Sub(Event): |
| 401 | + message: str = '' |
| 402 | + |
| 403 | + event = _Sub(message='hello', author='a') |
| 404 | + assert event.message == 'hello' |
| 405 | + |
| 406 | + def test_subclass_field_serializes_and_round_trips(self): |
| 407 | + class _Sub(Event): |
| 408 | + message: str = '' |
| 409 | + |
| 410 | + event = _Sub(message='hello', author='a') |
| 411 | + data = event.model_dump() |
| 412 | + assert data['message'] == 'hello' |
| 413 | + assert _Sub.model_validate(data).message == 'hello' |
| 414 | + |
| 415 | + def test_subclass_field_setter_updates_field_not_content(self): |
| 416 | + class _Sub(Event): |
| 417 | + message: str = '' |
| 418 | + |
| 419 | + event = _Sub(message='hello', author='a') |
| 420 | + event.message = 'updated' |
| 421 | + assert event.message == 'updated' |
| 422 | + assert event.content is None |
| 423 | + |
| 424 | + def test_base_event_message_still_aliases_content(self): |
| 425 | + content = types.Content(parts=[types.Part(text='hi')], role='model') |
| 426 | + event = Event(content=content) |
| 427 | + assert event.message is event.content |
0 commit comments