218 lines
5.3 KiB
Python
218 lines
5.3 KiB
Python
#!/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")
|