#!/usr/bin/env python3 """Unit tests for the script-local function lift (matlab_lint.split_local_functions). Pure Python, no container and no network: python3 tests/test_local_functions.py This one is a unit test rather than a smoke case because it is a source transform on the user's own code. The smoke suite can only tell us that a script ran; these cases are about the files it must NOT touch, and about the line numbers staying where the model put them, which is what an error message points at. """ import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app")) from owuitools.matlab_lint import split_local_functions # noqa: E402 FAILS: list[str] = [] def check(label: str, condition: bool, detail: str = "") -> None: if condition: print(f"ok {label}") else: FAILS.append(f"{label}: {detail}") print(f"FAIL {label}\n {detail}") def lifted(label: str, code: str, names: list[str], script_must_not: str | None = "function") -> None: r = split_local_functions(code) check(f"{label}: lifts {names}", [f.name for f in r.functions] == names, f"got {[f.name for f in r.functions]}, bailed={r.bailed!r}") check(f"{label}: line count preserved", len(r.script.split("\n")) == len(code.split("\n")), f"{len(r.script.split(chr(10)))} vs {len(code.split(chr(10)))}") if names and script_must_not: check(f"{label}: definitions gone from the script", script_must_not not in r.script, r.script) def bails(label: str, code: str) -> None: r = split_local_functions(code) check(f"{label}: left alone", r.functions == () and r.script == code, f"lifted {[f.name for f in r.functions]}, bailed={r.bailed!r}") # --- the case this exists for ------------------------------------------- lifted("plain script", """x = 3; disp(double_it(x)); function y = double_it(v) y = 2 * v; end """, ["double_it"]) lifted("two functions", """disp(a(1) + b(2)); function y = a(v) y = v; end function y = b(v) y = v * 10; end """, ["a", "b"]) lifted("output list and no args", """disp(pair()); function [p, q] = pair() p = 1; q = 2; end """, ["pair"]) lifted("no parentheses in signature", """disp(answer); function answer = answer answer = 42; end """, ["answer"]) # --- things that must not confuse the block scan ------------------------ lifted("one-line if/end above", """v = [1 2 3]; if numel(v) > 2, disp(tail(v)); end function y = tail(x) y = x(end); end """, ["tail"]) lifted("end used as an index", """v = [1 2 3]; disp(v(end)); disp(last(v)); function y = last(x) s = struct('f', {1, 2}); y = x(end) + numel(s); end """, ["last"]) lifted("switch and try inside the function", """disp(pick(2)); function y = pick(n) switch n case 1 y = 'one'; otherwise try y = 'other'; catch y = ''; end end end """, ["pick"]) # The script keeps its own text verbatim, so the word survives inside the # string and the comment - what must not survive is a real definition. lifted("the word function inside a string", """s = 'function y = fake(x)'; disp(real_one(s)); function y = real_one(x) y = numel(x); end """, ["real_one"], script_must_not=None) lifted("a comment that looks like a definition", """% function y = commented(x) disp(genuine(1)); function y = genuine(x) y = x; end """, ["genuine"], script_must_not=None) # --- files it must refuse to touch -------------------------------------- bails("function file", """function y = only(x) y = x; end """) bails("code after the definitions", """disp(f(1)); function y = f(x) y = x; end disp('trailing statement'); """) bails("classdef", """classdef Thing properties v = 1; end end """) bails("nested function", """disp(outer(1)); function y = outer(x) function z = inner(w) z = w + 1; end y = inner(x); end """) bails("duplicate names", """disp(f(1)); function y = f(x) y = x; end function y = f(x) y = x + 1; end """) bails("unbalanced end", """disp(1); end """) bails("no functions at all", """x = 1; disp(x); """) # A signature split across lines still names its function on the first # line, and the whole block - continuation and all - is lifted verbatim. lifted("continued signature", """disp(f(1)); function y = f(x, ... z) y = x; end """, ["f"]) # One that continues BEFORE the name has nothing to read, and is refused. bails("name on the continued line", """disp(f(1)); function y = ... f(x) y = x; end """) # --- the blanking must be exact ----------------------------------------- _code = "a = 1;\ndisp(f(a));\nfunction y = f(x)\n y = x;\nend\n" _r = split_local_functions(_code) check("script keeps its first lines verbatim", _r.script.split("\n")[:2] == ["a = 1;", "disp(f(a));"], _r.script.split("\n")[:2]) check("lifted text is the original source", _r.functions[0].text == "function y = f(x)\n y = x;\nend\n", repr(_r.functions[0].text)) check("lifted function remembers the line it came from", _r.functions[0].line == 3, _r.functions[0].line) check("tail is blank, not deleted", _r.script.split("\n")[2:] == ["", "", "", ""], repr(_r.script.split("\n")[2:])) print() print("=" * 60) if FAILS: print(f"FAILED {len(FAILS)}") for f in FAILS: print(" -", f) sys.exit(1) print("all local-function cases pass")