diff --git a/CHANGELOG.md b/CHANGELOG.md index d293156..c379426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ - An export now names its images by the file names `media/` holds them under. Every markdown image reference a question holds — in the question's text, a part's text, a worked solution, a final answer or an answer box's wording — is rewritten to the file name the image was carried under, so a document writing `![](figures/train.png)` exports as `![](train.png)` beside `media/train.png`, and Lambda Feedback finds the figure where it looks for one. A file two questions use is carried once. Where two different files have the same name, in2lambda names the second as Lambda Feedback's own exports name an image, `question_001__0001.png`. Reading an export back is unchanged, because an export already names its images this way. - A draft can freeze more than one document, which is how a sheet written as a question file and a separate solutions file is drafted. `in2lambda source add questions.docx solutions.docx` freezes them as source 1 and source 2 of the one `questions.draft.json`, and `in2lambda source add solutions.docx --draft questions.docx` adds a file to an existing draft as its next source. Every block id and line range of a source after the first carries that source's number — `2/b3`, `2/s10:14` — and `1/b3` names the block `b3` names. A field quoted from a source records which source it came from, so that the same line number in two documents is two places. `in2lambda source show` prints each source under its number and its name. `in2lambda spec run` runs the spec over every source: the first source is laid out as the spec's `layout` says, and in any source after it the `question` selector picks out the marker written above each question's solutions while every other match is a solution, paired onto the questions and parts of the first source as `in2lambda convert -a` pairs an answers file. A draft now holds `sources`, a list of `{source, hash, blocks}` in the order they were frozen, in place of those three keys at the top level, so a draft written before this release is refused as a draft in2lambda did not write; `in2lambda source add --start-over` freezes the document again. Four changes to the Python API break existing scripts: `in2lambda.source.add` takes a list of files and the draft to freeze them into; `in2lambda.source.frozen` returns the markdown of every source and `in2lambda.draft.apply` takes the markdown of every source, in place of one; `in2lambda.spec.fields` takes one `(blocks, markdown)` pair per source in place of its `elements` and `markdown` arguments; and `in2lambda.spec.Field` carries the number of the source its ranges are lines of, which every caller constructing a `Field` must pass. - Every command that works on a draft takes `--draft`, naming either the draft or the source it was frozen from: `in2lambda source show`, each `in2lambda draft` command, `in2lambda spec run`, `in2lambda validate`, `in2lambda build` and `in2lambda render`. Left off, each command uses the one draft in the current directory, and where the directory holds more than one draft, the command is refused, naming them. `in2lambda spec run` resolves its SPEC from the draft's directory. The Python functions behind those commands take the draft's path in place of a directory: `in2lambda.source.frozen`, `in2lambda.source.show`, `in2lambda.draft.execute`, `in2lambda.draft.replay`, `in2lambda.draft.spec_command`, `in2lambda.draft.report.validate`, `in2lambda.draft.export.build` and `in2lambda.draft.export.render`. `in2lambda.source.draft_of` returns the path of a document's draft, and `in2lambda.source.find` resolves `--draft` for the command line. +- `in2lambda source add` records the blocks nested inside a block as well as the top-level blocks, so that a sheet written as one list — each question an item, each part an item of a list inside that item — holds a block per part for a spec to select. A list item and a fenced div, which is what pandoc writes a `\begin{solution}` environment as, are the two blocks that hold blocks of their own. A list item or div holding a single element other than a list is one block. A nested block's id is the id of the block holding it and a number, such as `b3.1` and `b3.2.1`, and the block carries a `depth`: 1 for a top-level element, 2 for a block inside one. A block spans the blocks nested inside it. A fenced div spans the lines its content is written on, and not the `:::` lines pandoc wrote around it. `in2lambda source show` prints the ids against the line each block starts on, indented two spaces for each level below the top, so that a line starting a block and the first block inside it carries both ids. A spec selects a nested block by `depth`, as in `part: ListItem depth=2`. A block whose children hold a role holds no role itself, so one selector may match a block and its children. `in2lambda validate` reports the children of a block with children, in place of the block itself, as being in no field. A draft frozen from a document holding nothing nested is written as it was before this release and replays. A draft frozen before this release from a document holding a list does not replay, because freezing that document now records blocks the draft does not hold; `in2lambda source add --start-over` freezes the document again. +- A spec written before this release can match blocks it did not match before, because a selector that names no `depth` now matches a block nested inside a list item or a `solution` environment as well as a top-level block. The `PartsOneSol` example ships a `solution` environment holding two paragraphs and a display maths, which pandoc writes as a `Para` as well, so a spec reading `question: Para` now matches all three of them; the div holding them then holds no role, and the run writes three questions the document does not write in place of the first question's worked solution. The second question's worked solution survives, because that `solution` environment holds one element and stays one block. Write `depth=1` on each selector — `question: Para depth=1` — to keep the meaning the selector had before this release. One change to the Python API breaks existing scripts: `in2lambda.spec.Selector.matches` takes a list of `(block, element)` pairs in place of a list of panflute elements, so a caller matching a selector itself passes each `in2lambda.source.Block` beside its element. - `in2lambda convert FILE PartsOneSol` now exports the worked solution a document writes in a `solution` environment. Pandoc writes that environment as a Div whose classes hold `solution`, and the filter recognised only a Div whose first block reads `Solution`, so a document using the environment exported every question with an empty worked solution. A Div whose first block reads `Solution` is still recognised. - Importing `in2lambda.katex_convert` no longer writes a file called `log` into the working directory. That module reports what it changed in an expression to the `in2lambda.katex_convert` logger, which is silent unless the application configures logging. - `in2lambda convert` now reads a .docx that holds an image. in2lambda looks in the document for the directories a `\graphicspath` names, and read the document as UTF-8 text to find them. A .docx is a zip file, so converting a Word document holding a figure raised `UnicodeDecodeError`. in2lambda now reads a document that is not UTF-8 text as naming no directory, which is what a .docx names. diff --git a/docs/source/drafts.md b/docs/source/drafts.md index b158bbd..3de4b0d 100644 --- a/docs/source/drafts.md +++ b/docs/source/drafts.md @@ -105,8 +105,10 @@ $ cat sheet.draft.json } ``` -A block is one top-level element pandoc found - a heading, a paragraph, a list item - with the -lines it spans and an id to quote it by. `fields` holds the questions, parts and solutions +A block is one element pandoc found - a heading, a paragraph, a list item - with the lines it +spans and an id to quote it by. A list item, or a `\begin{solution}` environment, holds elements +of its own, and each of those is a block too: `b3` holds `b3.1` and `b3.2`, and `b3` spans them. +[Specs](spec.md) says more about the nested ones. `fields` holds the questions, parts and solutions written from the sheet, and `log` holds the commands that wrote them. Both are empty until a spec or a command fills them in. @@ -143,8 +145,9 @@ b8 15 The flow rate is $Q = \pi d^2 v / 4$. ``` `in2lambda source show` prints the frozen markdown numbered, with the id of each block against -the line it starts on. A command names a block by its id, `b3`, a line of the source, `s5`, or a -range of lines, `s15:16`. +the line it starts on. A block and the first block nested inside it start on the same line, so a +line may carry several ids, and the margin is indented two spaces for each level of nesting. A +command names a block by its id, `b3`, a line of the source, `s5`, or a range of lines, `s15:16`. ## Fill the draft in from a spec diff --git a/docs/source/spec.md b/docs/source/spec.md index 732d621..e344218 100644 --- a/docs/source/spec.md +++ b/docs/source/spec.md @@ -71,13 +71,14 @@ A selector is a block type followed by any number of constraints: ``` The type is a pandoc element: `Header`, `Para`, `ListItem`. A selector that omits the type matches -any block. A constraint names one of three attributes: +any block. A constraint names one of four attributes: | Attribute | Meaning | |-----------|---------| | `level` | A heading's level. `level=2` matches `##`. | | `text` | The whole block as text, with the markup removed. | | `label` | The first word of that text, which usually numbers a question. | +| `depth` | How deep the block sits. `depth=1` matches a top-level element of the document, `depth=2` a block nested inside one. See [Nested blocks](#nested-blocks). | `=` matches the whole value. `~` matches a regular expression anywhere in the value. `after SELECTOR,` requires the block to follow the first block that the named selector matches, which is @@ -92,6 +93,45 @@ reads `(a)` as a list marker. Its value is dedented as pandoc reads the item: th the first line, and the same width of indentation off every line below it. Use `strip` for the labels pandoc does not read as a marker, such as `Q1. ` and `Solution: `. +(nested-blocks)= +## Nested blocks + +Many sheets are written as one list: each question is an item, and the parts of a question are a +list nested inside that item. `in2lambda source add` records the blocks inside a block as well as +the top-level blocks, so that a selector reaches a part. + +A list item and a fenced div, which is what pandoc writes a `\begin{solution}` environment as, are +the two blocks that hold blocks of their own. A list item or div holding a single element other +than a list is that element, and stays one block. A nested block's id is the id of the block +holding it and a number: `b3` holds `b3.1` and `b3.2`, and `b3.2` holds `b3.2.1`. `in2lambda source +show` prints the ids against the line each block starts on, indented two spaces for each level +below the top. + +A block spans the blocks nested inside it, so `depth` distinguishes a question from its parts: + +```yaml +question: Para depth=2 +part: Para depth=3 +solution: Div +layout: PartSolPartSol +``` + +That spec reads a sheet whose questions are top-level list items. The question is the item's own +paragraph `b3.1`, and not the whole item `b3`. The `PartSolPartSol` layout pairs each solution +with the part written above it. + +A block whose children hold a role holds no role itself. Writing `b3` into `q1.text` and `b3.2` +into `q1.p1.text` would be two fields quoted from the same lines, which no command writes. A spec +quotes the item's own paragraphs into the question and the nested items into the parts. So one +selector may match a block and its children, and in2lambda assigns the role to the children. + +A fenced div spans the lines its content is written on. The `:::` lines pandoc wrote around the +content are pandoc's, as a list marker is, and no field quotes them. + +`in2lambda validate` reports the children of a block with children, in place of the block itself, +as being in no field. A block with children spans the blank lines and the fences between those +children, and no field can quote those lines. + (predicates)= ## Predicates diff --git a/in2lambda/draft/__init__.py b/in2lambda/draft/__init__.py index f780eb5..06b3f0f 100644 --- a/in2lambda/draft/__init__.py +++ b/in2lambda/draft/__init__.py @@ -18,14 +18,15 @@ import in2lambda.spec from in2lambda.draft.report import _order, _where, checks, overlapping, uncovered from in2lambda.source import ( + Block, SourceError, _digest, _elements, _numbered, _require_conversion_tools, blocks, - dedented, frozen, + quoted, save, serialise, ) @@ -548,24 +549,38 @@ def _quoted( ) -> str: """Lines of one frozen source as a field holds them. - Lines quoted out of a list item are dedented by the item's own indentation, which the - markdown requires and the author did not write. The ranges still name the source - lines. The block the lines fall in decides whether they are dedented, and the text - does not, so that a paragraph reading like a list item is quoted as it is written. + Lines quoted out of a list item, or out of a block nested inside a list item, are + dedented by the indentation the markdown requires and the author did not write. The + ranges still name the source lines. The block the lines fall in decides whether they + are dedented, and the text does not, so that a paragraph reading like a list item is + quoted as it is written. """ text = "\n".join(markdown.splitlines()[start - 1 : end]) - # Blocks do not overlap, so the block holding the first line is the block the lines - # belong to. A nested item falls in that block as well, because only a top-level item - # is a block of its own. + # The innermost block holding the first line, which is the last block in the list to + # hold it, because in2lambda writes a block after the block holding it. A block and + # the block holding it stand at the same indentation, so either dedents by the same + # width; the innermost block states the depth where the block holding it is a fenced + # div and not a list item. block = next( ( held - for held in draft["sources"][source - 1]["blocks"] + for held in reversed(draft["sources"][source - 1]["blocks"]) if held["start"] <= start <= held["end"] ), None, ) - return dedented(text) if block and block["type"] == "list item" else text + if block is None: + return text + return quoted( + text, + Block( + block["id"], + block["type"], + block["start"], + block["end"], + block.get("depth", 1), + ), + ) def _next(draft: dict[str, Any], prefix: str) -> str: diff --git a/in2lambda/draft/report.py b/in2lambda/draft/report.py index 1ed075b..31a1eba 100644 --- a/in2lambda/draft/report.py +++ b/in2lambda/draft/report.py @@ -110,6 +110,11 @@ def uncovered(draft: dict[str, Any]) -> list[Finding]: block by the lines the fields were taken from and not by the block's name, so that an ignore of a whole block covers both halves of a block `split block` has cut in two. + A block with blocks nested inside it is not reported; its children are. Such a block + spans its children and the blank lines and fences pandoc wrote between them, which no + field can quote, so reporting it would name lines nobody can account for and say + again what each child already says. + Args: draft: A draft, as `in2lambda.source.frozen` reads one. @@ -128,7 +133,16 @@ def uncovered(draft: dict[str, Any]) -> list[Finding]: } found = [] for number, source in enumerate(draft["sources"], start=1): + # A nested block's id is its parent's and a number, so a block whose id is the + # stem of another's is one holding blocks of its own. + parents = { + block["id"].rsplit(".", 1)[0] + for block in source["blocks"] + if "." in block["id"] + } for block in source["blocks"]: + if block["id"] in parents: + continue free = _runs( [ line diff --git a/in2lambda/source/__init__.py b/in2lambda/source/__init__.py index 123bf89..f493c17 100644 --- a/in2lambda/source/__init__.py +++ b/in2lambda/source/__init__.py @@ -3,8 +3,13 @@ A tool that writes questions from a document copies the wording out of the source, and a line range identifies that wording only while the text does not change. So in2lambda freezes the document once: :func:`add` converts it to markdown, hashes the markdown, and -writes a ``FILE.draft.json`` beside it listing every top-level block with the lines that -block spans. +writes a ``FILE.draft.json`` beside it listing every block with the lines that block +spans. + +A block is a top-level element of the markdown, or an element nested inside one. A list +item holding a list of its own is one block for the question and one block for each part, +and the nested ids state where each block sits: ``b3`` holds ``b3.1`` and ``b3.2``, and +``b3.2`` holds ``b3.2.1``. A block spans the blocks nested inside it. The draft is named after the source it was frozen from, so a folder holding a term's sheets holds one draft per sheet. @@ -27,7 +32,8 @@ import re import shutil import subprocess -from dataclasses import dataclass +import textwrap +from dataclasses import dataclass, replace from pathlib import Path from typing import Any, Optional @@ -570,7 +576,7 @@ def frozen(draft: str | Path) -> tuple[dict[str, Any], list[str]]: @dataclass class Block: - """One top-level block of a frozen source, and the lines it spans. + """One block of a frozen source, and the lines it spans. Lines are 1-based and inclusive, so `start` and `end` are the numbers :func:`show` prints beside that block's first and last line. @@ -580,10 +586,24 @@ class Block: type: str start: int end: int + depth: int = 1 + """How deep the block sits: 1 for a top-level element, 2 for a child of one.""" def to_dict(self) -> dict[str, str | int]: - """The block as it is written into the draft.""" - return {"id": self.id, "type": self.type, "start": self.start, "end": self.end} + """The block as it is written into the draft. + + A top-level block writes no ``depth``, so a document with nothing nested in it + freezes to the draft it has always frozen to and replays as it always did. + """ + written: dict[str, str | int] = { + "id": self.id, + "type": self.type, + "start": self.start, + "end": self.end, + } + if self.depth > 1: + written["depth"] = self.depth + return written def _numbered(source: int, name: str) -> str: @@ -597,7 +617,7 @@ def _numbered(source: int, name: str) -> str: def blocks(markdown: str, source: int = 1) -> list[Block]: - r"""Every top-level block of some markdown, in the order it is written. + r"""Every block of some markdown, in the order it is written. Args: markdown: A document in the dialect :func:`add` freezes to. @@ -605,14 +625,18 @@ def blocks(markdown: str, source: int = 1) -> list[Block]: but the first: a draft's second source has ``2/b1``, ``2/b2``. Returns: - One :class:`Block` per block, numbered ``b1`` onwards. The blocks do not - overlap, and every line of the document falls in at most one block. A block of a - type no command quotes is listed as ``other``, so that its lines have an id. + One :class:`Block` per top-level element, numbered ``b1`` onwards, each followed + by the blocks nested inside it, numbered ``b1.1`` onwards. Top-level blocks do + not overlap, and every line of the document falls in at most one top-level block. + A block of a type no command quotes is listed as ``other``, so that its lines + have an id. A block spans the blocks nested inside it. Examples: >>> from in2lambda.source import blocks >>> blocks("# Title\n\nSome words.\n") - [Block(id='b1', type='heading', start=1, end=1), Block(id='b2', type='paragraph', start=3, end=3)] + [Block(id='b1', type='heading', start=1, end=1, depth=1), Block(id='b2', type='paragraph', start=3, end=3, depth=1)] + >>> [(block.id, block.type) for block in blocks("1. Q1\n\n 1. (a)\n")] + [('b1', 'list item'), ('b1.1', 'paragraph'), ('b1.2', 'list item')] >>> [block.id for block in blocks("# Solutions\n", 2)] ['2/b1'] """ @@ -634,7 +658,10 @@ def dedented(text: str) -> str: off each line below as that line has to give, so that a list nested inside the item keeps its relative indent. Text whose first line holds no marker is returned unchanged. A paragraph that reads like a marker - ``A. Smith says`` - would be - dedented, so the caller decides by the block's type and not by its text. + dedented, so the caller decides by the block's type and not by its text. The same + holds of a block nested inside an item: :func:`quoted` calls this function on such + a block because its first line may hold the item's marker, and a nested block + whose first line reads like a marker is dedented too. Examples: >>> from in2lambda.source import dedented @@ -655,6 +682,35 @@ def dedented(text: str) -> str: ) +def quoted(text: str, block: Block) -> str: + r"""Some lines of a block, as a field quotes them. + + Args: + text: The lines as the source writes them. + block: The block they are the lines of, which says whether the indentation on + them is the markdown's. + + Returns: + The lines as they are, for a top-level block that is not a list item. For a list + item, or for a block nested inside one, the marker comes off the first line and + the indent the lines stand at comes off every one of them: both are what the + markdown needed to hold the item together, and four leading spaces after a blank + line are a code block wherever the field is rendered. + + Examples: + >>> from in2lambda.source import Block, quoted + >>> quoted("1. A person walks\n to the edge.", Block("b1", "list item", 3, 4)) + 'A person walks\nto the edge.' + >>> quoted(" It might have\n two lines.", Block("b1.2", "paragraph", 7, 8, 2)) + 'It might have\ntwo lines.' + >>> quoted("A. Smith says\nso.", Block("b1", "paragraph", 1, 2)) + 'A. Smith says\nso.' + """ + if block.type == "list item" or block.depth > 1: + return textwrap.dedent(dedented(text)) + return text + + def _elements(markdown: str, source: int = 1) -> list[tuple[Block, Any]]: """Every block of some markdown, each beside the panflute element it was taken from. @@ -667,22 +723,59 @@ def _elements(markdown: str, source: int = 1) -> list[tuple[Block, Any]]: document = pf.convert_text( markdown, input_format=f"{_MARKDOWN}+sourcepos", standalone=True ) - found = [span for element in document.content for span in _spans(element, pf)] + found = _walk(document.content, "b", 1, len(markdown.splitlines()), pf) + return [ + (replace(block, id=_numbered(source, block.id)), element) + for block, element in found + ] + + +def _walk(elements, prefix, depth, limit, pf): # type: ignore[no-untyped-def] + """The blocks a run of sibling elements accounts for, each parent before its children. + + Args: + elements: The elements standing side by side - the document's own, or the ones + inside one block of it. + prefix: What their ids start with: ``b`` at the top, ``b3.`` inside ``b3``. + depth: How deep they sit, counting the top-level elements as 1. + limit: The last line the final sibling may reach, which is the end of the + document at the top and the end of the parent block inside one. + pf: The panflute module, imported by the caller that has it. + """ + found = [span for element in elements for span in _spans(element, pf)] # Where no blank line separates one block from the next - a list straight after a # paragraph, a definition list - pandoc reports the first block as reaching into the - # second block's first line. So a block ends before the next block starts, and - # before the end of the document. - limits = [start - 1 for _, start, _, _ in found[1:]] + [len(markdown.splitlines())] - return [ - (Block(_numbered(source, f"b{number}"), kind, start, min(end, limit)), element) - for number, ((kind, start, end, element), limit) in enumerate( - zip(found, limits), 1 + # second block's first line. So a block ends before the next block starts, and before + # the end of the block holding it. + limits = [start - 1 for _, start, _, _ in found[1:]] + [limit] + walked = [] + for number, ((kind, start, end, element), stop) in enumerate(zip(found, limits), 1): + block = Block(f"{prefix}{number}", kind, start, min(end, stop), depth) + walked.append((block, element)) + walked.extend( + _walk(_children(element, pf), f"{block.id}.", depth + 1, block.end, pf) ) - ] + return walked + + +def _children(element, pf): # type: ignore[no-untyped-def] + """The elements inside a block that are blocks of their own, and none where it has any. + + A list item and a fenced Div - what pandoc makes of a ``solution`` environment - are + the two things a document nests blocks inside, and a spec reaches those blocks by + their depth. One holding a single element other than a list is that element, so it + stays one block: a sheet written without nesting freezes to the blocks it always did. + """ + if not isinstance(element, (pf.ListItem, pf.Div)): + return [] + inside = [_unwrapped(child, pf) for child in element.content] + if len(inside) == 1 and not isinstance(inside[0], (pf.BulletList, pf.OrderedList)): + return [] + return list(element.content) def _spans(element, pf): # type: ignore[no-untyped-def] - """The ``(type, start, end, element)`` quadruples one top-level element accounts for. + """The ``(type, start, end, element)`` quadruples one element accounts for. A list accounts for several: a command quotes a list item, not a list, and an item spans everything nested under it. The element returned is the block itself, past the @@ -698,6 +791,20 @@ def _spans(element, pf): # type: ignore[no-untyped-def] for item in inner.content if len(item.content) ] + if isinstance(inner, pf.Div): + # A fenced Div's own position covers the `:::` lines pandoc wrote around it. + # Those are pandoc's, as a list marker is, and a field cannot quote them, so the + # lines of the Div are the ones its content stands on. + if not len(inner.content): + return [] + return [ + ( + _kind(inner, pf), + _range(inner.content[0])[0], + _range(inner.content[-1])[1], + inner, + ) + ] return [(_kind(inner, pf), *_range(element), inner)] @@ -732,6 +839,8 @@ def _kind(inner, pf) -> str: # type: ignore[no-untyped-def] if isinstance(contents[0], pf.Image): return "image" return "paragraph" + if isinstance(inner, pf.Div): + return "div" return "other" @@ -890,10 +999,12 @@ def show(draft: str | Path) -> str: draft: The path of the draft to print. Returns: - One line per line of each frozen markdown: the id of the block starting there, - where a block starts there, then the line number and the line itself. A draft of - more than one source heads each source with its number and its name, because the - line numbers start again at 1 in each source. + One line per line of each frozen markdown: the ids of the blocks starting there, + where any block starts there, then the line number and the line itself. A block + and the first block nested inside it start on the same line, so a line may carry + several ids, and the margin is indented two spaces for each level of nesting + below the top. A draft of more than one source heads each source with its number + and its name, because the line numbers start again at 1 in each source. Raises: DraftMissing: there is no draft at that path. @@ -910,12 +1021,22 @@ def show(draft: str | Path) -> str: for number, (source, markdown) in enumerate( zip(found["sources"], sources), start=1 ): - ids = {block["start"]: block["id"] for block in source["blocks"]} + starting: dict[int, list[dict[str, Any]]] = {} + for block in source["blocks"]: + starting.setdefault(block["start"], []).append(block) + # The blocks are in document order, so the ids of one line read outermost first, + # and the margin is indented by the shallowest of them: a nested block whose + # parent starts further up stands out to the right of it. + ids = { + line_number: " " * (min(block.get("depth", 1) for block in found) - 1) + + " ".join(block["id"] for block in found) + for line_number, found in starting.items() + } lines = markdown.splitlines() - margin = max((len(block_id) for block_id in ids.values()), default=0) + margin = max((len(shown) for shown in ids.values()), default=0) numbers = len(str(len(lines))) body = "\n".join( - f"{ids.get(line_number, ''):>{margin}} {line_number:>{numbers}} {line}".rstrip() + f"{ids.get(line_number, ''):<{margin}} {line_number:>{numbers}} {line}".rstrip() for line_number, line in enumerate(lines, start=1) ) printed.append( diff --git a/in2lambda/spec/__init__.py b/in2lambda/spec/__init__.py index 809cb8a..621e100 100644 --- a/in2lambda/spec/__init__.py +++ b/in2lambda/spec/__init__.py @@ -47,13 +47,19 @@ from typing import Any, NamedTuple, Optional from in2lambda.filters import builtin_filters -from in2lambda.source import Block, SourceError, dedented +from in2lambda.source import Block, SourceError, quoted _KEYS = ("question", "part", "solution", "strip", "ignore", "layout", "predicates") """Every key a spec may hold. Any other key is a typo, and is refused as one.""" -_ATTRIBUTES = ("level", "text", "label") -"""What a constraint can be about: a heading's level, a block's text, its first word.""" +_ATTRIBUTES = ("level", "text", "label", "depth") +"""What a constraint can be about. + +A heading's level, a block's text, its first word, and how deep the block sits: 1 for a +top-level element of the document, 2 for a block nested inside one. A sheet whose +questions are list items with their parts nested under them is written ``question: +ListItem depth=1`` and ``part: ListItem depth=2``. +""" _ROLES = ("ignore", "question", "part", "solution") """The roles a spec holds selectors for, in the order each role is matched against a block. @@ -112,7 +118,7 @@ class Selector: def matches( self, - elements: list[Any], + elements: list[tuple[Block, Any]], index: int, pf: Any, functions: Optional[dict[str, Callable[[Any], Any]]] = None, @@ -120,7 +126,9 @@ def matches( """Whether the block at `index` matches this selector. Args: - elements: Every block of the document, as the panflute element it is. + elements: Every block of the document, each beside the panflute element it + is. A ``depth`` constraint reads the block; every other constraint, and + every predicate, reads the element. index: Which block to test. pf: The panflute module, imported by the caller that holds it. functions: The predicates the spec's file holds, as :func:`predicates` bound @@ -130,7 +138,7 @@ def matches( True where the element is of this type, meets every constraint, satisfies every predicate it calls, and follows a block the ``after`` selector matches. """ - element = elements[index] + block, element = elements[index] if self.after is not None and not any( self.after.matches(elements, earlier, pf, functions) for earlier in range(index) @@ -139,7 +147,7 @@ def matches( if self.type is not None and type(element).__name__ != self.type: return False if not all( - constraint.holds(_attribute(constraint.attribute, element, pf)) + constraint.holds(_attribute(constraint.attribute, block, element, pf)) for constraint in self.constraints ): return False @@ -188,8 +196,10 @@ class Doubled(NamedTuple): """Which block the field holds, and the lines that block was taken from.""" -def _attribute(name: str, element: Any, pf: Any) -> Optional[str]: +def _attribute(name: str, block: Block, element: Any, pf: Any) -> Optional[str]: """What a block holds for one attribute, or None where the block holds none.""" + if name == "depth": + return str(block.depth) if name == "level": return str(element.level) if isinstance(element, pf.Header) else None text = pf.stringify(element).strip() @@ -633,21 +643,31 @@ def _roles( matches within the source it is run over - ``after Header text=Solutions`` names a position in one document - so each source is classified on its own, whatever the sources before it hold. + + A block whose children hold a role holds no role itself. A block spans its children, + so a question quoted from the whole of a list item and a part quoted from an item + nested inside that list item would be two fields written over the same lines, which + :func:`in2lambda.draft.record` refuses. The spec quotes the item's own paragraph into + the question and the nested items into the parts, and one selector may match both. """ - found = [element for _, element in elements] - return [ + found = [ next( ( role for role in _ROLES if any( - selector.matches(found, index, pf, functions) + selector.matches(elements, index, pf, functions) for selector in getattr(spec, role) ) ), None, ) - for index in range(len(found)) + for index in range(len(elements)) + ] + held = {block.id for (block, _), role in zip(elements, found) if role is not None} + return [ + None if any(other.startswith(f"{block.id}.") for other in held) else role + for (block, _), role in zip(elements, found) ] @@ -717,11 +737,9 @@ def _stripped(spec: Spec, lines: list[str], block: Block) -> str: The value is the markdown, and not the text pandoc stringifies it to, so that the maths, the emphasis and the images of a question survive into the field. """ - text = "\n".join(lines[block.start - 1 : block.end]) # The list marker and the indent under it belong to the markdown and not to the # author, so they come off before the spec's patterns, which are for the rest. - if block.type == "list item": - text = dedented(text) + text = quoted("\n".join(lines[block.start - 1 : block.end]), block) for pattern in spec.strip: text = pattern.sub("", text) return text.strip() diff --git a/tests/conftest.py b/tests/conftest.py index 1496b29..414d9ff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -106,9 +106,15 @@ def frozen_sources(folder: Path) -> list[str]: A folder holding a ``solutions.md`` beside its ``source.md`` is a sheet written as two documents - the questions, and the worked solutions separately - and freezes as - two sources, so covering that is a second file in a folder rather than a test. + two sources, so covering that is a second file in a folder rather than a test. A + folder whose spec runs over a filter's ``example.tex`` holds a ``source.tex``, which + `tests.test_spec._frozen` copies in. """ - return [name for name in ("source.md", "solutions.md") if (folder / name).is_file()] + return [ + name + for name in ("source.md", "source.tex", "solutions.md") + if (folder / name).is_file() + ] @pytest.fixture diff --git a/tests/fixtures/against_convert/PartsOneSol/spec.yaml b/tests/fixtures/against_convert/PartsOneSol/spec.yaml index 6b5394b..8925e8c 100644 --- a/tests/fixtures/against_convert/PartsOneSol/spec.yaml +++ b/tests/fixtures/against_convert/PartsOneSol/spec.yaml @@ -1,6 +1,5 @@ -question: Para +question: Para depth=1 part: ListItem solution: Div -strip: ['(?m)^::: \{\.solution\}\n', '(?m)^:::$'] ignore: Header layout: PartsOneSol diff --git a/tests/fixtures/drafts/README.md b/tests/fixtures/drafts/README.md index ba25a56..d7b72cf 100644 --- a/tests/fixtures/drafts/README.md +++ b/tests/fixtures/drafts/README.md @@ -40,8 +40,10 @@ the fields point at. commands one at a time, and the only one whose question has two parts: both are answered by the spec's own solutions, so the `question solution` after it answers nothing, and the export carries it as a part of its own rather than dropping the wording. `nested_list` is a numbered question with two lettered parts nested inside it, quoted by line -range because only the top-level item is a block: each field is dedented by its own depth, four -spaces for the question and eight for the parts, while its range still names the source lines. +range: each field is dedented by the depth of the block its first line falls in, four spaces for +the question and eight for the parts, while its range still names the source lines. Nothing is +left in no field, because a block holding blocks of its own is not reported and each of its +children is quoted. `question_without_parts` is a question and nothing else, which the checks warn has no solution: it is here because a question with no parts is what the export has to write out as an empty part rather than as the template's. diff --git a/tests/fixtures/drafts/nested_list/report.json b/tests/fixtures/drafts/nested_list/report.json index c582b7d..ad9f6e8 100644 --- a/tests/fixtures/drafts/nested_list/report.json +++ b/tests/fixtures/drafts/nested_list/report.json @@ -1,20 +1,4 @@ [ - { - "check": "uncovered", - "field": "b2", - "level": "error", - "message": "b2 (lines 5-5, 8-8) is in no field and not marked ignore.", - "ranges": [ - [ - 5, - 5 - ], - [ - 8, - 8 - ] - ] - }, { "check": "no-solution", "field": "q1.p1", diff --git a/tests/fixtures/sources/README.md b/tests/fixtures/sources/README.md index 7e7397f..677bcad 100644 --- a/tests/fixtures/sources/README.md +++ b/tests/fixtures/sources/README.md @@ -16,6 +16,17 @@ Windows line endings, which is what a document off a teacher's machine usually h freeze to the same blocks and to a hash that `sha256sum source.md` reproduces. The `.gitattributes` at the top of the repository is what stops a checkout rewriting those endings away. +Three of the documents nest blocks inside blocks. `nested_list` is a question written as a list +item holding a paragraph, a list of two parts and a heading, and pins the dotted ids, the `depth` +each carries and that `b2` spans `b2.1` to `b2.4` while `b2.2` spans `b2.2.1` and `b2.2.2`. Its +last item holds one paragraph and stays one block, which is what keeps a document with no nesting +in it freezing to the blocks it always did. `fenced_div` is a `.tex` whose `solution` environment +pandoc writes as `::: {.solution}`, once inside a list item and once holding a list of its own, and +pins that a div's range is the lines its content stands on rather than the `:::` lines around it. +`display_maths`, described above, is the one that pins that an item nesting no list splits as +well: each of its two items holds a paragraph, display maths and a paragraph, and so holds the +three blocks `b7.1` to `b7.3` and `b8.1` to `b8.3`. + To cover another construct, add a folder: one `source.md`, `source.tex` or `source.docx`, and the `expected.json` the test compares the draft's `blocks` against. diff --git a/tests/fixtures/sources/display_maths/expected.json b/tests/fixtures/sources/display_maths/expected.json index bfdb1c2..0aa844f 100644 --- a/tests/fixtures/sources/display_maths/expected.json +++ b/tests/fixtures/sources/display_maths/expected.json @@ -41,10 +41,52 @@ "start": 17, "type": "list item" }, + { + "depth": 2, + "end": 17, + "id": "b7.1", + "start": 17, + "type": "paragraph" + }, + { + "depth": 2, + "end": 21, + "id": "b7.2", + "start": 19, + "type": "display maths" + }, + { + "depth": 2, + "end": 23, + "id": "b7.3", + "start": 23, + "type": "paragraph" + }, { "end": 31, "id": "b8", "start": 25, "type": "list item" + }, + { + "depth": 2, + "end": 25, + "id": "b8.1", + "start": 25, + "type": "paragraph" + }, + { + "depth": 2, + "end": 29, + "id": "b8.2", + "start": 27, + "type": "display maths" + }, + { + "depth": 2, + "end": 31, + "id": "b8.3", + "start": 31, + "type": "paragraph" } ] diff --git a/tests/fixtures/sources/fenced_div/expected.json b/tests/fixtures/sources/fenced_div/expected.json new file mode 100644 index 0000000..8e6b087 --- /dev/null +++ b/tests/fixtures/sources/fenced_div/expected.json @@ -0,0 +1,48 @@ +[ + { + "end": 1, + "id": "b1", + "start": 1, + "type": "heading" + }, + { + "end": 7, + "id": "b2", + "start": 3, + "type": "list item" + }, + { + "depth": 2, + "end": 3, + "id": "b2.1", + "start": 3, + "type": "paragraph" + }, + { + "depth": 2, + "end": 6, + "id": "b2.2", + "start": 6, + "type": "div" + }, + { + "end": 12, + "id": "b3", + "start": 10, + "type": "div" + }, + { + "depth": 2, + "end": 10, + "id": "b3.1", + "start": 10, + "type": "list item" + }, + { + "depth": 2, + "end": 12, + "id": "b3.2", + "start": 12, + "type": "list item" + } +] diff --git a/tests/fixtures/sources/fenced_div/source.tex b/tests/fixtures/sources/fenced_div/source.tex new file mode 100644 index 0000000..e5c35b9 --- /dev/null +++ b/tests/fixtures/sources/fenced_div/source.tex @@ -0,0 +1,16 @@ +\section{Fenced divs} + +\begin{enumerate} + \item Find the load the large piston carries. + + \begin{solution} + The load is $F = pA$. + \end{solution} +\end{enumerate} + +\begin{solution} +\begin{enumerate} + \item The small piston has area $a$. + \item The large piston has area $A$. +\end{enumerate} +\end{solution} diff --git a/tests/fixtures/sources/nested_list/expected.json b/tests/fixtures/sources/nested_list/expected.json new file mode 100644 index 0000000..04f343c --- /dev/null +++ b/tests/fixtures/sources/nested_list/expected.json @@ -0,0 +1,62 @@ +[ + { + "end": 1, + "id": "b1", + "start": 1, + "type": "heading" + }, + { + "end": 12, + "id": "b2", + "start": 3, + "type": "list item" + }, + { + "depth": 2, + "end": 4, + "id": "b2.1", + "start": 3, + "type": "paragraph" + }, + { + "depth": 2, + "end": 8, + "id": "b2.2", + "start": 6, + "type": "list item" + }, + { + "depth": 3, + "end": 6, + "id": "b2.2.1", + "start": 6, + "type": "paragraph" + }, + { + "depth": 3, + "end": 8, + "id": "b2.2.2", + "start": 8, + "type": "paragraph" + }, + { + "depth": 2, + "end": 10, + "id": "b2.3", + "start": 10, + "type": "list item" + }, + { + "depth": 2, + "end": 12, + "id": "b2.4", + "start": 12, + "type": "heading" + }, + { + "end": 14, + "id": "b3", + "start": 14, + "type": "list item" + } +] diff --git a/tests/fixtures/sources/nested_list/source.md b/tests/fixtures/sources/nested_list/source.md new file mode 100644 index 0000000..b686b26 --- /dev/null +++ b/tests/fixtures/sources/nested_list/source.md @@ -0,0 +1,14 @@ +# Problem sheet 9 + +1. A person walks from the centre + to the edge of a horizontal turntable. + + 1. Find the angular speed afterwards. + + Give the units. + + 2. Find the energy lost. + + ## Ten marks each + +2. Find the pressure at the bottom of a tank. diff --git a/tests/fixtures/specs/README.md b/tests/fixtures/specs/README.md index 59cbcf3..689bb3d 100644 --- a/tests/fixtures/specs/README.md +++ b/tests/fixtures/specs/README.md @@ -2,7 +2,9 @@ Each folder here is one run of `in2lambda spec run`: a `source.md` to freeze, the `spec.yaml` to run over it, the `expected.json` the spec should leave in the draft's `fields`, and the -`uncovered.txt` of the blocks the command should report as being in no field. The test freezes +`uncovered.txt` of the blocks the command should report as being in no field. A folder holding +no document of its own is a spec for the `example.tex` the filter its `layout` names ships, which +the test copies in as `source.tex`. The test freezes the source, runs the spec, compares both, and then replays the draft from its log and checks the file is unchanged byte for byte - so a folder covers what a spec makes of a document and that it can be rebuilt from what was recorded. A folder whose layout sends two blocks to the same field @@ -24,6 +26,14 @@ calls functions from it, because what tells its questions from the paragraph abo bold each of them starts with, which is markup rather than text. Its log entry names that file and hashes it as it does the spec, which is what makes a changed predicate refuse to replay. +`parts_sep_sol_nested`, `part_part_sol_sol_nested` and `part_sol_part_sol_nested` are the three +layouts whose example nests the parts of a question inside the question's own list item. Each +runs over the example its filter ships, so the one sheet covers the filter route and the spec +route, and each pins that a `depth` constraint reaches a nested block and that a block whose +children hold a role holds none itself. `part_part_sol_sol_nested` is the one whose solutions are +a list inside a `solution` environment, so its `solution` selector names both a nested item and +the div a solution written without parts is. + `ignore_as_a_list` writes its `ignore` as two selectors under the key rather than one beside it - the title of the sheet, and the paragraph about marks - and pins that a block either of them matches is ignored. `solutions_only` is a document of nothing but solutions, which every layout diff --git a/tests/fixtures/specs/part_part_sol_sol_nested/expected.json b/tests/fixtures/specs/part_part_sol_sol_nested/expected.json new file mode 100644 index 0000000..bb08bdf --- /dev/null +++ b/tests/fixtures/specs/part_part_sol_sol_nested/expected.json @@ -0,0 +1,98 @@ +{ + "b1.ignore": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 1, + 1 + ] + ], + "value": true + }, + "q1.p1.solution": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 10, + 10 + ] + ], + "value": "$1+1=2$" + }, + "q1.p1.text": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 5, + 5 + ] + ], + "value": "$1+1$" + }, + "q1.p2.solution": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 12, + 12 + ] + ], + "value": "$2+2=4$" + }, + "q1.p2.text": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 7, + 7 + ] + ], + "value": "$2+2$" + }, + "q1.text": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 3, + 3 + ] + ], + "value": "Here is some interesting information. Calculate the following:" + }, + "q2.solution": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 18, + 18 + ] + ], + "value": "This is the solution." + }, + "q2.text": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 15, + 15 + ] + ], + "value": "This questions has no parts. What is the solution?" + } +} diff --git a/tests/fixtures/specs/part_part_sol_sol_nested/spec.yaml b/tests/fixtures/specs/part_part_sol_sol_nested/spec.yaml new file mode 100644 index 0000000..7de99cb --- /dev/null +++ b/tests/fixtures/specs/part_part_sol_sol_nested/spec.yaml @@ -0,0 +1,7 @@ +question: Para depth=2 +part: ListItem depth=2 +solution: + - ListItem depth=3 + - Div +ignore: Header +layout: PartPartSolSol diff --git a/tests/fixtures/specs/part_part_sol_sol_nested/uncovered.txt b/tests/fixtures/specs/part_part_sol_sol_nested/uncovered.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/specs/part_sol_part_sol_nested/expected.json b/tests/fixtures/specs/part_sol_part_sol_nested/expected.json new file mode 100644 index 0000000..5df1f15 --- /dev/null +++ b/tests/fixtures/specs/part_sol_part_sol_nested/expected.json @@ -0,0 +1,98 @@ +{ + "b1.ignore": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 1, + 1 + ] + ], + "value": true + }, + "q1.p1.solution": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 8, + 8 + ] + ], + "value": "$1+1 = 2$" + }, + "q1.p1.text": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 5, + 5 + ] + ], + "value": "$1+1$" + }, + "q1.p2.solution": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 14, + 14 + ] + ], + "value": "$2+2=4$" + }, + "q1.p2.text": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 11, + 11 + ] + ], + "value": "$2+2$" + }, + "q1.text": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 3, + 3 + ] + ], + "value": "Here is some interesting information. Calculate the following:" + }, + "q2.solution": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 20, + 20 + ] + ], + "value": "This is the solution." + }, + "q2.text": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 17, + 17 + ] + ], + "value": "This questions has no parts. What is the solution?" + } +} diff --git a/tests/fixtures/specs/part_sol_part_sol_nested/spec.yaml b/tests/fixtures/specs/part_sol_part_sol_nested/spec.yaml new file mode 100644 index 0000000..c7669ef --- /dev/null +++ b/tests/fixtures/specs/part_sol_part_sol_nested/spec.yaml @@ -0,0 +1,5 @@ +question: Para depth=2 +part: Para depth=3 +solution: Div +ignore: Header +layout: PartSolPartSol diff --git a/tests/fixtures/specs/part_sol_part_sol_nested/uncovered.txt b/tests/fixtures/specs/part_sol_part_sol_nested/uncovered.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/specs/parts_sep_sol_nested/expected.json b/tests/fixtures/specs/parts_sep_sol_nested/expected.json new file mode 100644 index 0000000..49b2b07 --- /dev/null +++ b/tests/fixtures/specs/parts_sep_sol_nested/expected.json @@ -0,0 +1,86 @@ +{ + "b1.ignore": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 1, + 1 + ] + ], + "value": true + }, + "b3.2.ignore": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 13, + 13 + ] + ], + "value": true + }, + "q1.p1.text": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 5, + 7 + ] + ], + "value": "Here is the first subquestion. This would map onto (a),(b),(c) etc in Lambda Feedback.\n\nIt might have multiple paragraphs." + }, + "q1.p2.text": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 9, + 9 + ] + ], + "value": "Here is a second subquestion." + }, + "q1.text": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 3, + 3 + ] + ], + "value": "This is a sample question" + }, + "q2.text": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 11, + 11 + ] + ], + "value": "This is a second question." + }, + "q3.text": { + "by": "tests", + "edited": false, + "layer": 1, + "ranges": [ + [ + 15, + 15 + ] + ], + "value": "This is a third question. A separate solution file (if provided) should follow the same format." + } +} diff --git a/tests/fixtures/specs/parts_sep_sol_nested/spec.yaml b/tests/fixtures/specs/parts_sep_sol_nested/spec.yaml new file mode 100644 index 0000000..156179f --- /dev/null +++ b/tests/fixtures/specs/parts_sep_sol_nested/spec.yaml @@ -0,0 +1,6 @@ +question: + - Para depth=2 + - ListItem depth=1 +part: ListItem depth=2 +ignore: Header +layout: PartsSepSol diff --git a/tests/fixtures/specs/parts_sep_sol_nested/uncovered.txt b/tests/fixtures/specs/parts_sep_sol_nested/uncovered.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_source.py b/tests/test_source.py index 20c9197..ab12e32 100644 --- a/tests/test_source.py +++ b/tests/test_source.py @@ -135,7 +135,37 @@ def test_source_show_numbers_the_lines_and_names_the_blocks( ] for block in blocks: assert lines[block["start"] - 1].split()[0] == block["id"] - assert sum(bool(re.match(r" *b\d+ ", line)) for line in lines) == len(blocks) + assert sum(bool(re.match(r" *b[\d.]+ ", line)) for line in lines) == len(blocks) + + +def test_source_show_indents_the_ids_of_nested_blocks( + tmp_path: Path, monkeypatch +) -> None: + """A spec names a part by the depth it sits at, so the printing has to show it.""" + shutil.copytree(SOURCES_DIR / "nested_list", tmp_path, dirs_exist_ok=True) + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["source", "add", "source.md"]).exit_code == 0 + + result = runner.invoke(cli, ["source", "show"]) + + assert result.exit_code == 0, result.output + lines = result.output.splitlines() + blocks = json.loads((tmp_path / "source.draft.json").read_text())["sources"][0][ + "blocks" + ] + for block in blocks: + assert block["id"] in lines[block["start"] - 1].split() + # A block and the first block inside it start on the same line, so both ids are + # printed against it, and the margin is indented two spaces per level below the top. + assert [lines[number - 1] for number in (1, 3, 6, 8, 10, 14)] == [ + "b1 1 # Problem sheet 9", + "b2 b2.1 3 1. A person walks from the centre", + " b2.2 b2.2.1 6 1. Find the angular speed afterwards.", + " b2.2.2 8 Give the units.", + " b2.3 10 2. Find the energy lost.", + "b3 14 2. Find the pressure at the bottom of a tank.", + ] def test_a_second_source_is_frozen_beside_the_first( diff --git a/tests/test_spec.py b/tests/test_spec.py index add10a3..191d68e 100644 --- a/tests/test_spec.py +++ b/tests/test_spec.py @@ -15,13 +15,18 @@ from typing import Any import pytest +import yaml from click.testing import CliRunner from conftest import SPECS, SPECS_DIR, frozen_sources +import in2lambda import in2lambda.draft from in2lambda.main import cli from in2lambda.source import SourceError +FILTERS = Path(in2lambda.__file__).parent / "filters" +"""Where the example each filter ships lives, for a spec folder holding no document.""" + WORKED_EXAMPLE = SPECS_DIR / "parts_sep_sol" """The case the tests below happen to use; what they check holds for any of them.""" @@ -33,8 +38,17 @@ def _frozen(folder: Path, tmp_path: Path) -> CliRunner: - """A folder's documents and its spec, copied into `tmp_path` with the sources frozen.""" + """A folder's documents and its spec, copied into `tmp_path` with the sources frozen. + + A folder holding no document of its own is a spec written for the example the filter + it names ships, which is copied in as the source: the three layouts that nest their + parts inside their questions are spec-able exactly where the filters are, so the one + sheet covers both routes. + """ shutil.copytree(folder, tmp_path, dirs_exist_ok=True) + if not frozen_sources(tmp_path): + layout = yaml.safe_load((folder / "spec.yaml").read_text())["layout"] + shutil.copy(FILTERS / layout / "example.tex", tmp_path / "source.tex") runner = CliRunner() assert ( runner.invoke(cli, ["source", "add", *frozen_sources(tmp_path)]).exit_code == 0