Files
ai-contract-platform/yuqei-contract-platform/services/contract-api/tests/test_template_inputs.py
T

195 lines
11 KiB
Python

import importlib.util
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
import pytest
from pydantic import ValidationError
from sqlalchemy import create_engine, inspect, text
from alembic.migration import MigrationContext
from alembic.operations import Operations
from yuqei_contract_api.contract_templates import (
ContractTemplateEditRequest, ContractTemplateItem, ContractTemplateVariable,
TemplateRenderError, compile_template_body, get_contract_template,
get_contract_template_body, list_contract_templates, validate_contract_template_inputs,
)
from yuqei_contract_api.template_inputs import (
TemplateInputSchema, input_schema_hash, template_sample_values, validate_template_inputs,
)
from yuqei_contract_api.lifecycle_helpers import (
apply_template_snapshot_payload, build_template_edit_draft, build_template_diff,
template_edit_draft_snapshot, validate_template_edit_draft,
)
TYPED = [item for item in list_contract_templates().items
if item.input_schema is not None and item.source != "scene-pack-draft-v1"]
def samples(template, with_optional=True):
return deepcopy(template_sample_values(template.input_schema, [item.name for item in template.required_variables], [item.name for item in template.optional_variables], with_optional=with_optional))
@pytest.mark.parametrize("template", TYPED, ids=lambda item: item.contract_type)
@pytest.mark.parametrize("with_optional", [False, True])
def test_eight_templates_compile_typed_facts_and_preserve_versioned_validation(template, with_optional):
values = samples(template, with_optional)
result = compile_template_body(template, get_contract_template_body(template.id), values)
assert result.input_validation.valid
assert result.input_validation.schema_sha256 == input_schema_hash(template.input_schema)
assert "[[IF" not in result.body_content and "[[END]]" not in result.body_content
assert "{{" not in result.body_content
@pytest.mark.parametrize("template,fixture", [(template, fixture) for template in TYPED for fixture in template.input_schema.fixtures], ids=[f"{template.contract_type}-{fixture.id}" for template in TYPED for fixture in template.input_schema.fixtures])
def test_authored_template_input_fixtures(template, fixture):
values = {**samples(template), **fixture.overrides}
for name in fixture.omit:
values.pop(name, None)
validation = validate_contract_template_inputs(template, values)
assert {issue.code for issue in validation.issues} == set(fixture.expected_codes), validation.issues
assert validation.valid == fixture.expected_valid, validation.issues
if validation.valid:
compile_template_body(template, get_contract_template_body(template.id), values)
else:
with pytest.raises(TemplateRenderError) as error:
compile_template_body(template, get_contract_template_body(template.id), values)
assert error.value.issues
@pytest.mark.parametrize("template", TYPED, ids=lambda item: item.contract_type)
def test_publication_runs_typed_samples_and_negative_fixtures(template):
draft = build_template_edit_draft(template, base_body=get_contract_template_body(template.id))
validation = validate_template_edit_draft(template, draft, clause_ids=set(template.clause_ids), review_rule_ids=set(template.review_rule_ids))
assert validation.valid, validation.issues
assert validation.content_hash == draft.content_hash
def test_there_are_exactly_eight_typed_flagships():
assert {item.contract_type for item in TYPED} == {"purchase", "service", "software-saas", "commission", "nda", "labor", "lease", "works"}
@pytest.mark.parametrize("value", [True, [], {}, "NaN", "Infinity", "1e3", "1,00", "100万元", "10%", "USD100", "1.001", "-0.01"])
def test_strict_money_rejects_ambiguous_numbers(value):
schema = TemplateInputSchema.model_validate({"fields": {"金额": {"data_type": "money", "minimum": "0"}}})
assert not validate_template_inputs(schema, ["金额"], [], {"金额": value}).valid
@pytest.mark.parametrize("value", ["1000", "1,000.00", "人民币 1000元", "¥1000", "CNY1000"])
def test_currency_normalizes_without_changing_value(value):
schema = TemplateInputSchema.model_validate({"fields": {"金额": {"data_type": "money"}}})
result = validate_template_inputs(schema, ["金额"], [], {"金额": value})
assert result.valid
assert result.normalized_values["金额"].endswith(" CNY")
def test_partial_validation_tolerates_missing_not_invalid_values():
template = get_contract_template("TPL-PURCHASE-GOODS-001")
incomplete = validate_contract_template_inputs(template, {}, partial=True)
assert incomplete.valid and all(issue.severity == "warning" for issue in incomplete.issues)
malformed = validate_contract_template_inputs(template, {"签署日期": "2026-02-30"}, partial=True)
assert not malformed.valid
def test_whitespace_does_not_bypass_conditions_and_duplicate_keys_are_errors():
template = get_contract_template("TPL-PURCHASE-GOODS-001")
values = samples(template)
values.update({"采购明细模式": " 单项货物 ", "合同价款": "900"})
result = validate_contract_template_inputs(template, values)
assert any(issue.code == "RULE_EQUAL_PRODUCT" for issue in result.issues)
values[" 合同价款 "] = "1000"
assert any(issue.code == "DUPLICATE_FIELD" for issue in validate_contract_template_inputs(template, values).issues)
def test_adjustment_requires_actual_matching_amount_and_reason():
template = get_contract_template("TPL-PURCHASE-GOODS-001")
values = {**samples(template), "合同价款": "900", "价款调整金额": "-100", "价款调整说明": ""}
assert any(issue.code == "ADJUSTMENT_REASON" for issue in validate_contract_template_inputs(template, values).issues)
values["价款调整说明"] = "本次优惠100元"
result = compile_template_body(template, get_contract_template_body(template.id), values)
assert "本次优惠100元" in result.body_content and "-100 CNY" in result.body_content
def test_dates_check_leap_year_real_calendar_and_do_not_drop_suffix_facts():
schema = TemplateInputSchema.model_validate({"fields": {"日期": {"data_type": "date"}, "期限": {"data_type": "period"}}})
assert validate_template_inputs(schema, [], ["日期", "期限"], {"日期": "2028年2月29日"}).valid
assert not validate_template_inputs(schema, [], ["日期", "期限"], {"日期": "2026-02-29"}).valid
assert not validate_template_inputs(schema, [], ["日期", "期限"], {"期限": "2026-09-01 至 2026-10-01,但提前解除不退费"}).valid
@pytest.mark.parametrize("schema", [
{"fields": {"a": {"data_type": "choice", "options": []}}},
{"fields": {"a": {"minimum": "oops"}}},
{"fields": {"a": {"minimum": "NaN"}}},
{"fields": {"a": {"active_when": {"field": "missing", "value": "yes"}}}},
{"fields": {"a": {"active_when": {"field": "b", "value": "yes"}}, "b": {"active_when": {"field": "a", "value": "yes"}}}},
{"fields": {"a": {}, "b": {}, "c": {}}, "rules": [{"id": "bad-types", "kind": "equal_product", "fields": ["a", "b", "c"], "message": "bad"}]},
{"fields": {"a": {}}, "conditions": {"bad": {"field": "a", "operator": "contains_any", "value": []}}},
])
def test_invalid_rule_definitions_fail_before_runtime(schema):
with pytest.raises(ValidationError):
TemplateInputSchema.model_validate(schema)
def test_schema_changes_invalidate_draft_hash_and_old_snapshots_do_not_inherit_rules():
template = get_contract_template("TPL-PURCHASE-GOODS-001")
draft = build_template_edit_draft(template, base_body=get_contract_template_body(template.id))
snapshot = template_edit_draft_snapshot(draft)
assert apply_template_snapshot_payload(template, snapshot).input_schema == template.input_schema
historical = {**snapshot}
historical.pop("input_schema")
assert apply_template_snapshot_payload(template, historical).input_schema is None
edited = build_template_edit_draft(template, ContractTemplateEditRequest(input_schema=None), existing=draft)
assert edited.input_schema is None and edited.content_hash != draft.content_hash
assert any(item.field == "input_schema" for item in build_template_diff(template, edited, draft.body_content).changed_fields)
def test_snapshot_reuses_labels_across_required_optional_and_falls_back_for_new_names():
template = ContractTemplateItem(
id="label-test", name="label-test", version="v1", contract_type="test",
body_preview="",
required_variables=[ContractTemplateVariable(name="甲", label="第一方", required=True)],
optional_variables=[ContractTemplateVariable(name="乙", label="第二方", required=False)],
)
resolved = apply_template_snapshot_payload(template, {
"body_content": "",
"required_variables": ["乙", "新增"],
"optional_variables": ["甲"],
})
assert [(item.name, item.label, item.required) for item in resolved.required_variables] == [
("乙", "第二方", True), ("新增", "新增", True),
]
assert [(item.name, item.label, item.required) for item in resolved.optional_variables] == [
("甲", "第一方", False),
]
def test_conditional_branches_do_not_reinterpret_user_input():
template = ContractTemplateItem(id="test", name="test", version="v1", contract_type="test", body_preview="", required_variables=[ContractTemplateVariable(name="模式", label="模式"), ContractTemplateVariable(name="条款", label="条款")], input_schema=TemplateInputSchema.model_validate({"fields": {"模式": {"role": "control"}, "条款": {}}, "conditions": {"selected": {"field": "模式", "value": "是"}}}))
body = "[[IF selected]]正文:{{条款}}。[[END]]"
result = compile_template_body(template, body, {"模式": "是", "条款": "保留[[IF hidden]]原文[[END]]"})
assert "保留[[IF hidden]]原文[[END]]" in result.body_content
for invalid in ("[[IF unknown]]x[[END]]", "[[IF selected]]x", "x[[END]]", "[[IF bad id]]x[[END]]"):
with pytest.raises(TemplateRenderError):
compile_template_body(template, invalid, {"模式": "是", "条款": "条款"})
def test_additive_migration_keeps_historical_rows_and_is_idempotent(tmp_path):
path = Path(__file__).parents[1] / "migrations/versions/20260828_0075_add_template_input_schemas.py"
spec = importlib.util.spec_from_file_location("tp03_migration", path)
migration = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration)
engine = create_engine(f"sqlite:///{tmp_path / 'migration.db'}")
with engine.begin() as connection:
for table in ("template_edit_drafts", "template_published_snapshots"):
connection.execute(text(f"CREATE TABLE {table} (id TEXT PRIMARY KEY, body_content TEXT)"))
connection.execute(text(f"INSERT INTO {table} VALUES ('historical', '原正文')"))
migration.op = Operations(MigrationContext.configure(connection))
migration.upgrade()
migration.upgrade()
migration.downgrade()
for table in ("template_edit_drafts", "template_published_snapshots"):
assert "input_schema" in {column["name"] for column in inspect(connection).get_columns(table)}
assert connection.execute(text(f"SELECT body_content, input_schema FROM {table}")).one() == ("原正文", None)