"""Tests for DingTalk adapter helper behavior."""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from langbot.pkg.platform.sources.dingtalk import (
DingTalkAdapter,
_dingtalk_card_markdown,
_dingtalk_clean_form_content,
_dingtalk_completed_input_lines,
_dingtalk_extract_component_inputs,
_dingtalk_form_component_params,
_dingtalk_pending_input_defs,
)
def test_dingtalk_select_component_params_expose_options():
params = _dingtalk_form_component_params(
{
'_current_input_field': 'choice',
'input_defs': [
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
}
],
'inputs': {},
}
)
assert params['select_visible'] == 'true'
assert params['select_placeholder'] == 'choice'
assert params['select_options'] == ['A', 'B']
assert [option['value'] for option in params['index_o']] == ['A', 'B']
assert [option['value'] for option in params['test_index']] == ['A', 'B']
assert params['index_o'][0]['text']['zh_CN'] == 'A'
assert params['index_o'][0]['text']['en_US'] == 'A'
assert params['select_index'] == -1
def test_dingtalk_extract_select_from_builtin_result_dict():
inputs = _dingtalk_extract_component_inputs({'selectResult': {'index': 1, 'value': 'B'}})
assert inputs == {'select': 'B'}
def test_dingtalk_extract_select_from_template_param_string():
inputs = _dingtalk_extract_component_inputs({'select': '{"index": 1, "value": "B"}'})
assert inputs == {'select': '{"index": 1, "value": "B"}'}
def test_dingtalk_extract_input_and_select_together():
inputs = _dingtalk_extract_component_inputs(
{
'inputResult': {'value': 'looks good'},
'__built_in_selectResult__': {'index': 0, 'value': 'A'},
}
)
assert inputs == {'input': 'looks good', 'select': 'A'}
def test_dingtalk_pending_input_defs_includes_file_fields():
pending = _dingtalk_pending_input_defs(
{
'input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{'output_variable_name': 'files', 'type': 'file-list'},
],
'inputs': {'comment': 'ready'},
}
)
assert [field['output_variable_name'] for field in pending] == ['files']
def test_dingtalk_completed_input_lines_include_text_and_select_values():
lines = _dingtalk_completed_input_lines(
{
'all_input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
},
],
'inputs': {'comment': 'looks good', 'choice': 'B'},
}
)
assert lines == ['✅ 已填写 comment:looks good', '✅ 已选择 choice:B']
def test_dingtalk_clean_form_content_uses_all_input_defs():
content = _dingtalk_clean_form_content(
{
'raw_form_content': 'Hello\n\n{{#$output.comment#}}\n\n{{#$output.choice#}}\n',
'input_defs': [],
'all_input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{'output_variable_name': 'choice', 'type': 'select'},
],
}
)
assert content == 'Hello'
def test_dingtalk_field_stage_keeps_prior_prompts_and_completed_values():
content = _dingtalk_clean_form_content(
{
'_current_input_field': 'choice',
'raw_form_content': ('Question\n{{#$output.comment#}}\nChoose an answer\n{{#$output.choice#}}'),
'form_content': 'Choose an answer',
'input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{'output_variable_name': 'choice', 'type': 'select'},
],
'inputs': {'comment': 'hello'},
}
)
assert '{{#$output.' not in content
assert content.index('Question') < content.index('comment')
assert content.index('comment') < content.index('Choose an answer')
def test_dingtalk_final_action_stage_interleaves_prompts_and_completed_values():
content = _dingtalk_clean_form_content(
{
'_action_select_only': True,
'raw_form_content': ('11\nQuestion\n{{#$output.comment#}}\nChoose an answer\n{{#$output.choice#}}'),
'all_input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{'output_variable_name': 'choice', 'type': 'select'},
],
'inputs': {'comment': 'hello', 'choice': 'B'},
}
)
assert '{{#$output.' not in content
assert content.startswith('11\nQuestion')
assert content.index('Question') < content.index('comment')
assert content.index('comment') < content.index('Choose an answer')
assert content.index('Choose an answer') < content.index('choice')
def test_dingtalk_card_markdown_preserves_internal_line_breaks():
assert _dingtalk_card_markdown('11\nQuestion\nCompleted') == '11
Question
Completed'
def _build_card_action_adapter() -> DingTalkAdapter:
adapter = DingTalkAdapter.model_construct(
card_state={
'card-1': {
'session_key': 'group_group-1',
'launcher_type': 'group',
'launcher_id': 'group-1',
'sender_user_id': 'initiator-1',
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'actions': [{'id': 'approve', 'title': 'Approve'}],
'node_title': 'Review',
'form_content': 'Please review',
'input_defs': [],
'inputs': {},
}
},
active_turn_card={},
active_turn_text={},
)
adapter.logger = AsyncMock()
adapter.ap = MagicMock()
adapter.ap.platform_mgr.bots = []
adapter.ap.query_pool.add_query = AsyncMock()
return adapter
@pytest.mark.asyncio
async def test_dingtalk_group_card_action_uses_clicker_as_sender():
adapter = _build_card_action_adapter()
with patch.object(DingTalkAdapter, '_mark_card_resolved', new=AsyncMock()) as mark_resolved:
await adapter._on_card_action(
{
'out_track_id': 'card-1',
'user_id': 'reviewer-2',
'action_id': 'approve',
'params': {},
}
)
await asyncio.sleep(0)
call = adapter.ap.query_pool.add_query.await_args
assert call.kwargs['launcher_id'] == 'group-1'
assert call.kwargs['sender_id'] == 'reviewer-2'
assert call.kwargs['message_event'].sender.id == 'reviewer-2'
mark_resolved.assert_awaited_once()
@pytest.mark.asyncio
async def test_dingtalk_unknown_card_action_is_rejected():
adapter = _build_card_action_adapter()
await adapter._on_card_action(
{
'out_track_id': 'card-1',
'user_id': 'reviewer-2',
'action_id': 'not-on-card',
'params': {},
}
)
adapter.ap.query_pool.add_query.assert_not_awaited()
adapter.logger.warning.assert_awaited_once()