xref: /trunk/test/odf-validate/calcval.py (revision 0e16a31d78a652c56c5c0fc79c752f48b9048df9)
1*0e16a31dSJim Jagielski#**************************************************************
2*0e16a31dSJim Jagielski#
3*0e16a31dSJim Jagielski#  Licensed to the Apache Software Foundation (ASF) under one
4*0e16a31dSJim Jagielski#  or more contributor license agreements.  See the NOTICE file
5*0e16a31dSJim Jagielski#  distributed with this work for additional information
6*0e16a31dSJim Jagielski#  regarding copyright ownership.  The ASF licenses this file
7*0e16a31dSJim Jagielski#  to you under the Apache License, Version 2.0 (the
8*0e16a31dSJim Jagielski#  "License"); you may not use this file except in compliance
9*0e16a31dSJim Jagielski#  with the License.  You may obtain a copy of the License at
10*0e16a31dSJim Jagielski#
11*0e16a31dSJim Jagielski#    http://www.apache.org/licenses/LICENSE-2.0
12*0e16a31dSJim Jagielski#
13*0e16a31dSJim Jagielski#  Unless required by applicable law or agreed to in writing,
14*0e16a31dSJim Jagielski#  software distributed under the License is distributed on an
15*0e16a31dSJim Jagielski#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16*0e16a31dSJim Jagielski#  KIND, either express or implied.  See the License for the
17*0e16a31dSJim Jagielski#  specific language governing permissions and limitations
18*0e16a31dSJim Jagielski#  under the License.
19*0e16a31dSJim Jagielski#
20*0e16a31dSJim Jagielski#**************************************************************
21*0e16a31dSJim Jagielski# usage: runpy.sh calcval.py <port> <cases.tsv> <out.json>
22*0e16a31dSJim Jagielski#        python3 -I calcval.py --compare old.json new.json
23*0e16a31dSJim Jagielski#
24*0e16a31dSJim Jagielski# Evaluates the formulas of a cases TSV (see calcval-cases.tsv for the columns
25*0e16a31dSJim Jagielski# and the data area) in a new Calc document and records, per case, the compile
26*0e16a31dSJim Jagielski# and evaluation result of the current build, and whether it equals the value
27*0e16a31dSJim Jagielski# that ODF 1.3 Part 4 requires. --compare lists the cases whose result changed.
28*0e16a31dSJim Jagielski
29*0e16a31dSJim Jagielskiimport json, math, re, sys
30*0e16a31dSJim Jagielski
31*0e16a31dSJim Jagielski
32*0e16a31dSJim Jagielskidef parse_mode(mode):
33*0e16a31dSJim Jagielski    m = re.fullmatch(r"cell(?:@(\d+))?", mode)
34*0e16a31dSJim Jagielski    if m:
35*0e16a31dSJim Jagielski        return "cell", 1, 1, int(m.group(1) or 1)
36*0e16a31dSJim Jagielski    m = re.fullmatch(r"array(?::(\d+)x(\d+))?", mode)
37*0e16a31dSJim Jagielski    if m:
38*0e16a31dSJim Jagielski        return "array", int(m.group(1) or 1), int(m.group(2) or 1), 1
39*0e16a31dSJim Jagielski    raise ValueError("bad mode %r" % mode)
40*0e16a31dSJim Jagielski
41*0e16a31dSJim Jagielski
42*0e16a31dSJim Jagielskidef read_cases(path):
43*0e16a31dSJim Jagielski    cases = []
44*0e16a31dSJim Jagielski    for ln, line in enumerate(open(path, encoding="utf-8"), 1):
45*0e16a31dSJim Jagielski        line = line.rstrip("\n")
46*0e16a31dSJim Jagielski        if not line or line.startswith("#"):
47*0e16a31dSJim Jagielski            continue
48*0e16a31dSJim Jagielski        f = line.split("\t")
49*0e16a31dSJim Jagielski        if len(f) != 5:
50*0e16a31dSJim Jagielski            raise SystemExit("%s:%d: expected 5 tab separated columns, got %d" % (path, ln, len(f)))
51*0e16a31dSJim Jagielski        cases.append(dict(zip(("id", "formula", "mode", "expected", "note"), f)))
52*0e16a31dSJim Jagielski    ids = [c["id"] for c in cases]
53*0e16a31dSJim Jagielski    if len(set(ids)) != len(ids):
54*0e16a31dSJim Jagielski        raise SystemExit("duplicate case ids in " + path)
55*0e16a31dSJim Jagielski    return cases
56*0e16a31dSJim Jagielski
57*0e16a31dSJim Jagielski
58*0e16a31dSJim Jagielski# Analysis add-in functions are not known by their English names to setFormula;
59*0e16a31dSJim Jagielski# the case formulas keep the ODF names and are rewritten to the programmatic names.
60*0e16a31dSJim JagielskiADDIN = {"RECEIVED": "getReceived", "BESSELK": "getBesselk", "BESSELY": "getBessely"}
61*0e16a31dSJim Jagielski
62*0e16a31dSJim Jagielski
63*0e16a31dSJim Jagielskidef api_formula(f):
64*0e16a31dSJim Jagielski    for name, prog in ADDIN.items():
65*0e16a31dSJim Jagielski        f = re.sub(r"(?<![A-Za-z0-9_.])%s\(" % name, "com.sun.star.sheet.addin.Analysis.%s(" % prog, f)
66*0e16a31dSJim Jagielski    return f
67*0e16a31dSJim Jagielski
68*0e16a31dSJim Jagielski
69*0e16a31dSJim JagielskiNOT_AVAILABLE = 32767
70*0e16a31dSJim Jagielski
71*0e16a31dSJim Jagielski
72*0e16a31dSJim Jagielskidef elem_match(exp, cell):
73*0e16a31dSJim Jagielski    # cell: dict with rt (CellContentType name of the formula result; errors report VALUE), err, str, val
74*0e16a31dSJim Jagielski    if exp == "#N/A":
75*0e16a31dSJim Jagielski        return cell["err"] == NOT_AVAILABLE
76*0e16a31dSJim Jagielski    if exp == "#ERR":
77*0e16a31dSJim Jagielski        return cell["err"] != 0
78*0e16a31dSJim Jagielski    if cell["err"] != 0:
79*0e16a31dSJim Jagielski        return False
80*0e16a31dSJim Jagielski    if exp.startswith("s:"):
81*0e16a31dSJim Jagielski        return cell["rt"] == "TEXT" and cell["str"] == exp[2:]
82*0e16a31dSJim Jagielski    tol = 1e-9
83*0e16a31dSJim Jagielski    if exp.startswith("~"):
84*0e16a31dSJim Jagielski        exp, tol = exp[1:], 1e-6
85*0e16a31dSJim Jagielski    e = float(exp)
86*0e16a31dSJim Jagielski    return cell["rt"] == "VALUE" and isinstance(cell["val"], float) and abs(cell["val"] - e) <= tol * max(1.0, abs(e))
87*0e16a31dSJim Jagielski
88*0e16a31dSJim Jagielski
89*0e16a31dSJim Jagielskidef judge(expected, cells):
90*0e16a31dSJim Jagielski    if expected == "-":
91*0e16a31dSJim Jagielski        return "record"
92*0e16a31dSJim Jagielski    exps = expected.split("|")
93*0e16a31dSJim Jagielski    if len(exps) != len(cells):
94*0e16a31dSJim Jagielski        return "mismatch"
95*0e16a31dSJim Jagielski    return "match" if all(elem_match(e, c) for e, c in zip(exps, cells)) else "mismatch"
96*0e16a31dSJim Jagielski
97*0e16a31dSJim Jagielski
98*0e16a31dSJim Jagielskidef num(v):
99*0e16a31dSJim Jagielski    return v if math.isfinite(v) else repr(v)
100*0e16a31dSJim Jagielski
101*0e16a31dSJim Jagielski
102*0e16a31dSJim Jagielskidef cell_result(cell):
103*0e16a31dSJim Jagielski    try:
104*0e16a31dSJim Jagielski        rt = cell.getPropertyValue("FormulaResultType").value
105*0e16a31dSJim Jagielski    except Exception:
106*0e16a31dSJim Jagielski        rt = None
107*0e16a31dSJim Jagielski    return {"rt": rt, "err": cell.getError(), "str": cell.getString(), "val": num(float(cell.getValue()))}
108*0e16a31dSJim Jagielski
109*0e16a31dSJim Jagielski
110*0e16a31dSJim Jagielskidef setup_data(doc):
111*0e16a31dSJim Jagielski    sheets = doc.getSheets()
112*0e16a31dSJim Jagielski    sheets.insertNewByName("Other", 1)
113*0e16a31dSJim Jagielski    sh = sheets.getByIndex(0)
114*0e16a31dSJim Jagielski
115*0e16a31dSJim Jagielski    def put(addr, vals):
116*0e16a31dSJim Jagielski        # vals: list of rows; None leaves the cell empty; str is text
117*0e16a31dSJim Jagielski        a0 = sh.getCellRangeByName(addr).getRangeAddress()
118*0e16a31dSJim Jagielski        for r, row in enumerate(vals):
119*0e16a31dSJim Jagielski            for c, v in enumerate(row):
120*0e16a31dSJim Jagielski                if v is None:
121*0e16a31dSJim Jagielski                    continue
122*0e16a31dSJim Jagielski                cell = sh.getCellByPosition(a0.StartColumn + c, a0.StartRow + r)
123*0e16a31dSJim Jagielski                if isinstance(v, str):
124*0e16a31dSJim Jagielski                    cell.setString(v)
125*0e16a31dSJim Jagielski                else:
126*0e16a31dSJim Jagielski                    cell.setValue(v)
127*0e16a31dSJim Jagielski
128*0e16a31dSJim Jagielski    put("A1", [[v] for v in "yxxyxyxxy"])
129*0e16a31dSJim Jagielski    put("B1", [[v] for v in "yxyyyyxyx"])
130*0e16a31dSJim Jagielski    put("D1", [[10 * i, 11 * i, 101 * i] for i in range(1, 6)])
131*0e16a31dSJim Jagielski    put("H1", [[10 * i for i in range(1, 6)], [11 * i for i in range(1, 6)], [101 * i for i in range(1, 6)]])
132*0e16a31dSJim Jagielski    put("N1", [[v] for v in (3, 5, 8, 9, 13, 14)])
133*0e16a31dSJim Jagielski    put("O1", [[v] for v in (1, 2, 3, 4, 5, 6)])
134*0e16a31dSJim Jagielski    put("P1", [[v] for v in (2, 4, 7, 15, 30, 58)])
135*0e16a31dSJim Jagielski    put("R1", [["Name", "Age", "Score"], ["A", 20, 5], ["B", 30, "n/a"], ["C", 40, 15], ["D", 50, None], ["E", 60, 25]])
136*0e16a31dSJim Jagielski    put("V1", [["Age"], [">=30"]])
137*0e16a31dSJim Jagielski    put("W1", [["Age"], [None]])
138*0e16a31dSJim Jagielski    put("Y1", [["Name"], ["A"]])
139*0e16a31dSJim Jagielski    put("AH1", [[v] for v in (6, 6, 6, 6, 3, 2, 1, 2, 6, 4, 2)])
140*0e16a31dSJim Jagielski    put("AI1", [[v] for v in (1, 3, 5)])
141*0e16a31dSJim Jagielski    put("AJ1", [[v] for v in (2, 4, 7, 9)])
142*0e16a31dSJim Jagielski    put("AK1", [[v] for v in (2, 4, 7, 9)])
143*0e16a31dSJim Jagielski    return sh
144*0e16a31dSJim Jagielski
145*0e16a31dSJim Jagielski
146*0e16a31dSJim Jagielskidef office_version(ctx):
147*0e16a31dSJim Jagielski    try:
148*0e16a31dSJim Jagielski        from unohelp import pv
149*0e16a31dSJim Jagielski        cp = ctx.ServiceManager.createInstanceWithContext("com.sun.star.configuration.ConfigurationProvider", ctx)
150*0e16a31dSJim Jagielski        ca = cp.createInstanceWithArguments("com.sun.star.configuration.ConfigurationAccess",
151*0e16a31dSJim Jagielski                                            (pv("nodepath", "/org.openoffice.Setup/Product"),))
152*0e16a31dSJim Jagielski        return "%s %s" % (ca.getByName("ooName"), ca.getByName("ooSetupVersion"))
153*0e16a31dSJim Jagielski    except Exception as e:
154*0e16a31dSJim Jagielski        return "unknown (%s)" % e
155*0e16a31dSJim Jagielski
156*0e16a31dSJim Jagielski
157*0e16a31dSJim Jagielskidef run(port, cases_path, out_path):
158*0e16a31dSJim Jagielski    from unohelp import connect, desktop, pv
159*0e16a31dSJim Jagielski    cases = read_cases(cases_path)
160*0e16a31dSJim Jagielski    ctx = connect(port)
161*0e16a31dSJim Jagielski    desk = desktop(ctx)
162*0e16a31dSJim Jagielski    doc = desk.loadComponentFromURL("private:factory/scalc", "_blank", 0, (pv("Hidden", True),))
163*0e16a31dSJim Jagielski    if doc is None:
164*0e16a31dSJim Jagielski        raise SystemExit("could not create a Calc document")
165*0e16a31dSJim Jagielski    results = []
166*0e16a31dSJim Jagielski    aborted = None
167*0e16a31dSJim Jagielski    try:
168*0e16a31dSJim Jagielski        sh = setup_data(doc)
169*0e16a31dSJim Jagielski        scratch = sh.getCellRangeByName("AA1:AF12")
170*0e16a31dSJim Jagielski        for case in cases:
171*0e16a31dSJim Jagielski            kind, cols, rows, row = parse_mode(case["mode"])
172*0e16a31dSJim Jagielski            rec = dict(case)
173*0e16a31dSJim Jagielski            try:
174*0e16a31dSJim Jagielski                formula = api_formula(case["formula"])
175*0e16a31dSJim Jagielski                if kind == "cell":
176*0e16a31dSJim Jagielski                    cell = sh.getCellByPosition(26, row - 1)
177*0e16a31dSJim Jagielski                    cell.setFormula(formula)
178*0e16a31dSJim Jagielski                    cells = [cell]
179*0e16a31dSJim Jagielski                else:
180*0e16a31dSJim Jagielski                    rng = sh.getCellRangeByPosition(26, 0, 26 + cols - 1, rows - 1)
181*0e16a31dSJim Jagielski                    rng.setArrayFormula(formula)
182*0e16a31dSJim Jagielski                    cells = [sh.getCellByPosition(26 + c, r) for r in range(rows) for c in range(cols)]
183*0e16a31dSJim Jagielski                doc.calculateAll()
184*0e16a31dSJim Jagielski                rec["formula_used"] = formula
185*0e16a31dSJim Jagielski                res = [cell_result(c) for c in cells]
186*0e16a31dSJim Jagielski                rec["readback"] = cells[0].getFormula()
187*0e16a31dSJim Jagielski                rec["result"] = res
188*0e16a31dSJim Jagielski                rec["status"] = judge(case["expected"], res)
189*0e16a31dSJim Jagielski            except Exception as e:
190*0e16a31dSJim Jagielski                rec["exception"] = "%s: %s" % (type(e).__name__, e)
191*0e16a31dSJim Jagielski                rec["status"] = "exception"
192*0e16a31dSJim Jagielski                if "Disposed" in type(e).__name__ or "connect" in str(e).lower():
193*0e16a31dSJim Jagielski                    aborted = case["id"]
194*0e16a31dSJim Jagielski            results.append(rec)
195*0e16a31dSJim Jagielski            try:
196*0e16a31dSJim Jagielski                scratch.clearContents(1 | 2 | 4 | 8 | 16 | 32)
197*0e16a31dSJim Jagielski            except Exception as e:
198*0e16a31dSJim Jagielski                aborted = aborted or case["id"]
199*0e16a31dSJim Jagielski                rec["exception"] = rec.get("exception", "") + " clear: %s" % e
200*0e16a31dSJim Jagielski            if aborted:
201*0e16a31dSJim Jagielski                break
202*0e16a31dSJim Jagielski    finally:
203*0e16a31dSJim Jagielski        try:
204*0e16a31dSJim Jagielski            doc.close(True)
205*0e16a31dSJim Jagielski        except Exception:
206*0e16a31dSJim Jagielski            pass
207*0e16a31dSJim Jagielski    out = {"office": office_version(ctx) if not aborted else "unknown", "cases_file": cases_path,
208*0e16a31dSJim Jagielski           "aborted_at": aborted, "cases": results}
209*0e16a31dSJim Jagielski    with open(out_path, "w", encoding="utf-8") as f:
210*0e16a31dSJim Jagielski        json.dump(out, f, indent=1, ensure_ascii=False)
211*0e16a31dSJim Jagielski        f.write("\n")
212*0e16a31dSJim Jagielski    n = len(results)
213*0e16a31dSJim Jagielski    st = {}
214*0e16a31dSJim Jagielski    for r in results:
215*0e16a31dSJim Jagielski        st[r["status"]] = st.get(r["status"], 0) + 1
216*0e16a31dSJim Jagielski    nerr = sum(1 for r in results if r["status"] != "exception" and any(c["err"] for c in r["result"]))
217*0e16a31dSJim Jagielski    print("cases run %d of %d: %s; cases with an error value in the result: %d%s" % (
218*0e16a31dSJim Jagielski        n, len(cases), ", ".join("%s=%d" % kv for kv in sorted(st.items())), nerr,
219*0e16a31dSJim Jagielski        "; ABORTED at " + aborted if aborted else ""))
220*0e16a31dSJim Jagielski    for r in results:
221*0e16a31dSJim Jagielski        if r["status"] == "mismatch":
222*0e16a31dSJim Jagielski            print("MISMATCH %-28s %-12s expected %s got %s" % (r["id"], r["mode"], r["expected"], summary(r)))
223*0e16a31dSJim Jagielski    return 1 if aborted else 0
224*0e16a31dSJim Jagielski
225*0e16a31dSJim Jagielski
226*0e16a31dSJim Jagielskidef summary(rec):
227*0e16a31dSJim Jagielski    if "result" not in rec:
228*0e16a31dSJim Jagielski        return rec.get("exception", "?")
229*0e16a31dSJim Jagielski    return " | ".join("err=%d" % c["err"] if c["err"] else
230*0e16a31dSJim Jagielski                      ("%r" % c["val"] if c["rt"] == "VALUE" else "%r" % c["str"]) for c in rec["result"])
231*0e16a31dSJim Jagielski
232*0e16a31dSJim Jagielski
233*0e16a31dSJim Jagielskidef same(a, b):
234*0e16a31dSJim Jagielski    if "result" not in a or "result" not in b:
235*0e16a31dSJim Jagielski        return a.get("exception") == b.get("exception") and ("result" in a) == ("result" in b)
236*0e16a31dSJim Jagielski    if a["readback"] != b["readback"] or len(a["result"]) != len(b["result"]):
237*0e16a31dSJim Jagielski        return False
238*0e16a31dSJim Jagielski    for x, y in zip(a["result"], b["result"]):
239*0e16a31dSJim Jagielski        if (x["rt"], x["err"], x["str"]) != (y["rt"], y["err"], y["str"]):
240*0e16a31dSJim Jagielski            return False
241*0e16a31dSJim Jagielski        vx, vy = x["val"], y["val"]
242*0e16a31dSJim Jagielski        if isinstance(vx, float) and isinstance(vy, float):
243*0e16a31dSJim Jagielski            if abs(vx - vy) > 1e-12 * max(1.0, abs(vx)):
244*0e16a31dSJim Jagielski                return False
245*0e16a31dSJim Jagielski        elif vx != vy:
246*0e16a31dSJim Jagielski            return False
247*0e16a31dSJim Jagielski    return True
248*0e16a31dSJim Jagielski
249*0e16a31dSJim Jagielski
250*0e16a31dSJim Jagielskidef compare(old_path, new_path):
251*0e16a31dSJim Jagielski    old = {r["id"]: r for r in json.load(open(old_path, encoding="utf-8"))["cases"]}
252*0e16a31dSJim Jagielski    new = {r["id"]: r for r in json.load(open(new_path, encoding="utf-8"))["cases"]}
253*0e16a31dSJim Jagielski    changed = 0
254*0e16a31dSJim Jagielski    for i in old:
255*0e16a31dSJim Jagielski        if i not in new:
256*0e16a31dSJim Jagielski            print("REMOVED %s" % i); changed += 1
257*0e16a31dSJim Jagielski        elif not same(old[i], new[i]):
258*0e16a31dSJim Jagielski            changed += 1
259*0e16a31dSJim Jagielski            print("CHANGED %-28s %-12s %s\n    old: %s [%s]\n    new: %s [%s]" % (
260*0e16a31dSJim Jagielski                i, new[i]["mode"], new[i]["formula"], summary(old[i]), old[i]["status"],
261*0e16a31dSJim Jagielski                summary(new[i]), new[i]["status"]))
262*0e16a31dSJim Jagielski    for i in new:
263*0e16a31dSJim Jagielski        if i not in old:
264*0e16a31dSJim Jagielski            print("ADDED   %s" % i); changed += 1
265*0e16a31dSJim Jagielski    print("%d cases compared, %d changed" % (len(set(old) & set(new)), changed))
266*0e16a31dSJim Jagielski    return 1 if changed else 0
267*0e16a31dSJim Jagielski
268*0e16a31dSJim Jagielski
269*0e16a31dSJim Jagielskidef main(argv):
270*0e16a31dSJim Jagielski    if len(argv) == 4 and argv[1] == "--compare":
271*0e16a31dSJim Jagielski        return compare(argv[2], argv[3])
272*0e16a31dSJim Jagielski    if len(argv) == 4:
273*0e16a31dSJim Jagielski        return run(int(argv[1]), argv[2], argv[3])
274*0e16a31dSJim Jagielski    print("usage: runpy.sh calcval.py <port> <cases.tsv> <out.json>\n"
275*0e16a31dSJim Jagielski          "       python3 -I calcval.py --compare old.json new.json", file=sys.stderr)
276*0e16a31dSJim Jagielski    return 2
277*0e16a31dSJim Jagielski
278*0e16a31dSJim Jagielski
279*0e16a31dSJim Jagielskisys.exit(main(sys.argv))
280