xref: /trunk/test/aoo-test/src/aootest/documents.py (revision 85d6e4e58e50c1ed01601e2737a66d9715456246)
1################################################################
2#
3#  Licensed to the Apache Software Foundation (ASF) under one
4#  or more contributor license agreements.  See the NOTICE file
5#  distributed with this work for additional information
6#  regarding copyright ownership.  The ASF licenses this file
7#  to you under the Apache License, Version 2.0 (the
8#  "License"); you may not use this file except in compliance
9#  with the License.  You may obtain a copy of the License at
10#
11#    http://www.apache.org/licenses/LICENSE-2.0
12#
13#  Unless required by applicable law or agreed to in writing,
14#  software distributed under the License is distributed on an
15#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16#  KIND, either express or implied.  See the License for the
17#  specific language governing permissions and limitations
18#  under the License.
19
20"""The documents every save mode is run over. None of them is stored in this repository: they come from the office under
21test (the templates and documents its install ships under share/), from documents the office creates itself, and from a
22directory the user names with --documents."""
23import json
24import os
25from collections import namedtuple
26from pathlib import Path
27
28from aootest import install
29from aootest.client import WorkerFailure, WorkerHang
30from aootest.paths import DATA
31
32# Source: id is the stable name used in test ids; path is None for a document the office creates (`app` names a new empty one,
33# `builder` is a (builder name, file name) pair for one the worker's generate op makes); kind is the ODF type it is saved as
34# (odt, ods, odp, odg); checks are the data/checks files with the values a reload must read back.
35Source = namedtuple("Source", "id path kind app builder checks", defaults=(None, None, ()))
36SHIPPED_KIND = {"odt": "odt", "ott": "odt", "ods": "ods", "ots": "ods", "odp": "odp", "otp": "odp", "odg": "odg", "otg": "odg"}
37GENERATED = (("writer", "odt"), ("calc", "ods"), ("impress", "odp"), ("draw", "odg"))
38# builder, file it makes, kind, checks. The para-fill-foreign builder is not here: it crashes the trunk dev app
39# (tests/odf/test_known_crashes.py), and a crash slows the office starts that follow.
40BUILT = (("page-fill", "page-fill.odt", "odt", ("page-fill/checks.json", "page-fill/bitmap.json")),
41         ("fill-others", "others-writer.odt", "odt", ("fill-others/writer.json",)),
42         ("fill-others", "others-calc.ods", "ods", ("fill-others/calc.json",)),
43         ("fill-others", "others-draw.odg", "odg", ("fill-others/draw.json",)),
44         ("fill-others", "others-impress.odp", "odp", ("fill-others/impress.json",)),
45         ("t13", "dropdown.odt", "odt", ("t13/checks-dropdown.json",)),
46         ("t13", "annot.odt", "odt", ("t13/checks-annot.json",)),
47         ("t13", "tabcolor.ods", "ods", ("t13/checks-tabcolor.json",)),
48         ("t13", "scaleto.ods", "ods", ("t13/checks-scaleto.json",)),
49         ("t13", "chart.ods", "ods", ("t13/checks-chart.json",)),
50         ("page-fill-1_3", "aoo-src.odt", "odt", ("page-fill-1_3/checks.json",)),
51         ("page-fill-1_3", "calc-src.ods", "ods", ("page-fill-1_3/checks-calc.json",)),
52         ("page-fill-1_3", "calc-drawing-page.ods", "ods", ("page-fill-1_3/checks-calc.json",)),
53         ("row-split", "row-split.odt", "odt", ("row-split/checks.json",)),
54         ("grid-snap", "grid-snap.odt", "odt", ("grid-snap/checks.json",)),
55         ("ole-text", "ole-text.odg", "odg", ("ole-text/checks.json",)),
56         ("ole-text-edge", "ole-text-edge.odg", "odg", ("ole-text-edge/checks.json",)),
57         ("chart-shapes", "chart-shapes.ods", "ods", ("chart-shapes/checks.json",)),
58         ("normal-values5b", "normal5b.ods", "ods", ("normal-values5b/checks-image.json",)),
59         ("connector-viewbox", "conn.odg", "odg", ("connector-viewbox/checks.json",)),
60         ("connector-viewbox", "conn.odp", "odp", ("connector-viewbox/checks.json",)),
61         ("para-fill", "para-fill.odt", "odt", ("para-fill/checks.json", "para-fill/bitmap.json")),
62         ("alien-attrs", "alien-writer.odt", "odt", ("alien-attrs/writer.json", "alien-attrs/writer-cell.json", "alien-attrs/writer-foreign.json")),
63         ("alien-attrs", "alien-draw.odg", "odg", ("alien-attrs/draw.json",)),
64         ("alien-attrs", "alien-calc.ods", "ods", ("alien-attrs/calc.json",)),
65         ("boolean-writer", "boolean-writer.odt", "odt", ("boolean-writer/checks.json",)),
66         ("normal-values", "normal-values.odt", "odt", ("normal-values/checks.json",)),
67         ("note-ref", "note-ref.odt", "odt", ("note-ref/checks-seq.json",)))
68# The files of the script builders (the ODF harness's large make.py scripts), generated from a run: builder<TAB>file. They carry no
69# reload checks, so they only add the save-mode items. Types the four above do not cover get their own filter in FILTERS.
70BUILT_KIND = dict(SHIPPED_KIND, odm="odm", odf="odf", odb="odb")
71BUILD_TIMEOUT, BUILD_TIMEOUTS = 300, {"base": 180, "calc": 900, "chart": 900, "draw": 900, "styles-gfx": 900, "styles-text": 900, "writer": 900}
72DEFAULT_LIMIT = 40
73SHIPPED_DIRS = ("template", "samples")
74
75
76def _tsv(name):
77    return [line.split("\t") for line in (DATA / name).read_text(encoding="utf-8").splitlines() if line and not line.startswith("#")]
78
79
80def script_excluded():
81    """(builder, file, reason) rows of data/built-excluded.tsv: files of the script builders that are not in the default run."""
82    return [tuple(r) for r in _tsv("built-excluded.tsv")]
83
84
85def script_built():
86    """(builder, file, kind, checks) rows for the files in data/built-files.tsv, without the excluded ones."""
87    left_out = {(b, f) for b, f, _ in script_excluded()}
88    return [(b, f, BUILT_KIND[f.rsplit(".", 1)[1]], ()) for b, f in _tsv("built-files.tsv") if (b, f) not in left_out]
89
90
91def share_dir(layout):
92    """share/ sits next to program/ in every layout (macOS: Contents/share)."""
93    return layout.program.parent / "share"
94
95
96def _walk(base):
97    for root, dirs, files in os.walk(base):
98        dirs.sort()
99        for name in sorted(files):
100            ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
101            if ext in SHIPPED_KIND:
102                yield Path(root) / name, ext
103
104
105def _interleave(groups):
106    """Takes one item from each group in turn, so a limit still covers every type and many directories."""
107    iters = [iter(g) for g in groups if g]
108    while iters:
109        for it in list(iters):
110            try:
111                yield next(it)
112            except StopIteration:
113                iters.remove(it)
114
115
116def _spread(items):
117    """Orders (id, path, ext) items so that consecutive ones come from different directories: one from each in turn."""
118    by_dir = {}
119    for item in items:
120        by_dir.setdefault(item[1].parent, []).append(item)
121    return list(_interleave([by_dir[d] for d in sorted(by_dir)]))
122
123
124def shipped(layout, limit=DEFAULT_LIMIT):
125    """Documents and templates under share/template and share/samples of the install, at most `limit`: types and directories
126    are interleaved, so the same install always gives the same list."""
127    share = share_dir(layout)
128    groups = {}
129    for sub in SHIPPED_DIRS:
130        if (share / sub).is_dir():
131            for path, ext in _walk(share / sub):
132                groups.setdefault(ext, []).append(("shipped/" + path.relative_to(share).as_posix(), path, ext))
133    ordered = _interleave([_spread(groups[e]) for e in sorted(groups)])
134    return [Source(i, p, SHIPPED_KIND[e]) for i, p, e in list(ordered)[:limit]]
135
136
137def generated():
138    """One small document per application, and the documents the worker builds, all created by the office under test."""
139    return ([Source("generated/" + app, None, kind, app) for app, kind in GENERATED] +
140            [Source("built/%s/%s" % (b, f), None, kind, None, (b, f), checks) for b, f, kind, checks in BUILT + tuple(script_built())])
141
142
143def load_checks(name):
144    """The list of {"name", "expr", "expect"} in data/checks/<name>."""
145    return json.load(open(DATA / "checks" / name, encoding="utf-8"))
146
147
148def extra(directory):
149    """Every ODF document under a directory named by the user."""
150    if not directory:
151        return []
152    base = Path(directory)
153    return [Source("documents/" + p.relative_to(base).as_posix(), p, SHIPPED_KIND[e]) for p, e in _walk(base)]
154
155
156def collect(office=None, limit=DEFAULT_LIMIT, documents=None):
157    """The sources for a run: [] when no usable office is named (the tests then skip with a reason)."""
158    if not office:
159        return []
160    try:
161        layout = install.discover(office)
162    except install.LayoutError:
163        return []
164    return generated() + shipped(layout, limit) + extra(documents)
165
166
167def out_name(source):
168    """Name of the saved copy: the id with separators and dots flattened (so x.ott and x.odt stay apart), in the extension of
169    the ODF type it is saved as."""
170    return source.id.replace("/", "__").replace("\\", "__").replace(".", "_") + "." + source.kind
171
172
173def from_config(config, environ=None):
174    """The sources for the pytest run `config` describes (--office or AOO_OFFICE, --shipped-limit, --documents)."""
175    environ = os.environ if environ is None else environ
176    return collect(config.getoption("--office") or environ.get("AOO_OFFICE"), config.getoption("--shipped-limit"), config.getoption("--documents"))
177
178
179class Prepared:
180    """The file of each source, made once: a shipped or user document is used where it is, anything else is created by `session`
181    in `directory`. A script builder runs in the office that `own_office(builder)` returns, and that office is closed afterwards:
182    those scripts fail or stall depending on what the office did before."""
183
184    def __init__(self, session, directory, own_office=None):
185        self.session, self.dir, self._made, self._built, self._failed = session, Path(directory), {}, set(), {}
186        self._outdir = {}
187        self.own_office = own_office
188        self.script_builders = {b for b, _ in _tsv("built-files.tsv")}
189
190    def file(self, source):
191        """The file of `source`. A source that could not be made raises the same error every time, without trying again."""
192        if source.path is not None:
193            return str(source.path)
194        if source.id in self._failed:
195            raise self._failed[source.id]
196        if source.id not in self._made:
197            try:
198                self._make(source)
199            except WorkerFailure as e:
200                self._failed[source.id] = e
201                raise
202        return self._made[source.id]
203
204    def _build(self, name):
205        """Runs builder `name`. A script builder that stalls is run once more in a fresh office and a directory of its own: the base
206        script stalled in its first variant in two of about six runs, and left a lock file behind."""
207        scripted = self.own_office and name in self.script_builders
208        for attempt in (1, 2):
209            outdir = self.dir / name / ("out" if attempt == 1 else "out-retry")
210            own = self.own_office(name) if scripted else None
211            try:
212                (own or self.session).call("generate", {"builder": name, "outdir": str(outdir)},
213                                           timeout=BUILD_TIMEOUTS.get(name, BUILD_TIMEOUT))
214                break
215            except WorkerHang as e:
216                if not scripted or attempt == 2:
217                    self._failed[name] = e
218                    raise
219            except WorkerFailure as e:
220                self._failed[name] = e
221                raise
222            finally:
223                if own:
224                    own.close()
225        self._outdir[name] = outdir
226        self._built.add(name)
227
228    def _make(self, source):
229        if source.builder:
230            name, filename = source.builder
231            if name in self._failed:
232                raise self._failed[name]
233            if name not in self._built:
234                self._build(name)
235            self._made[source.id] = str(self._outdir[name] / filename)
236        else:
237            path = self.dir / ("%s.%s" % (source.app, source.kind))
238            self.dir.mkdir(parents=True, exist_ok=True)
239            self.session.call("new_and_store", {"app": source.app, "dst": str(path), "filter": FILTERS[source.kind],
240                                                "text": "Hello %s" % source.app}, timeout=180)
241            self._made[source.id] = str(path)
242
243
244FILTERS = {"odt": "writer8", "ods": "calc8", "odp": "impress8", "odg": "draw8", "odm": "writerglobal8", "odf": "math8",
245           "odb": "StarOffice XML (Base)"}
246