Files

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)