|
14 | 14 | spss_source_hash, |
15 | 15 | ) |
16 | 16 | from openstatspec.transform import ( |
| 17 | + AssignOperation, |
17 | 18 | CreateVariableOperation, |
| 19 | + ExecuteOperation, |
| 20 | + Operand, |
18 | 21 | DeleteVariableOperation, |
19 | 22 | RecodeMatch, |
20 | 23 | RecodeOperation, |
@@ -680,6 +683,112 @@ def test_recode_copy_preserves_source_declared_width() -> None: |
680 | 683 | assert bound.output_schema == schema |
681 | 684 |
|
682 | 685 |
|
| 686 | +@pytest.mark.parametrize("workload", ["execute", "labels"]) |
| 687 | +def test_generic_binding_10k_has_linear_bookkeeping(workload) -> None: |
| 688 | + size = 10_000 |
| 689 | + budget = 10 * (size + size) |
| 690 | + probes = 0 |
| 691 | + |
| 692 | + class Name(str): |
| 693 | + def casefold(self): |
| 694 | + nonlocal probes |
| 695 | + probes += 1 |
| 696 | + assert probes <= budget, f"variable-name casefold probes: {probes} > {budget}" |
| 697 | + return super().casefold() |
| 698 | + |
| 699 | + class Operations(tuple): |
| 700 | + def __getitem__(self, key): |
| 701 | + nonlocal probes |
| 702 | + if isinstance(key, slice): |
| 703 | + probes += len(range(*key.indices(len(self)))) |
| 704 | + assert probes <= budget, f"operation suffix entries: {probes} > {budget}" |
| 705 | + return super().__getitem__(key) |
| 706 | + |
| 707 | + schema = _schema(*(VariableDefinition(Name(f"V{i}"), "numeric") for i in range(size))) |
| 708 | + operations = ( |
| 709 | + Operations((ExecuteOperation(),) * size) if workload == "execute" |
| 710 | + else tuple(SetVariableLabelOperation(f"v{i}", "L") for i in range(size)) |
| 711 | + ) |
| 712 | + plan = TransformationPlan(operations) |
| 713 | + probes = 0 # Count only the public bind, not input construction. |
| 714 | + bound = bind_transformation_plan(plan, schema) |
| 715 | + assert bound.plan == plan |
| 716 | + assert bound.output_schema == _schema(*( |
| 717 | + VariableDefinition(f"V{i}", "numeric", variable_label="L" if workload == "labels" else None) |
| 718 | + for i in range(size) |
| 719 | + )) |
| 720 | + |
| 721 | + |
| 722 | +@pytest.mark.parametrize("create", [ |
| 723 | + CreateVariableOperation("replacement", "numeric"), |
| 724 | + AssignOperation("replacement", "create", Operand("literal", value=TypedValue.binary64(1))), |
| 725 | +], ids=["explicit", "assign"]) |
| 726 | +def test_generic_distant_create_preserves_first_error_and_empty_schema_exception(create) -> None: |
| 727 | + schema = _schema(VariableDefinition("Only", "numeric")) |
| 728 | + prefix = (DeleteVariableOperation("oNLY"),) + (ExecuteOperation(),) * 100 |
| 729 | + plan = TransformationPlan(prefix + (create,), contract="openstatspec-transformation-plan-v0.3") |
| 730 | + assert bind_transformation_plan(plan, schema).output_schema == _schema( |
| 731 | + VariableDefinition("replacement", "numeric"), |
| 732 | + ) |
| 733 | + plan = TransformationPlan( |
| 734 | + prefix + (create, SetVariableLabelOperation("missing", "L"), create), |
| 735 | + contract=plan.contract, |
| 736 | + ) |
| 737 | + with pytest.raises(TransformationFrontendError) as caught: |
| 738 | + bind_transformation_plan(plan, schema) |
| 739 | + assert caught.value.as_dict() == { |
| 740 | + "code": "unknown_variable", |
| 741 | + "detail": "Variable 'missing' is not present in the current schema.", |
| 742 | + "details": {"variable": "missing"}, |
| 743 | + } |
| 744 | + |
| 745 | + |
| 746 | +@pytest.mark.parametrize("later", [(), (RecodeOperation( |
| 747 | + source="missing", target="replacement", target_mode="create", |
| 748 | + rules=(RecodeRule(RecodeMatch("values", (TypedValue.binary64(1),)), |
| 749 | + RecodeResult("literal", TypedValue.binary64(0))),), |
| 750 | + unmatched=RecodeResult("copy"), |
| 751 | +),)], ids=["no-create", "recode-create"]) |
| 752 | +def test_generic_last_delete_precedes_later_lookup_error(later) -> None: |
| 753 | + plan = TransformationPlan( |
| 754 | + (DeleteVariableOperation("oNLY"), SetVariableLabelOperation("missing", "L")) |
| 755 | + + (ExecuteOperation(),) * 100 + later, |
| 756 | + contract="openstatspec-transformation-plan-v0.3", |
| 757 | + ) |
| 758 | + with pytest.raises(TransformationFrontendError) as caught: |
| 759 | + bind_transformation_plan(plan, _schema(VariableDefinition("Only", "numeric"))) |
| 760 | + assert caught.value.as_dict() == { |
| 761 | + "code": "cannot_delete_last_variable", |
| 762 | + "detail": "A dataset must retain at least one variable.", |
| 763 | + "details": {"variable": "Only"}, |
| 764 | + } |
| 765 | + |
| 766 | + |
| 767 | +def test_generic_delete_recreate_appends_with_new_spelling_and_metadata() -> None: |
| 768 | + plan = TransformationPlan(( |
| 769 | + DeleteVariableOperation("oNLY"), |
| 770 | + CreateVariableOperation("ONLY", "string", 3), |
| 771 | + SetVariableLabelOperation("only", "New"), |
| 772 | + ), contract="openstatspec-transformation-plan-v0.3") |
| 773 | + bound = bind_transformation_plan(plan, _schema( |
| 774 | + VariableDefinition("Only", "numeric", variable_label="Old"), |
| 775 | + VariableDefinition("Keep", "numeric"), |
| 776 | + )) |
| 777 | + assert bound.output_schema == _schema( |
| 778 | + VariableDefinition("Keep", "numeric"), |
| 779 | + VariableDefinition("ONLY", "string", variable_label="New", declared_string_width=3), |
| 780 | + ) |
| 781 | + |
| 782 | + |
| 783 | +def test_literal_unicode_canonical_plan_vector() -> None: |
| 784 | + plan = TransformationPlan((SetVariableLabelOperation("Only", "Täpselt"),), input_alias="survey") |
| 785 | + assert plan.canonical_bytes() == ( |
| 786 | + '{"contract":"openstatspec-transformation-plan-v0.2","input_alias":"survey",' |
| 787 | + '"operations":[{"label":"Täpselt","op":"set_variable_label","variable":"Only"}]}' |
| 788 | + ).encode("utf-8") |
| 789 | + assert plan.sha256() == "2ab3bf3c6dd2c21ce0fed076d43c49d93b6cc38e3e38529a18b7563286af9e03" |
| 790 | + |
| 791 | + |
683 | 792 | def test_generic_plan_binding_validates_sequential_schema_state() -> None: |
684 | 793 | plan = TransformationPlan(( |
685 | 794 | RecodeOperation( |
|
0 commit comments