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