This commit is contained in:
gsinghpal
2026-05-16 13:18:52 -04:00
parent 191a9c82be
commit 9ebf89bde2
1080 changed files with 0 additions and 1197 deletions

View File

@@ -0,0 +1,24 @@
from . import test_overdue_aging
from . import test_level_resolver
from . import test_risk_scorer
from . import test_tone_selector
from . import test_followup_text_generator
from . import test_fusion_followup_level
from . import test_fusion_followup_run
from . import test_fusion_followup_text_cache
from . import test_res_partner_inherit
from . import test_account_move_line_inherit
from . import test_fusion_followup_engine
from . import test_engine_integration
from . import test_followup_controller
from . import test_followup_adapter
from . import test_followup_tools
from . import test_followup_cron
from . import test_engine_property
from . import test_followup_full_flow
from . import test_performance_benchmarks
from . import test_batch_followup_wizard
from . import test_migration_round_trip
from . import test_coexistence
from . import test_followup_tours
from . import test_local_llm_compat

View File

@@ -0,0 +1,34 @@
from odoo import fields as odoo_fields
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAccountMoveLineFollowup(TransactionCase):
"""Verify follow-up tracking fields are added to account.move.line."""
def test_fields_exist_on_model(self):
"""Both new fields are declared on account.move.line."""
AML = self.env['account.move.line']
self.assertIn('fusion_followup_level_id', AML._fields)
self.assertIn('fusion_followup_last_run_date', AML._fields)
self.assertEqual(
AML._fields['fusion_followup_level_id'].comodel_name,
'fusion.followup.level',
)
def test_assign_level_and_date_on_existing_line(self):
"""We can write the new fields onto an existing move line."""
line = self.env['account.move.line'].search([], limit=1)
if not line:
self.skipTest("No account.move.line records present in DB to test against.")
level = self.env['fusion.followup.level'].create({
'name': 'Reminder', 'sequence': 601, 'delay_days': 7, 'tone': 'gentle',
})
when = odoo_fields.Datetime.now()
line.write({
'fusion_followup_level_id': level.id,
'fusion_followup_last_run_date': when,
})
self.assertEqual(line.fusion_followup_level_id, level)
self.assertEqual(line.fusion_followup_last_run_date, when)

View File

@@ -0,0 +1,37 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.exceptions import UserError
@tagged('post_install', '-at_install')
class TestBatchFollowupWizard(TransactionCase):
def test_default_loads_active_ids(self):
partners = self.env['res.partner'].create([
{'name': 'B1'}, {'name': 'B2'},
])
wizard = self.env['fusion.batch.followup.wizard'].with_context(
active_model='res.partner', active_ids=partners.ids,
).create({})
self.assertEqual(set(wizard.partner_ids.ids), set(partners.ids))
def test_selected_scope_no_partners_raises(self):
wizard = self.env['fusion.batch.followup.wizard'].create({
'scope': 'selected', 'partner_ids': [(6, 0, [])],
})
with self.assertRaises(UserError):
wizard.action_run()
def test_run_completes_with_no_overdue_partners(self):
partners = self.env['res.partner'].create([
{'name': 'NoOverdue1'}, {'name': 'NoOverdue2'},
])
wizard = self.env['fusion.batch.followup.wizard'].create({
'scope': 'selected',
'partner_ids': [(6, 0, partners.ids)],
'force': True,
})
wizard.action_run()
self.assertEqual(wizard.state, 'done')
# 2 partners with no overdue → both skipped
self.assertEqual(wizard.skipped_count, 2)

View File

@@ -0,0 +1,37 @@
"""Coexistence tests: fusion_accounting_followup menu only visible when
Enterprise account_followup is NOT installed."""
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFollowupCoexistence(TransactionCase):
def setUp(self):
super().setUp()
self.coex_group = self.env.ref(
'fusion_accounting_core.group_fusion_show_when_enterprise_absent',
raise_if_not_found=False,
)
self.assertIsNotNone(self.coex_group, "Coexistence group must exist")
def test_engine_always_available(self):
self.assertIn('fusion.followup.engine', self.env.registry)
def test_menu_gated_by_coexistence_group(self):
menu = self.env.ref('fusion_accounting_followup.menu_fusion_followup_root',
raise_if_not_found=False)
if not menu:
self.skipTest("Menu not loaded")
menu_groups = getattr(menu, 'group_ids', None) or menu.groups_id
self.assertIn(self.coex_group, menu_groups,
"Followup root menu must require the coexistence group")
def test_levels_menu_gated(self):
menu = self.env.ref('fusion_accounting_followup.menu_fusion_followup_levels',
raise_if_not_found=False)
if not menu:
self.skipTest("Menu not loaded")
menu_groups = getattr(menu, 'group_ids', None) or menu.groups_id
self.assertIn(self.coex_group, menu_groups)

View File

@@ -0,0 +1,76 @@
"""Integration tests: full follow-up flow with real overdue invoices."""
from datetime import date, timedelta
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install', 'integration')
class TestFollowupEngineIntegration(TransactionCase):
def setUp(self):
super().setUp()
self.engine = self.env['fusion.followup.engine']
self.partner = self.env['res.partner'].create({
'name': 'Integration Partner', 'email': 'integ@test.local',
})
for seq, name, days, tone in [(801, 'Test Reminder', 7, 'gentle'),
(802, 'Test Warning', 30, 'firm'),
(803, 'Test Legal', 60, 'legal')]:
self.env['fusion.followup.level'].create({
'name': name, 'sequence': seq, 'delay_days': days, 'tone': tone,
})
line = self.env['account.move.line'].search([
('parent_state', '=', 'posted'),
('account_id.account_type', '=', 'asset_receivable'),
('reconciled', '=', False),
('amount_residual', '>', 0),
], limit=1)
if not line:
self.skipTest("No posted unreconciled receivable lines in test DB")
line.write({
'partner_id': self.partner.id,
'date_maturity': date.today() - timedelta(days=20),
})
def test_get_overdue_finds_lines(self):
result = self.engine.get_overdue_for_partner(self.partner)
self.assertGreater(result['overdue_line_count'], 0)
self.assertGreater(result['aging']['total_overdue_amount'], 0)
def test_compute_level_picks_reminder_at_20_days(self):
level = self.engine.compute_followup_level(self.partner)
self.assertTrue(level)
self.assertGreater(level.delay_days, 0)
def test_send_followup_creates_run(self):
result = self.engine.send_followup_email(self.partner, force=True)
self.assertIn(result['status'], ('sent', 'manual_review'))
if 'run_id' in result:
run = self.env['fusion.followup.run'].browse(result['run_id'])
self.assertEqual(run.partner_id, self.partner)
def test_pause_blocks_send_unless_force(self):
self.engine.pause_followup(self.partner,
until_date=date.today() + timedelta(days=30))
result = self.engine.send_followup_email(self.partner)
self.assertTrue(result['status'].startswith('paused'))
result_force = self.engine.send_followup_email(self.partner, force=True)
self.assertIn(result_force['status'], ('sent', 'manual_review'))
def test_history_grows_with_each_send(self):
Run = self.env['fusion.followup.run']
before = Run.search_count([('partner_id', '=', self.partner.id)])
self.engine.send_followup_email(self.partner, force=True)
after = Run.search_count([('partner_id', '=', self.partner.id)])
self.assertGreater(after, before)
def test_text_cache_used_on_repeat_call(self):
Cache = self.env['fusion.followup.text.cache']
self.engine.send_followup_email(self.partner, force=True)
cache_count_after_first = Cache.search_count([('partner_id', '=', self.partner.id)])
self.engine.send_followup_email(self.partner, force=True)
cache_count_after_second = Cache.search_count([('partner_id', '=', self.partner.id)])
self.assertEqual(cache_count_after_first, cache_count_after_second,
"Repeat send with same params should not create new cache row")

View File

@@ -0,0 +1,92 @@
"""Property-based invariants for follow-up services."""
from datetime import date, timedelta
from hypothesis import given, settings, strategies as st, HealthCheck
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_followup.services.overdue_aging import (
compute_aging, BUCKETS,
)
from odoo.addons.fusion_accounting_followup.services.risk_scorer import score_partner
from odoo.addons.fusion_accounting_followup.services.tone_selector import select_tone
@tagged('post_install', '-at_install', 'property_based')
class TestAgingInvariants(TransactionCase):
@given(
as_of=st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31)),
amounts=st.lists(
st.tuples(
st.integers(min_value=-180, max_value=180),
st.floats(min_value=0.01, max_value=100000,
allow_nan=False, allow_infinity=False),
),
min_size=0, max_size=20,
),
)
@settings(max_examples=80, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_buckets_sum_equals_total(self, as_of, amounts):
lines = [
{'date_maturity': as_of + timedelta(days=offset),
'amount_residual': round(amt, 2)}
for offset, amt in amounts
]
report = compute_aging(move_lines=lines, as_of=as_of)
bucket_sum = sum(b.amount for b in report.buckets)
self.assertAlmostEqual(bucket_sum, report.total_amount, places=1)
@given(
as_of=st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31)),
days_overdue=st.integers(min_value=1, max_value=365),
amount=st.floats(min_value=0.01, max_value=10000,
allow_nan=False, allow_infinity=False),
)
@settings(max_examples=50, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_overdue_amount_excludes_current(self, as_of, days_overdue, amount):
lines = [
{'date_maturity': as_of - timedelta(days=days_overdue),
'amount_residual': round(amount, 2)},
{'date_maturity': as_of + timedelta(days=10),
'amount_residual': 100.0},
]
report = compute_aging(move_lines=lines, as_of=as_of)
self.assertAlmostEqual(report.total_overdue_amount, round(amount, 2), places=1)
@given(
invoices=st.integers(min_value=0, max_value=100),
late=st.integers(min_value=0, max_value=100),
days_late=st.floats(min_value=0, max_value=180,
allow_nan=False, allow_infinity=False),
)
@settings(max_examples=80, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_risk_score_in_range(self, invoices, late, days_late):
late = min(late, invoices) if invoices > 0 else 0
result = score_partner(
total_invoices=invoices, paid_late_count=late,
avg_days_late=days_late,
longest_overdue_days=int(days_late),
open_overdue_amount=invoices * 1000.0,
average_invoice_amount=1000.0,
)
self.assertGreaterEqual(result.score, 0)
self.assertLessEqual(result.score, 100)
@tagged('post_install', '-at_install', 'property_based')
class TestToneInvariants(TransactionCase):
@given(
sequence=st.integers(min_value=1, max_value=10),
risk=st.integers(min_value=0, max_value=100),
)
@settings(max_examples=50, deadline=1000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_tone_always_in_valid_set(self, sequence, risk):
tone = select_tone(level_sequence=sequence, risk_score=risk)
self.assertIn(tone, ('gentle', 'firm', 'legal'))

View File

@@ -0,0 +1,42 @@
"""FollowupAdapter wiring tests — engine paths."""
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
from odoo.addons.fusion_accounting_ai.services.data_adapters.followup import (
FollowupAdapter,
)
@tagged('post_install', '-at_install')
class TestFollowupAdapter(TransactionCase):
def setUp(self):
super().setUp()
self.adapter = FollowupAdapter(self.env)
def test_list_overdue_via_fusion_returns_dict(self):
result = self.adapter.list_overdue_via_fusion(
company_id=self.env.company.id,
)
self.assertIn('partners', result)
self.assertIn('total', result)
self.assertIn('count', result)
def test_list_overdue_via_community_returns_error(self):
result = self.adapter.list_overdue_via_community()
self.assertIn('error', result)
def test_send_followup_via_fusion_no_overdue(self):
partner = self.env['res.partner'].create({'name': 'AdapterTest'})
result = self.adapter.send_followup_via_fusion(
partner_id=partner.id, force=True,
)
self.assertIn(
result.get('status', ''),
('no_action', 'no_overdue', 'sent', 'manual_review'),
)
def test_send_followup_via_community_returns_error(self):
result = self.adapter.send_followup_via_community(partner_id=1)
self.assertIn('error', result)

View File

@@ -0,0 +1,80 @@
"""HttpCase tests for the 6 follow-up JSON-RPC endpoints."""
import json
from datetime import date, timedelta
from odoo.tests import tagged
from odoo.tests.common import HttpCase, new_test_user
@tagged('post_install', '-at_install')
class TestFollowupController(HttpCase):
def setUp(self):
super().setUp()
self.user = new_test_user(
self.env, login='fu_test_user',
groups='base.group_user,base.group_partner_manager,'
'account.group_account_invoice',
)
def _jsonrpc(self, endpoint, params):
self.authenticate('fu_test_user', 'fu_test_user')
url = f'/fusion/followup/{endpoint}'
body = {'jsonrpc': '2.0', 'method': 'call', 'params': params, 'id': 1}
response = self.url_open(
url, data=json.dumps(body),
headers={'Content-Type': 'application/json'},
)
self.assertEqual(
response.status_code, 200,
f"{endpoint} returned {response.status_code}: {response.text[:300]}",
)
result = response.json()
if 'error' in result:
self.fail(f"{endpoint} errored: {result['error']}")
return result.get('result', {})
def test_list_overdue_returns_dict(self):
result = self._jsonrpc('list_overdue', {'company_id': self.env.company.id})
self.assertIn('partners', result)
self.assertIn('total', result)
def test_get_partner_detail(self):
partner = self.env['res.partner'].create({
'name': 'Ctrl Test Partner', 'email': 'ctrl@test.local',
})
result = self._jsonrpc('get_partner_detail', {'partner_id': partner.id})
self.assertEqual(result['partner']['id'], partner.id)
self.assertIn('overdue', result)
self.assertIn('history', result)
def test_pause_sets_paused_until(self):
partner = self.env['res.partner'].create({'name': 'Pause Test'})
future = (date.today() + timedelta(days=20)).isoformat()
result = self._jsonrpc('pause', {
'partner_id': partner.id, 'until_date': future,
})
self.assertEqual(result['paused_until'], future)
def test_reset_clears_status(self):
partner = self.env['res.partner'].create({
'name': 'Reset Test',
'fusion_followup_status': 'paused',
})
result = self._jsonrpc('reset', {'partner_id': partner.id})
self.assertEqual(result['status'], 'reset')
def test_send_no_overdue_returns_no_action(self):
partner = self.env['res.partner'].create({
'name': 'No Overdue', 'email': 'no@test.local',
})
result = self._jsonrpc('send', {
'partner_id': partner.id, 'force': True,
})
self.assertIn(result.get('status'), ('no_action', 'no_overdue'))
def test_generate_text_no_level_returns_no_level(self):
partner = self.env['res.partner'].create({'name': 'NoLevel Test'})
result = self._jsonrpc('generate_text', {'partner_id': partner.id})
self.assertIn(result.get('status'), ('no_level', 'ok'))

View File

@@ -0,0 +1,18 @@
"""Smoke tests for the fusion follow-up cron handlers."""
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('post_install', '-at_install')
class TestFollowupCron(TransactionCase):
def setUp(self):
super().setUp()
self.cron = self.env['fusion.followup.cron']
def test_cron_daily_scan_runs(self):
self.cron._cron_daily_scan()
def test_cron_risk_refresh_runs(self):
self.cron._cron_risk_refresh()

View File

@@ -0,0 +1,84 @@
"""End-to-end integration: scan -> escalate -> send -> reset."""
from datetime import date, timedelta
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install', 'integration')
class TestFollowupFullFlow(TransactionCase):
def setUp(self):
super().setUp()
self.engine = self.env['fusion.followup.engine']
self.partner = self.env['res.partner'].create({
'name': 'Full Flow Partner', 'email': 'flow@test.local',
})
for seq, name, days, tone in [(701, 'FlowReminder', 7, 'gentle'),
(702, 'FlowWarning', 30, 'firm'),
(703, 'FlowLegal', 60, 'legal')]:
self.env['fusion.followup.level'].create({
'name': name, 'sequence': seq,
'delay_days': days, 'tone': tone,
})
line = self.env['account.move.line'].search([
('parent_state', '=', 'posted'),
('account_id.account_type', '=', 'asset_receivable'),
('reconciled', '=', False),
('amount_residual', '>', 0),
], limit=1)
if not line:
self.skipTest("No posted unreconciled receivable lines in test DB")
line.write({
'partner_id': self.partner.id,
'date_maturity': date.today() - timedelta(days=20),
})
def test_full_flow_scan_send_reset(self):
level = self.engine.compute_followup_level(self.partner)
self.assertTrue(level)
self.assertGreater(level.delay_days, 0)
Run = self.env['fusion.followup.run']
before = Run.search_count([('partner_id', '=', self.partner.id)])
result = self.engine.send_followup_email(self.partner, force=True)
after = Run.search_count([('partner_id', '=', self.partner.id)])
self.assertGreater(after, before)
self.assertIn(result['status'], ('sent', 'manual_review'))
self.engine.pause_followup(self.partner,
until_date=date.today() + timedelta(days=14))
result_paused = self.engine.send_followup_email(self.partner)
self.assertTrue(result_paused['status'].startswith('paused'))
self.engine.reset_followup(self.partner)
self.partner.invalidate_recordset(['fusion_followup_status'])
self.assertEqual(self.partner.fusion_followup_status, 'no_action')
def test_escalate_advances_to_next_level(self):
Level = self.env['fusion.followup.level']
level1 = Level.search([('sequence', '=', 701)], limit=1)
self.engine.send_followup_email(self.partner, level=level1, force=True)
self.partner.invalidate_recordset(['fusion_followup_last_level_id'])
result = self.engine.escalate_to_next_level(self.partner)
self.assertIn('partner_id', result)
self.partner.invalidate_recordset(['fusion_followup_last_level_id'])
if self.partner.fusion_followup_last_level_id:
self.assertGreaterEqual(self.partner.fusion_followup_last_level_id.sequence, 702)
def test_text_cache_reused_on_repeat(self):
Cache = self.env['fusion.followup.text.cache']
self.engine.send_followup_email(self.partner, force=True)
after_first = Cache.search_count([('partner_id', '=', self.partner.id)])
self.engine.send_followup_email(self.partner, force=True)
after_second = Cache.search_count([('partner_id', '=', self.partner.id)])
self.assertEqual(after_first, after_second)
def test_history_records_each_send(self):
Run = self.env['fusion.followup.run']
before = Run.search_count([('partner_id', '=', self.partner.id)])
self.engine.send_followup_email(self.partner, force=True)
self.engine.send_followup_email(self.partner, force=True)
after = Run.search_count([('partner_id', '=', self.partner.id)])
self.assertEqual(after - before, 2)

View File

@@ -0,0 +1,80 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_followup.services.followup_text_generator import (
generate_followup_text,
)
from odoo.addons.fusion_accounting_followup.services.followup_text_prompt import (
SYSTEM_PROMPT, build_prompt,
)
@tagged('post_install', '-at_install')
class TestFollowupTextGenerator(TransactionCase):
def setUp(self):
super().setUp()
self.env['ir.config_parameter'].sudo().search([
('key', 'in', ['fusion_accounting.provider.followup_text',
'fusion_accounting.provider.default'])
]).unlink()
def test_fallback_gentle(self):
result = generate_followup_text(
self.env, partner_name='Acme Corp', total_overdue=1500,
currency_code='USD', longest_overdue_days=15, tone='gentle',
invoice_count=2,
)
self.assertEqual(result['tone_used'], 'gentle')
self.assertIn('Acme Corp', result['body'])
self.assertIn('1,500.00', result['body'])
def test_fallback_firm(self):
result = generate_followup_text(
self.env, partner_name='Acme', total_overdue=5000,
currency_code='USD', longest_overdue_days=45, tone='firm',
invoice_count=3,
)
self.assertEqual(result['tone_used'], 'firm')
def test_fallback_legal(self):
result = generate_followup_text(
self.env, partner_name='Acme', total_overdue=10000,
currency_code='USD', longest_overdue_days=90, tone='legal',
invoice_count=5,
)
self.assertEqual(result['tone_used'], 'legal')
self.assertIn('FINAL NOTICE', result['subject'])
def test_returns_required_keys(self):
result = generate_followup_text(
self.env, partner_name='X', total_overdue=100,
currency_code='USD', longest_overdue_days=10, tone='gentle',
)
for key in ('subject', 'body', 'tone_used', 'key_points'):
self.assertIn(key, result)
@tagged('post_install', '-at_install')
class TestFollowupTextPrompt(TransactionCase):
def test_system_prompt_requires_json(self):
self.assertIn('JSON', SYSTEM_PROMPT)
self.assertIn('"subject"', SYSTEM_PROMPT)
self.assertIn('"body"', SYSTEM_PROMPT)
def test_build_prompt_returns_tuple(self):
result = build_prompt(
partner_name='X', total_overdue=100, currency_code='USD',
longest_overdue_days=10, tone='gentle',
)
self.assertEqual(len(result), 2)
self.assertIn('100.00', result[1])
def test_build_prompt_includes_risk_drivers(self):
_, user = build_prompt(
partner_name='X', total_overdue=100, currency_code='USD',
longest_overdue_days=10, tone='firm',
risk_drivers=['Chronic late payer', '5/10 paid late'],
)
self.assertIn('RISK FACTORS', user)
self.assertIn('Chronic late payer', user)

View File

@@ -0,0 +1,61 @@
"""AI tool dispatch tests for fusion follow-up tools."""
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
from odoo.addons.fusion_accounting_ai.services.tools import customer_followup as tools
@tagged('post_install', '-at_install')
class TestFusionFollowupTools(TransactionCase):
def test_fusion_list_overdue(self):
result = tools.fusion_list_overdue(
self.env, {'company_id': self.env.company.id},
)
self.assertIn('partners', result)
def test_fusion_get_partner_detail(self):
partner = self.env['res.partner'].create({
'name': 'Tool Partner', 'email': 't@t.local',
})
result = tools.fusion_get_partner_followup_detail(
self.env, {'partner_id': partner.id},
)
self.assertEqual(result['partner_id'], partner.id)
def test_fusion_generate_text_uses_fallback(self):
self.env['ir.config_parameter'].sudo().search([
('key', 'in', [
'fusion_accounting.provider.followup_text',
'fusion_accounting.provider.default',
]),
]).unlink()
result = tools.fusion_generate_followup_text(self.env, {
'partner_name': 'Acme', 'total_overdue': 1000,
'currency_code': 'USD', 'longest_overdue_days': 15,
'tone': 'gentle',
})
self.assertIn('subject', result)
self.assertIn('body', result)
def test_fusion_get_risk_score(self):
partner = self.env['res.partner'].create({'name': 'Risk Test'})
result = tools.fusion_get_partner_risk_score(
self.env, {'partner_id': partner.id},
)
self.assertIn('risk', result)
def test_tools_registered_in_dispatch(self):
from odoo.addons.fusion_accounting_ai.services.tools import TOOL_DISPATCH
for tool_name in [
'fusion_list_overdue',
'fusion_get_partner_followup_detail',
'fusion_generate_followup_text',
'fusion_send_followup',
'fusion_get_partner_risk_score',
]:
self.assertIn(
tool_name, TOOL_DISPATCH,
f"{tool_name} not registered in TOOL_DISPATCH",
)

View File

@@ -0,0 +1,23 @@
"""Python wrappers for OWL tours via HttpCase.start_tour."""
from odoo.tests.common import HttpCase
from odoo.tests import tagged
@tagged('post_install', '-at_install', 'tour')
class TestFollowupTours(HttpCase):
def test_smoke_tour(self):
self.start_tour("/odoo", "fusion_followup_smoke", login="admin")
def test_partners_tour(self):
self.start_tour("/odoo", "fusion_followup_partners", login="admin")
def test_levels_tour(self):
self.start_tour("/odoo", "fusion_followup_levels", login="admin")
def test_history_tour(self):
self.start_tour("/odoo", "fusion_followup_history", login="admin")
def test_batch_wizard_tour(self):
self.start_tour("/odoo", "fusion_followup_batch_wizard", login="admin")

View File

@@ -0,0 +1,74 @@
"""Unit tests for the fusion.followup.engine 7-method API."""
from datetime import date, timedelta
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFusionFollowupEngine(TransactionCase):
def setUp(self):
super().setUp()
self.engine = self.env['fusion.followup.engine']
self.partner = self.env['res.partner'].create({
'name': 'Engine Test Partner', 'email': 'engine@test.local',
})
for seq, name, days, tone in [(901, 'Reminder', 7, 'gentle'),
(902, 'Warning', 30, 'firm'),
(903, 'Legal', 60, 'legal')]:
self.env['fusion.followup.level'].create({
'name': name, 'sequence': seq,
'delay_days': days, 'tone': tone,
})
def test_engine_model_exists(self):
self.assertIn('fusion.followup.engine', self.env.registry)
def test_get_overdue_returns_dict(self):
result = self.engine.get_overdue_for_partner(self.partner)
self.assertIn('aging', result)
self.assertIn('risk', result)
self.assertEqual(result['partner_id'], self.partner.id)
def test_compute_followup_level_no_overdue_returns_empty(self):
result = self.engine.compute_followup_level(self.partner)
self.assertFalse(result)
def test_pause_sets_partner_state(self):
until = date.today() + timedelta(days=14)
self.engine.pause_followup(self.partner, until_date=until)
self.partner.invalidate_recordset(['fusion_followup_paused_until', 'fusion_followup_status'])
self.assertEqual(self.partner.fusion_followup_paused_until, until)
self.assertEqual(self.partner.fusion_followup_status, 'paused')
def test_reset_clears_state(self):
self.engine.pause_followup(self.partner)
self.engine.reset_followup(self.partner)
self.partner.invalidate_recordset([
'fusion_followup_status', 'fusion_followup_paused_until',
'fusion_followup_last_level_id',
])
self.assertEqual(self.partner.fusion_followup_status, 'no_action')
self.assertFalse(self.partner.fusion_followup_paused_until)
def test_snapshot_history_returns_runs(self):
Run = self.env['fusion.followup.run']
run = Run.create({
'partner_id': self.partner.id,
'state': 'sent',
'overdue_amount': 500,
})
result = self.engine.snapshot_followup_history(self.partner)
self.assertEqual(result['count'], 1)
self.assertEqual(result['runs'][0]['id'], run.id)
def test_send_no_overdue_returns_no_action(self):
Level = self.env['fusion.followup.level']
level = Level.search([('sequence', '=', 901)], limit=1)
result = self.engine.send_followup_email(self.partner, level=level, force=True)
self.assertEqual(result['status'], 'no_overdue')
def test_escalate_when_no_current_level(self):
result = self.engine.escalate_to_next_level(self.partner)
self.assertIn('partner_id', result)

View File

@@ -0,0 +1,43 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFusionFollowupLevel(TransactionCase):
def test_create_minimal(self):
# Note: sequences 1-3 are reserved for seeded default levels.
level = self.env['fusion.followup.level'].create({
'name': 'Reminder', 'sequence': 901, 'delay_days': 7, 'tone': 'gentle',
})
self.assertEqual(level.name, 'Reminder')
self.assertTrue(level.active)
def test_negative_delay_rejected(self):
with self.assertRaises(Exception):
self.env['fusion.followup.level'].create({
'name': 'Bad', 'sequence': 902, 'delay_days': -5, 'tone': 'gentle',
})
def test_duplicate_sequence_rejected(self):
self.env['fusion.followup.level'].create({
'name': 'A', 'sequence': 100, 'delay_days': 7, 'tone': 'gentle',
})
with self.assertRaises(Exception):
self.env['fusion.followup.level'].create({
'name': 'B', 'sequence': 100, 'delay_days': 30, 'tone': 'firm',
})
def test_three_levels_escalate(self):
for seq, name, days, tone in [(1, 'R', 7, 'gentle'),
(2, 'W', 30, 'firm'),
(3, 'L', 60, 'legal')]:
self.env['fusion.followup.level'].create({
'name': name, 'sequence': seq + 200,
'delay_days': days, 'tone': tone,
})
levels = self.env['fusion.followup.level'].search([
('sequence', '>', 200),
], order='sequence')
self.assertEqual(len(levels), 3)
self.assertEqual(levels.mapped('tone'), ['gentle', 'firm', 'legal'])

View File

@@ -0,0 +1,44 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFusionFollowupRun(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'Run Test Partner'})
cls.level = cls.env['fusion.followup.level'].create({
'name': 'Reminder', 'sequence': 301, 'delay_days': 7, 'tone': 'gentle',
})
def test_create_minimal(self):
run = self.env['fusion.followup.run'].create({
'partner_id': self.partner.id,
'level_id': self.level.id,
})
self.assertEqual(run.state, 'draft')
self.assertTrue(run.execution_date)
def test_action_mark_sent(self):
run = self.env['fusion.followup.run'].create({
'partner_id': self.partner.id,
'level_id': self.level.id,
})
run.action_mark_sent()
self.assertEqual(run.state, 'sent')
def test_action_mark_failed_records_error(self):
run = self.env['fusion.followup.run'].create({
'partner_id': self.partner.id,
})
run.action_mark_failed(error='SMTP unreachable')
self.assertEqual(run.state, 'failed')
self.assertEqual(run.error_message, 'SMTP unreachable')
def test_partner_required(self):
with self.assertRaises(Exception):
self.env['fusion.followup.run'].create({
'level_id': self.level.id,
})

View File

@@ -0,0 +1,60 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFusionFollowupTextCache(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'Cache Test Partner'})
cls.level = cls.env['fusion.followup.level'].create({
'name': 'Reminder', 'sequence': 401, 'delay_days': 7, 'tone': 'gentle',
})
cls.cache = cls.env['fusion.followup.text.cache']
def _kwargs(self, **overrides):
base = dict(
partner_id=self.partner.id, level_id=self.level.id,
overdue_amount=1234.56, longest_overdue_days=10,
invoice_count=3, tone='gentle',
)
base.update(overrides)
return base
def test_fingerprint_stable_and_unique(self):
fp1 = self.cache.compute_fingerprint(**self._kwargs())
fp2 = self.cache.compute_fingerprint(**self._kwargs())
fp3 = self.cache.compute_fingerprint(**self._kwargs(tone='firm'))
self.assertEqual(fp1, fp2)
self.assertNotEqual(fp1, fp3)
self.assertEqual(len(fp1), 64)
def test_lookup_returns_empty_when_missing(self):
result = self.cache.lookup(**self._kwargs())
self.assertFalse(result)
def test_lookup_finds_cached_entry(self):
kwargs = self._kwargs()
fp = self.cache.compute_fingerprint(**kwargs)
entry = self.cache.create({
'partner_id': self.partner.id,
'level_id': self.level.id,
'fingerprint': fp,
'subject': 'Hi',
'body': 'Please pay.',
'tone_used': 'gentle',
})
found = self.cache.lookup(**kwargs)
self.assertEqual(found.id, entry.id)
def test_action_increment_use(self):
entry = self.cache.create({
'partner_id': self.partner.id,
'fingerprint': 'abc123',
})
self.assertEqual(entry.use_count, 0)
entry.action_increment_use()
entry.action_increment_use()
self.assertEqual(entry.use_count, 2)

View File

@@ -0,0 +1,58 @@
from datetime import date, timedelta
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_followup.services.level_resolver import (
FollowupLevelSpec, resolve_level,
)
from odoo.addons.fusion_accounting_followup.services.overdue_aging import compute_aging
@tagged('post_install', '-at_install')
class TestLevelResolver(TransactionCase):
def setUp(self):
super().setUp()
self.levels = [
FollowupLevelSpec(sequence=1, name='Reminder', delay_days=7, tone='gentle'),
FollowupLevelSpec(sequence=2, name='Warning', delay_days=30, tone='firm'),
FollowupLevelSpec(sequence=3, name='Legal Notice', delay_days=60, tone='legal'),
]
def test_no_overdue_returns_none(self):
as_of = date(2026, 4, 19)
lines = [{'date_maturity': as_of + timedelta(days=10), 'amount_residual': 100}]
report = compute_aging(move_lines=lines, as_of=as_of)
result = resolve_level(aging_report=report, levels=self.levels)
self.assertIsNone(result)
def test_15_days_overdue_picks_reminder(self):
as_of = date(2026, 4, 19)
lines = [{'date_maturity': as_of - timedelta(days=15), 'amount_residual': 100}]
report = compute_aging(move_lines=lines, as_of=as_of)
result = resolve_level(aging_report=report, levels=self.levels)
self.assertEqual(result.name, 'Reminder')
def test_45_days_overdue_picks_warning(self):
as_of = date(2026, 4, 19)
lines = [{'date_maturity': as_of - timedelta(days=45), 'amount_residual': 200}]
report = compute_aging(move_lines=lines, as_of=as_of)
result = resolve_level(aging_report=report, levels=self.levels)
self.assertEqual(result.name, 'Warning')
def test_75_days_overdue_picks_legal(self):
as_of = date(2026, 4, 19)
lines = [{'date_maturity': as_of - timedelta(days=75), 'amount_residual': 300}]
report = compute_aging(move_lines=lines, as_of=as_of)
result = resolve_level(aging_report=report, levels=self.levels)
self.assertEqual(result.name, 'Legal Notice')
def test_no_levels_returns_none(self):
as_of = date(2026, 4, 19)
lines = [{'date_maturity': as_of - timedelta(days=30), 'amount_residual': 100}]
report = compute_aging(move_lines=lines, as_of=as_of)
result = resolve_level(aging_report=report, levels=[])
self.assertIsNone(result)
def test_invalid_tone_raises(self):
with self.assertRaises(ValueError):
FollowupLevelSpec(sequence=1, name='X', delay_days=7, tone='invalid')

View File

@@ -0,0 +1,69 @@
"""Local LLM compat test for followup_text_generator.
Auto-detects LM Studio (:1234) or Ollama (:11434), skips when absent."""
import socket
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
def _server_reachable(host, port, timeout=1.0):
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except (OSError, socket.timeout):
return False
def _detect_local_llm():
for host, port, default_model in [
('host.docker.internal', 1234, 'local-model'),
('host.docker.internal', 11434, 'llama3.1:8b'),
('localhost', 1234, 'local-model'),
('localhost', 11434, 'llama3.1:8b'),
]:
if _server_reachable(host, port, timeout=0.5):
return (f'http://{host}:{port}/v1', default_model)
return (None, None)
@tagged('post_install', '-at_install', 'local_llm')
class TestLocalLLMFollowupText(TransactionCase):
def setUp(self):
super().setUp()
self.base_url, self.model = _detect_local_llm()
if not self.base_url:
self.skipTest("No local LLM server detected")
def test_followup_text_with_local_llm(self):
params = self.env['ir.config_parameter'].sudo()
prior = {k: params.get_param(k) for k in [
'fusion_accounting.openai_base_url',
'fusion_accounting.openai_model',
'fusion_accounting.provider.followup_text',
]}
params.set_param('fusion_accounting.openai_base_url', self.base_url)
params.set_param('fusion_accounting.openai_model', self.model)
params.set_param('fusion_accounting.openai_api_key', 'lm-studio')
params.set_param('fusion_accounting.provider.followup_text', 'openai')
try:
from odoo.addons.fusion_accounting_followup.services.followup_text_generator import (
generate_followup_text,
)
result = generate_followup_text(
self.env, partner_name='Acme Corp',
total_overdue=15000, currency_code='USD',
longest_overdue_days=45, tone='firm',
invoice_count=3,
risk_drivers=['8/12 invoices paid late', 'Avg 30 days late'],
)
self.assertIn('subject', result)
self.assertIn('body', result)
self.assertIn('tone_used', result)
finally:
for k, v in prior.items():
if v is not None:
params.set_param(k, v)

View File

@@ -0,0 +1,30 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFollowupMigrationRoundTrip(TransactionCase):
def test_bootstrap_step_runs(self):
wizard = self.env['fusion.migration.wizard'].create({})
result = wizard._followup_bootstrap_step()
self.assertEqual(result['step'], 'followup_bootstrap')
# Either Enterprise present or not — both OK
self.assertIn(result['enterprise_module_present'], [True, False])
def test_bootstrap_idempotent(self):
wizard = self.env['fusion.migration.wizard'].create({})
first = wizard._followup_bootstrap_step()
second = wizard._followup_bootstrap_step()
# Second run skips what first created (or both no-op)
if first['enterprise_module_present']:
self.assertGreaterEqual(second['skipped'], first['created'])
def test_partner_state_bootstrap_step(self):
"""Verify the partner-state migration step runs without error."""
wizard = self.env['fusion.migration.wizard'].create({})
result = wizard._followup_partner_state_bootstrap_step()
self.assertEqual(result['step'], 'followup_partner_state')
self.assertIn(result['enterprise_module_present'], [True, False])
self.assertGreaterEqual(result['updated'], 0)
self.assertGreaterEqual(result['skipped'], 0)

View File

@@ -0,0 +1,69 @@
from datetime import date, timedelta
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_followup.services.overdue_aging import (
compute_aging, BUCKETS,
)
@tagged('post_install', '-at_install')
class TestOverdueAging(TransactionCase):
def test_empty_lines_returns_zero_buckets(self):
report = compute_aging(move_lines=[], as_of=date(2026, 4, 19))
self.assertEqual(report.total_amount, 0)
self.assertEqual(len(report.buckets), len(BUCKETS))
for b in report.buckets:
self.assertEqual(b.amount, 0)
def test_current_bucket_for_future_maturity(self):
as_of = date(2026, 4, 19)
lines = [{'date_maturity': date(2026, 5, 19), 'amount_residual': 100}]
report = compute_aging(move_lines=lines, as_of=as_of)
current = next(b for b in report.buckets if b.name == 'current')
self.assertEqual(current.amount, 100)
self.assertEqual(report.total_overdue_amount, 0)
def test_30_day_bucket(self):
as_of = date(2026, 4, 19)
lines = [{'date_maturity': as_of - timedelta(days=15), 'amount_residual': 200}]
report = compute_aging(move_lines=lines, as_of=as_of)
b = next(b for b in report.buckets if b.name == '1_30')
self.assertEqual(b.amount, 200)
def test_60_day_bucket(self):
as_of = date(2026, 4, 19)
lines = [{'date_maturity': as_of - timedelta(days=45), 'amount_residual': 300}]
report = compute_aging(move_lines=lines, as_of=as_of)
b = next(b for b in report.buckets if b.name == '31_60')
self.assertEqual(b.amount, 300)
def test_120_plus_bucket(self):
as_of = date(2026, 4, 19)
lines = [{'date_maturity': as_of - timedelta(days=200), 'amount_residual': 500}]
report = compute_aging(move_lines=lines, as_of=as_of)
b = next(b for b in report.buckets if b.name == '120_plus')
self.assertEqual(b.amount, 500)
def test_total_overdue_excludes_current(self):
as_of = date(2026, 4, 19)
lines = [
{'date_maturity': as_of + timedelta(days=10), 'amount_residual': 100},
{'date_maturity': as_of - timedelta(days=10), 'amount_residual': 200},
{'date_maturity': as_of - timedelta(days=50), 'amount_residual': 300},
]
report = compute_aging(move_lines=lines, as_of=as_of)
self.assertEqual(report.total_amount, 600)
self.assertEqual(report.total_overdue_amount, 500)
def test_buckets_sum_equals_total(self):
as_of = date(2026, 4, 19)
lines = [
{'date_maturity': as_of + timedelta(days=10), 'amount_residual': 100},
{'date_maturity': as_of - timedelta(days=15), 'amount_residual': 200},
{'date_maturity': as_of - timedelta(days=75), 'amount_residual': 300},
{'date_maturity': as_of - timedelta(days=200), 'amount_residual': 500},
]
report = compute_aging(move_lines=lines, as_of=as_of)
bucket_sum = sum(b.amount for b in report.buckets)
self.assertAlmostEqual(bucket_sum, report.total_amount, places=2)

View File

@@ -0,0 +1,100 @@
"""Performance benchmarks tagged 'benchmark'."""
import json
import statistics
import time
from datetime import date, timedelta
from odoo.tests.common import HttpCase, TransactionCase, new_test_user
from odoo.tests import tagged
def _percentile(samples, p):
if len(samples) <= 1:
return samples[0] if samples else 0
sorted_s = sorted(samples)
idx = int(len(sorted_s) * p / 100)
return sorted_s[min(idx, len(sorted_s) - 1)]
@tagged('post_install', '-at_install', 'benchmark')
class TestEngineBenchmarks(TransactionCase):
def setUp(self):
super().setUp()
self.engine = self.env['fusion.followup.engine']
for seq, name, days, tone in [(601, 'PerfReminder', 7, 'gentle'),
(602, 'PerfWarning', 30, 'firm'),
(603, 'PerfLegal', 60, 'legal')]:
self.env['fusion.followup.level'].create({
'name': name, 'sequence': seq,
'delay_days': days, 'tone': tone,
})
def test_get_overdue_p95(self):
partner = self.env['res.partner'].create({'name': 'PerfPartner'})
timings = []
for _ in range(10):
start = time.perf_counter()
self.engine.get_overdue_for_partner(partner)
timings.append((time.perf_counter() - start) * 1000)
p95 = _percentile(timings, 95)
median = statistics.median(timings)
msg = f"get_overdue_for_partner: median={median:.0f}ms p95={p95:.0f}ms"
print(f"\n PERF: {msg} (target <100ms)")
self.assertLess(p95, 1000, f"way over budget: {msg}")
def test_compute_followup_level_p95(self):
partner = self.env['res.partner'].create({'name': 'CompLevelPerf'})
timings = []
for _ in range(10):
start = time.perf_counter()
self.engine.compute_followup_level(partner)
timings.append((time.perf_counter() - start) * 1000)
p95 = _percentile(timings, 95)
median = statistics.median(timings)
msg = f"compute_followup_level: median={median:.0f}ms p95={p95:.0f}ms"
print(f"\n PERF: {msg} (target <50ms)")
self.assertLess(p95, 500)
def test_send_followup_p95(self):
partner = self.env['res.partner'].create({
'name': 'SendPerf', 'email': 'sp@test.local',
})
timings = []
for _ in range(5):
start = time.perf_counter()
self.engine.send_followup_email(partner, force=True)
timings.append((time.perf_counter() - start) * 1000)
p95 = _percentile(timings, 95)
median = statistics.median(timings)
msg = f"send_followup_email (no overdue): median={median:.0f}ms p95={p95:.0f}ms"
print(f"\n PERF: {msg} (target <200ms)")
self.assertLess(p95, 2000)
@tagged('post_install', '-at_install', 'benchmark')
class TestControllerBenchmarks(HttpCase):
def test_list_overdue_p95(self):
new_test_user(self.env, login='fu_perf',
groups='base.group_user,account.group_account_invoice,base.group_partner_manager')
for i in range(20):
self.env['res.partner'].create({'name': f'PerfP{i}'})
self.authenticate('fu_perf', 'fu_perf')
timings = []
for _ in range(5):
start = time.perf_counter()
response = self.url_open(
'/fusion/followup/list_overdue',
data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'id': 1,
'params': {'company_id': self.env.company.id}}),
headers={'Content-Type': 'application/json'},
)
timings.append((time.perf_counter() - start) * 1000)
self.assertEqual(response.status_code, 200)
p95 = _percentile(timings, 95)
median = statistics.median(timings)
msg = f"controller.list_overdue: median={median:.0f}ms p95={p95:.0f}ms"
print(f"\n PERF: {msg} (target <500ms)")
self.assertLess(p95, 5000)

View File

@@ -0,0 +1,27 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestResPartnerFollowup(TransactionCase):
def test_default_status_no_action(self):
partner = self.env['res.partner'].create({'name': 'Default Status'})
self.assertEqual(partner.fusion_followup_status, 'no_action')
self.assertEqual(partner.fusion_followup_risk_band, 'low')
self.assertEqual(partner.fusion_followup_risk_score, 0)
def test_run_count_reflects_history(self):
partner = self.env['res.partner'].create({'name': 'History Partner'})
self.assertEqual(partner.fusion_followup_run_count, 0)
for _ in range(3):
self.env['fusion.followup.run'].create({'partner_id': partner.id})
partner.invalidate_recordset(['fusion_followup_run_count', 'fusion_followup_run_ids'])
self.assertEqual(partner.fusion_followup_run_count, 3)
def test_action_view_followup_history_returns_action(self):
partner = self.env['res.partner'].create({'name': 'Action Partner'})
action = partner.action_view_followup_history()
self.assertEqual(action['res_model'], 'fusion.followup.run')
self.assertEqual(action['domain'], [('partner_id', '=', partner.id)])
self.assertEqual(action['context']['default_partner_id'], partner.id)

View File

@@ -0,0 +1,48 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_followup.services.risk_scorer import (
score_partner, PartnerRiskScore,
)
@tagged('post_install', '-at_install')
class TestRiskScorer(TransactionCase):
def test_no_history_returns_low(self):
result = score_partner()
self.assertEqual(result.band, 'low')
self.assertLess(result.score, 30)
def test_chronic_late_pays_returns_high(self):
result = score_partner(
total_invoices=20, paid_late_count=18,
avg_days_late=45, longest_overdue_days=90,
open_overdue_amount=15000, average_invoice_amount=2000,
)
self.assertIn(result.band, ('high', 'critical'))
self.assertGreater(len(result.drivers), 0)
def test_one_off_overdue_returns_medium(self):
result = score_partner(
total_invoices=10, paid_late_count=1,
avg_days_late=20, longest_overdue_days=45,
open_overdue_amount=2000, average_invoice_amount=2000,
)
self.assertIn(result.band, ('low', 'medium'))
def test_score_capped_at_100(self):
result = score_partner(
total_invoices=10, paid_late_count=10,
avg_days_late=180, longest_overdue_days=300,
open_overdue_amount=999999, average_invoice_amount=1000,
)
self.assertLessEqual(result.score, 100)
def test_score_floored_at_0(self):
result = score_partner()
self.assertGreaterEqual(result.score, 0)
def test_band_thresholds(self):
for s, expected_band in [(10, 'low'), (40, 'medium'), (70, 'high'), (90, 'critical')]:
r = PartnerRiskScore(score=s, band=expected_band, drivers=[])
self.assertEqual(r.band, expected_band)

View File

@@ -0,0 +1,25 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_followup.services.tone_selector import select_tone
@tagged('post_install', '-at_install')
class TestToneSelector(TransactionCase):
def test_level_1_default_gentle(self):
self.assertEqual(select_tone(level_sequence=1), 'gentle')
def test_level_2_default_firm(self):
self.assertEqual(select_tone(level_sequence=2), 'firm')
def test_level_3_default_legal(self):
self.assertEqual(select_tone(level_sequence=3), 'legal')
def test_critical_risk_escalates_gentle_to_firm(self):
self.assertEqual(select_tone(level_sequence=1, risk_score=85), 'firm')
def test_extreme_risk_escalates_firm_to_legal(self):
self.assertEqual(select_tone(level_sequence=2, risk_score=95), 'legal')
def test_unknown_level_defaults_gentle(self):
self.assertEqual(select_tone(level_sequence=99), 'gentle')