diff --git a/CHANGELOG.md b/CHANGELOG.md index e401a7a..7f5a9cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,4 +7,5 @@ - beartype is now `^0.22`. At 0.20.0 and below its import hook leaves `cli` a plain function rather than a group, so the new command line either fails to import or runs `convert` whatever the arguments; 0.20.1 is the first version that works. - `in2lambda source add FILE` freezes a document: it converts .docx and .tex to markdown beside the file, and writes a `draft.json` holding the markdown's hash and every block in it with the lines it spans, so that another tool can quote the source by line range. `in2lambda source show` prints that markdown numbered with the block ids. Freezing a file that has changed since is refused unless `--start-over` says to discard the draft, and so is showing one, since its block ids would name lines they are not the ids of. Both need pandoc and the `convert` extra, as `convert` does. - A draft now holds a `log` of every command that changed it and a `fields` map of what those commands wrote, each field recording which layer wrote it (1 a spec, 2 a predicate, 3 a line range, 4 a literal), the source ranges it was copied from, whether it has been edited and by whom. `in2lambda draft mark ignore BLOCK` is the first such command, and `in2lambda draft replay` rebuilds the draft from the frozen markdown and the log, refusing unless what it builds is the `draft.json` that is there, byte for byte. A `draft.json` written before this has no `log` in it and is refused as one nothing here wrote; `in2lambda source add --start-over` freezes the document again. +- A draft is filled in by `in2lambda draft question add`, `in2lambda draft part add QUESTION` and `in2lambda draft question solution QUESTION`. Each takes `--text` to copy the wording out of the frozen source, as a block id such as `b3` or as lines such as `s10:14`, or `--literal TEXT` where the source does not say it in a form the field can take, which records the field as edited and written by layer 4 rather than 3. Question and part numbers are worked out from the fields already written rather than given, so a replay arrives at the same ids. `in2lambda draft split block BLOCK AT` cuts a block the parser made one of two things into `b3a` and `b3b`, so that each half can be quoted on its own. A command writing a field that is already written, or quoting lines another field was taken from, is refused naming both fields. - The Python API is unchanged: `in2lambda.main.runner` and everything under `in2lambda.api` take the same arguments and return the same objects. diff --git a/in2lambda/draft/__init__.py b/in2lambda/draft/__init__.py index f4f39d1..4a26cc9 100644 --- a/in2lambda/draft/__init__.py +++ b/in2lambda/draft/__init__.py @@ -10,6 +10,7 @@ a handler registered with :func:`command` is never called by anything else. """ +import re from collections.abc import Callable # Rather than typing's, which beartype warns on. from pathlib import Path from typing import Any @@ -27,16 +28,21 @@ Command = dict[str, Any] """One entry of the log: ``{"command": name, "args": {...}, "by": who}``.""" -Handler = Callable[[dict[str, Any], str, dict[str, Any], str], None] +Handler = Callable[[dict[str, Any], str, dict[str, Any], str], str] """What a command does: `handler(draft, markdown, args, by)`, changing the draft. The frozen markdown is passed in rather than read, so that a handler quoting the source -by line range quotes the same text on a replay as it did when it first ran. +by line range quotes the same text on a replay as it did when it first ran. What comes +back is what the command wrote, named - the key of the field, or the block ids a split +made - which is what whoever ran it needs in the command after this one. """ _HANDLERS: dict[str, Handler] = {} """Every command there is, by the name a log entry names it with.""" +_RANGE = re.compile(r"s(\d+)(?::(\d+))?") +"""Lines of the frozen source, as ``s16`` for one of them or ``s10:14`` for several.""" + class MalformedCommand(SourceError): """A log holds something that is not a command, so nothing can be made of it.""" @@ -50,6 +56,18 @@ class NoSuchBlock(SourceError): """A command names a block the frozen source has not got.""" +class NoSuchLines(SourceError): + """A command names lines the frozen source has not got, or names them as nothing.""" + + +class NoSuchQuestion(SourceError): + """A command adds to a question nothing has written yet.""" + + +class AlreadyFilled(SourceError): + """A command would write a field that is written, or lines another field took.""" + + class ReplayDiffers(SourceError): """Replaying a draft's log does not reproduce the draft.""" @@ -79,7 +97,8 @@ def record( layer: int, ranges: list[list[int]], by: str, -) -> None: + edited: bool = False, +) -> str: """Writes one field of a draft, with where it came from. Args: @@ -92,16 +111,39 @@ def record( ranges: The line ranges of the frozen source the value was copied from, as ``[[start, end], ...]``, and empty where it was not copied from any. by: Who ran the command, as a name or a model. + edited: Whether the value is something other than what the source says. A + literal is the one thing a command writes that arrives edited; otherwise a + field is edited when something later replaces what a command wrote. + + Returns: + The key, so that a handler can hand back the field it wrote. + + Raises: + AlreadyFilled: the field is written already, or the lines it was to be copied + from are where another field came from. Nothing here changes a field once it + is written, so either is a mistake, and worth naming both halves of. """ + if key in draft["fields"]: + raise AlreadyFilled( + f"{key} is already written, and no command here changes a field that is. " + "Run in2lambda source add --start-over to begin the draft again." + ) + for filled, field in draft["fields"].items(): + for taken in field["ranges"]: + if any(taken[0] <= end and start <= taken[1] for start, end in ranges): + raise AlreadyFilled( + f"Lines {taken[0]}-{taken[1]} are where {filled} came from, so " + f"they cannot also be {key}. Run in2lambda source show to see " + "which lines are still free." + ) draft["fields"][key] = { "value": value, "layer": layer, "ranges": ranges, - # A field is edited when something replaces the value a command wrote, which is - # not something a command can do to its own field on the way in. - "edited": False, + "edited": edited, "by": by, } + return key def _fault(entry: Any) -> str: @@ -117,24 +159,33 @@ def _fault(entry: Any) -> str: return "" -def _argument(args: dict[str, Any], name: str, command: str) -> Any: - """One argument of a command, given that the log entry gave it. +def _argument(args: dict[str, Any], name: str, command: str, kind: type = str) -> Any: + """One argument of a command, given that the log entry gave it as `kind`. Handlers take their arguments through this rather than indexing, so that a log - entry missing one says which one rather than raising a KeyError at whoever ran it. + entry missing one, or holding a number where a name belongs, says which argument it + is rather than raising at whoever ran it. Every argument is a name but the line a + block is split at. Raises: - MalformedCommand: the entry has no argument of that name. + MalformedCommand: the entry has no argument of that name, or has one that is + not of that kind. """ if name not in args: raise MalformedCommand( f"{args!r} in the log is not a command {command} can run: it has no " f'"{name}" argument.' ) + if not isinstance(args[name], kind): + wanted = "a line number" if kind is int else "a name" + raise MalformedCommand( + f"{args!r} in the log is not a command {command} can run: its " + f'"{name}" is {args[name]!r} rather than {wanted}.' + ) return args[name] -def apply(draft: dict[str, Any], markdown: str, entry: Any) -> None: +def apply(draft: dict[str, Any], markdown: str, entry: Any) -> str: """Runs one command against a draft and records it in the draft's log. Args: @@ -144,6 +195,9 @@ def apply(draft: dict[str, Any], markdown: str, entry: Any) -> None: `Command`, because a log is read from a file anyone can edit: what shape it has is something to tell the reader about, not something to assume. + Returns: + What the command wrote, as the handler names it. + Raises: MalformedCommand: the entry is not a command. UnknownCommand: nothing is registered under that name. @@ -158,25 +212,31 @@ def apply(draft: dict[str, Any], markdown: str, entry: Any) -> None: f"{entry['command']} is not a command this version of in2lambda has, so " "the draft cannot be built from its log. It was written by a newer one." ) - handler(draft, markdown, entry["args"], entry["by"]) + written = handler(draft, markdown, entry["args"], entry["by"]) # After the handler, so a command that was refused is not recorded as having run. draft["log"].append(entry) + return written -def execute(entry: Command, directory: str = ".") -> None: +def execute(entry: Command, directory: str = ".") -> str: """Runs one command against the draft in a directory and writes it back. Args: entry: The command, as it is written in the log. directory: Where the ``draft.json`` to change is. + Returns: + What the command wrote, as the handler names it: the key of a field, or the + block ids a split made. + Raises: SourceError: the draft is missing, is not one of ours, or was written from markdown that has changed since; or the command is unknown or refused. """ draft, markdown = frozen(directory) - apply(draft, markdown, entry) + written = apply(draft, markdown, entry) save(Path(directory) / DRAFT, draft) + return written def replay(directory: str = ".") -> None: @@ -219,18 +279,142 @@ def replay(directory: str = ".") -> None: ) -@command("mark ignore") -def _mark_ignore( - draft: dict[str, Any], markdown: str, args: dict[str, Any], by: str -) -> None: - """Marks one block of the frozen source as nothing to take a question from.""" - block = _argument(args, "block", "mark ignore") +def _block(draft: dict[str, Any], block: str) -> dict[str, Any]: + """One block of the frozen source, given the draft has one of that id. + + Raises: + NoSuchBlock: it has not. + """ if (found := next((b for b in draft["blocks"] if b["id"] == block), None)) is None: raise NoSuchBlock( f"There is no block {block} in {DRAFT}. Run in2lambda source show to see " "the ids of the blocks there are." ) - record( + return found + + +def _lines( + draft: dict[str, Any], markdown: str, where: str, command: str +) -> tuple[int, int]: + """The first and last line of the source that a ``text`` argument names. + + A block id says the lines are whatever that block spans, which is what an author + reading `show` has in front of them; a range says them outright, for the part of a + block that is not worth splitting in two. + + Raises: + NoSuchBlock: it is neither a range nor a block the frozen source has. + NoSuchLines: it is a range of lines the source has not got. + """ + # Block ids are b1, b2, b3a, so anything starting with an s was meant as a range and + # is answered as one, rather than as a block of that name nobody was looking for. + if not where.startswith("s"): + found = _block(draft, where) + return found["start"], found["end"] + lines = len(markdown.splitlines()) + if (named := _RANGE.fullmatch(where)) is not None: + start, end = int(named[1]), int(named[2] or named[1]) + if 1 <= start <= end <= lines: + return start, end + raise NoSuchLines( + f"{command} was given {where}, which is not lines of the frozen source: it has " + f"{lines} lines, and they are named as s16, or as s10:14 for a range running " + "from an earlier line to a later. Run in2lambda source show to see them " + "numbered." + ) + + +def _fill( + draft: dict[str, Any], + markdown: str, + args: dict[str, Any], + by: str, + *, + command: str, + key: str, +) -> str: + """Writes the field a command fills, from its ``text`` or its ``literal``. + + A field is copied out of the frozen source by ``text``, which is what freezing it + was for, or typed out as a ``literal`` where the source does not say it in a form + the field can take. A literal is nobody's quotation: it is layer 4, it has no range + behind it, and it arrives edited, because what it holds is not what the source says. + + Raises: + MalformedCommand: the command gives both of them, or neither, or gives one of + them as something other than text. + NoSuchBlock, NoSuchLines: its ``text`` is not somewhere in the source. + AlreadyFilled: the field, or the lines it names, are taken. + """ + text, literal = args.get("text"), args.get("literal") + if text is not None and literal is not None: + raise MalformedCommand( + f"{args!r} in the log is not a command {command} can run: it gives both a " + '"text" and a "literal", and a field is either copied from the source or ' + "typed out, not both." + ) + if text is None and literal is None: + raise MalformedCommand( + f"{args!r} in the log is not a command {command} can run: it gives neither " + 'a "text" nor a "literal", so there is nothing for it to write.' + ) + # Back through `_argument` now that which of them was given is settled, so that one + # given as a number is a message about the argument rather than a failure inside. + if literal is not None: + return record( + draft, + key, + _argument(args, "literal", command), + layer=4, + ranges=[], + by=by, + edited=True, + ) + start, end = _lines(draft, markdown, _argument(args, "text", command), command) + return record( + draft, + key, + "\n".join(markdown.splitlines()[start - 1 : end]), + layer=3, + ranges=[[start, end]], + by=by, + ) + + +def _next(draft: dict[str, Any], prefix: str) -> str: + """The first of ``{prefix}1``, ``{prefix}2``... the draft has no text for. + + Ids are worked out rather than given, so that replaying a log numbers the questions + and their parts exactly as the run that recorded it did. + """ + number = 1 + while f"{prefix}{number}.text" in draft["fields"]: + number += 1 + return f"{prefix}{number}" + + +def _require_question(draft: dict[str, Any], question: str, command: str) -> None: + """Checks the draft has the question a command adds to. + + Raises: + NoSuchQuestion: nothing has written that question's text, so there is nothing + for a part or a solution to belong to. + """ + if f"{question}.text" not in draft["fields"]: + raise NoSuchQuestion( + f"There is no question {question} in {DRAFT}: {command} adds to a question " + "that in2lambda draft question add has already written." + ) + + +@command("mark ignore") +def _mark_ignore( + draft: dict[str, Any], markdown: str, args: dict[str, Any], by: str +) -> str: + """Marks one block of the frozen source as nothing to take a question from.""" + block = _argument(args, "block", "mark ignore") + found = _block(draft, block) + return record( draft, f"{block}.ignore", True, @@ -238,3 +422,80 @@ def _mark_ignore( ranges=[[found["start"], found["end"]]], by=by, ) + + +@command("question add") +def _question_add( + draft: dict[str, Any], markdown: str, args: dict[str, Any], by: str +) -> str: + """Adds a question, taking the first number no question has taken.""" + return _fill( + draft, + markdown, + args, + by, + command="question add", + key=f"{_next(draft, 'q')}.text", + ) + + +@command("part add") +def _part_add( + draft: dict[str, Any], markdown: str, args: dict[str, Any], by: str +) -> str: + """Adds a part to a question, taking the first number that question has not.""" + question = _argument(args, "question", "part add") + _require_question(draft, question, "part add") + return _fill( + draft, + markdown, + args, + by, + command="part add", + key=f"{_next(draft, f'{question}.p')}.text", + ) + + +@command("question solution") +def _question_solution( + draft: dict[str, Any], markdown: str, args: dict[str, Any], by: str +) -> str: + """Gives a question its worked solution, wherever in the source it is written.""" + question = _argument(args, "question", "question solution") + _require_question(draft, question, "question solution") + return _fill( + draft, + markdown, + args, + by, + command="question solution", + key=f"{question}.solution", + ) + + +@command("split block") +def _split_block( + draft: dict[str, Any], markdown: str, args: dict[str, Any], by: str +) -> str: + """Cuts one block of the frozen source in two, so each half can be named. + + A block is whatever the parser made of the source, which is sometimes two things: a + question and the part under it, written with no blank line between them. The source + is untouched and the halves are ``b3a`` and ``b3b``, so a replay, which rebuilds the + blocks from the markdown and then runs the log over them, arrives at the same ids. + """ + block = _argument(args, "block", "split block") + at = _argument(args, "at", "split block", int) + found = _block(draft, block) + if not found["start"] < at <= found["end"]: + raise NoSuchLines( + f"{block} is lines {found['start']}-{found['end']}, so it cannot be split " + f"at line {at}: the line split at is the first line of the second half, and " + "each half has to have a line in it." + ) + index = draft["blocks"].index(found) + draft["blocks"][index : index + 1] = [ + {**found, "id": f"{block}a", "end": at - 1}, + {**found, "id": f"{block}b", "start": at}, + ] + return f"{block}a and {block}b" diff --git a/in2lambda/main.py b/in2lambda/main.py index 8ee7fe0..1d1ea2e 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -9,9 +9,12 @@ import getpass import importlib import shlex -from collections.abc import Iterator # Rather than typing's, which beartype warns on. +from collections.abc import ( # Rather than typing's, which beartype warns on. + Callable, + Iterator, +) from contextlib import contextmanager -from typing import Optional +from typing import Any, Optional import rich_click as click @@ -23,7 +26,7 @@ # All four are in other people's scripts as in2lambda.main names, whether or not they # are used here: `_pandoc` and `file_type` were defined here before there was an # in2lambda.source, and `ConversionToolsMissing` is what `runner` documents raising. -from in2lambda.source import ( +from in2lambda.source import ( # noqa: F401 # Re-exported, so not unused. ConversionToolsMissing, SourceError, _pandoc, @@ -242,6 +245,51 @@ def draft_group() -> None: """Builds up the draft in this directory, recording every command in it.""" +_by = click.option( + "--by", + default=getpass.getuser, + help="Who to record the command as having been run by. [default: your username]", +) +"""Who ran a draft command, which every one of them records.""" + + +def _text_or_literal(command: Callable[..., None]) -> Callable[..., None]: + """The two ways to fill a field: quoted from the frozen source, or typed out.""" + for option in ( + click.option( + "--literal", + help="The text itself, where the source does not say it in a form the " + "field can take. Marks the field as edited.", + ), + click.option( + "--text", + help="Where in the frozen source the text is: a block id such as b3, or " + "lines such as s10:14. Run in2lambda source show to see both.", + ), + ): + command = option(command) + return command + + +def _run(command: str, args: dict[str, Any], by: str) -> None: + """Runs one draft command against the draft here and says what it wrote. + + Arguments nobody gave are left out rather than recorded as nulls: the log is what a + replay runs, and an option that was not passed is not an argument of the command. + """ + with _message_not_traceback(): + written = in2lambda.draft.execute( + { + "command": command, + "args": { + name: given for name, given in args.items() if given is not None + }, + "by": by, + } + ) + click.echo(f"Wrote {written}.") + + @draft_group.group("mark") def draft_mark() -> None: """Says what to make of a block of the frozen source.""" @@ -249,17 +297,68 @@ def draft_mark() -> None: @draft_mark.command("ignore") @click.argument("block") -@click.option( - "--by", - default=getpass.getuser, - help="Who to record the command as having been run by. [default: your username]", -) +@_by def draft_mark_ignore(block: str, by: str) -> None: """Marks BLOCK as nothing to take a question from.""" - with _message_not_traceback(): - in2lambda.draft.execute( - {"command": "mark ignore", "args": {"block": block}, "by": by} - ) + _run("mark ignore", {"block": block}, by) + + +@draft_group.group("question") +def draft_question() -> None: + """Adds a question to the draft, or says where its solution is written.""" + + +@draft_question.command("add") +@_text_or_literal +@_by +def draft_question_add(text: Optional[str], literal: Optional[str], by: str) -> None: + """Adds a question, numbered after the ones already there.""" + _run("question add", {"text": text, "literal": literal}, by) + + +@draft_question.command("solution") +@click.argument("question") +@_text_or_literal +@_by +def draft_question_solution( + question: str, text: Optional[str], literal: Optional[str], by: str +) -> None: + """Gives QUESTION the worked solution written at --text or --literal.""" + _run( + "question solution", + {"question": question, "text": text, "literal": literal}, + by, + ) + + +@draft_group.group("part") +def draft_part() -> None: + """Adds a part to a question of the draft.""" + + +@draft_part.command("add") +@click.argument("question") +@_text_or_literal +@_by +def draft_part_add( + question: str, text: Optional[str], literal: Optional[str], by: str +) -> None: + """Adds a part of QUESTION, numbered after the parts it already has.""" + _run("part add", {"question": question, "text": text, "literal": literal}, by) + + +@draft_group.group("split") +def draft_split() -> None: + """Cuts up a block of the frozen source that is really two things.""" + + +@draft_split.command("block") +@click.argument("block") +@click.argument("at", type=int) +@_by +def draft_split_block(block: str, at: int, by: str) -> None: + """Splits BLOCK in two, the second half starting at line AT.""" + _run("split block", {"block": block, "at": at}, by) @draft_group.command("replay") diff --git a/in2lambda/source/__init__.py b/in2lambda/source/__init__.py index e732074..bd2d6dc 100644 --- a/in2lambda/source/__init__.py +++ b/in2lambda/source/__init__.py @@ -18,11 +18,35 @@ import subprocess from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import Any, Optional DRAFT = "draft.json" """What a frozen source is written to, beside the source itself.""" + +def _field_fault(field: Any) -> str: + """What is wrong with the shape of one field of a draft, or "" if nothing is. + + Only ``ranges`` is looked inside for, because it is the only part of a field + anything here reads: `in2lambda.draft.record` compares the lines a command is + quoting against the lines every field was taken from. The value, the layer, whether + it was edited and by whom are written and read back whole, and an edit to any of + them is what a replay catches byte for byte. + """ + if not isinstance(field, dict): + return "is not an object" + if "ranges" not in field: + return "has no ranges" + if not isinstance(field["ranges"], list) or not all( + isinstance(pair, list) + and len(pair) == 2 + and all(isinstance(line, int) for line in pair) + for pair in field["ranges"] + ): + return f"has ranges {field['ranges']!r} rather than pairs of line numbers" + return "" + + _FIELDS = ("source", "hash", "blocks", "log", "fields") """What a draft has in it, and so what one has to have for anything here to read it. @@ -216,6 +240,12 @@ def _draft(path: Path) -> dict[str, Any]: f"{path} is not a draft anything here wrote: its {field} is " f"{draft[field]!r} rather than {called}. {advice}" ) + for key, field in draft["fields"].items(): + if fault := _field_fault(field): + raise DraftUnreadable( + f"{path} is not a draft anything here wrote: its fields has {key} " + f"that {fault}. {advice}" + ) return draft @@ -422,6 +452,11 @@ def add(file: str, start_over: bool = False) -> Path: # --start-over is the way to throw them away, and the only one. log: list[Any] = [] fields: dict[str, Any] = {} + # The blocks a draft already here has, which are not always what parsing the + # markdown gives: `split block` cuts one in two, and parsing again would undo that + # while keeping the log entry saying it happened, leaving the ids the fields were + # written against naming nothing. + found: Optional[list[Any]] = None if not start_over: if draft.is_file(): @@ -432,7 +467,7 @@ def add(file: str, start_over: bool = False) -> Path: "Run in2lambda source add --start-over to freeze it again, which " "invalidates every line range taken from the old draft." ) - log, fields = existing["log"], existing["fields"] + found, log, fields = existing["blocks"], existing["log"], existing["fields"] elif frozen_path != source and frozen_path.exists(): raise DraftExists( f"{frozen_path.name} is already there and no {DRAFT} claims it, so it " @@ -443,7 +478,8 @@ def add(file: str, start_over: bool = False) -> Path: # Before either file is written: a parse that fails half way through would # otherwise leave the markdown there with no draft claiming it, and the next run # would refuse to touch a file this one wrote. - found = [block.to_dict() for block in blocks(markdown)] + if found is None: + found = [block.to_dict() for block in blocks(markdown)] if frozen_path != source: # The bytes pandoc wrote, so that the file on disk is what `digest` is of; diff --git a/tests/fixtures/drafts/README.md b/tests/fixtures/drafts/README.md index 45494ce..5c7902b 100644 --- a/tests/fixtures/drafts/README.md +++ b/tests/fixtures/drafts/README.md @@ -7,4 +7,8 @@ its log and checks the file is unchanged byte for byte - so a folder covers both writes and that it can be rebuilt from what it recorded. To cover another command, add a folder. `mark_ignore` is the `sources/markdown` document with two -of its blocks marked as nothing to take a question from. +of its blocks marked as nothing to take a question from. `two_questions` is a sheet with a title, a +rubric, two questions with a part each and a separate solutions section, written out by every +command there is: the second question runs into its part with no blank line between them, so the +parser makes one block of the two and `split block` cuts it, and the first question's part is typed +out rather than quoted, because the source writes it with an `(a)` the field should not carry. diff --git a/tests/fixtures/drafts/two_questions/commands.json b/tests/fixtures/drafts/two_questions/commands.json new file mode 100644 index 0000000..67a89e5 --- /dev/null +++ b/tests/fixtures/drafts/two_questions/commands.json @@ -0,0 +1,77 @@ +[ + { + "args": { + "block": "b1" + }, + "by": "tests", + "command": "mark ignore" + }, + { + "args": { + "block": "b2" + }, + "by": "tests", + "command": "mark ignore" + }, + { + "args": { + "text": "b3" + }, + "by": "tests", + "command": "question add" + }, + { + "args": { + "literal": "State the continuity equation for the pipe.", + "question": "q1" + }, + "by": "tests", + "command": "part add" + }, + { + "args": { + "at": 12, + "block": "b5" + }, + "by": "tests", + "command": "split block" + }, + { + "args": { + "text": "b5a" + }, + "by": "tests", + "command": "question add" + }, + { + "args": { + "question": "q2", + "text": "b5b" + }, + "by": "tests", + "command": "part add" + }, + { + "args": { + "block": "b6" + }, + "by": "tests", + "command": "mark ignore" + }, + { + "args": { + "question": "q1", + "text": "s16" + }, + "by": "tests", + "command": "question solution" + }, + { + "args": { + "question": "q2", + "text": "s18" + }, + "by": "tests", + "command": "question solution" + } +] diff --git a/tests/fixtures/drafts/two_questions/expected.json b/tests/fixtures/drafts/two_questions/expected.json new file mode 100644 index 0000000..f81e303 --- /dev/null +++ b/tests/fixtures/drafts/two_questions/expected.json @@ -0,0 +1,105 @@ +{ + "b1.ignore": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 1, + 1 + ] + ], + "value": true + }, + "b2.ignore": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 3, + 3 + ] + ], + "value": true + }, + "b6.ignore": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 14, + 14 + ] + ], + "value": true + }, + "q1.p1.text": { + "by": "tests", + "edited": true, + "layer": 4, + "ranges": [], + "value": "State the continuity equation for the pipe." + }, + "q1.solution": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 16, + 16 + ] + ], + "value": "The flow rate is $Q = \\pi d^2 v / 4$." + }, + "q1.text": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 5, + 6 + ] + ], + "value": "Water flows through a horizontal pipe of diameter $d$ at speed $v$.\nFind the volume flow rate." + }, + "q2.p1.text": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 12, + 12 + ] + ], + "value": "(a) Give the drag coefficient you used." + }, + "q2.solution": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 18, + 18 + ] + ], + "value": "This one is $F = \\tfrac12 \\rho U^2 A C_d$." + }, + "q2.text": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 10, + 11 + ] + ], + "value": "A submarine is towed at speed $U$ through still water.\nFind the drag force on it." + } +} diff --git a/tests/fixtures/drafts/two_questions/source.md b/tests/fixtures/drafts/two_questions/source.md new file mode 100644 index 0000000..71e2b1e --- /dev/null +++ b/tests/fixtures/drafts/two_questions/source.md @@ -0,0 +1,18 @@ +# Pipe flow problems + +Answer both questions, and show your working. + +Water flows through a horizontal pipe of diameter $d$ at speed $v$. +Find the volume flow rate. + +(a) State the continuity equation for the pipe. + +A submarine is towed at speed $U$ through still water. +Find the drag force on it. +(a) Give the drag coefficient you used. + +## Solutions + +The flow rate is $Q = \pi d^2 v / 4$. + +This one is $F = \tfrac12 \rho U^2 A C_d$. diff --git a/tests/test_draft.py b/tests/test_draft.py index dceddbc..1f51e76 100644 --- a/tests/test_draft.py +++ b/tests/test_draft.py @@ -22,6 +22,9 @@ MARK_IGNORE = DRAFTS_DIR / "mark_ignore" """The case the tests below happen to use; what they check holds for any of them.""" +TWO_QUESTIONS = DRAFTS_DIR / "two_questions" +"""The one with questions written into it, which is what refusing a second one needs.""" + def _built(folder: Path, tmp_path: Path) -> Path: """A folder's document, frozen in `tmp_path` with its commands applied to it.""" @@ -51,12 +54,17 @@ def test_a_draft_built_by_commands_replays_identically( assert draft_path.read_bytes() == written +@pytest.mark.parametrize("folder", DRAFTS, ids=lambda path: path.name) def test_freezing_an_unchanged_source_again_keeps_what_the_commands_wrote( - tmp_path: Path, monkeypatch + folder: Path, tmp_path: Path, monkeypatch ) -> None: - """The lines have not moved, so the commands run against them still hold.""" + """The lines have not moved, so the commands run against them still hold. + + Over every folder rather than one of them, because a command that changes the blocks + rather than the fields - `split block` - is only kept if the draft is not rebuilt. + """ monkeypatch.chdir(tmp_path) - draft_path = _built(MARK_IGNORE, tmp_path) + draft_path = _built(folder, tmp_path) built = draft_path.read_bytes() runner = CliRunner() @@ -119,6 +127,17 @@ def test_a_log_naming_a_command_nothing_has_is_refused( ({"command": "mark ignore", "args": None, "by": "tests"}, "None"), ({"command": "mark ignore", "args": [], "by": "tests"}, "[]"), ({"command": "mark ignore", "args": {}, "by": "tests"}, "block"), + ( + { + "command": "question add", + "args": {"text": "s1", "literal": "Words."}, + "by": "tests", + }, + "literal", + ), + ({"command": "split block", "args": {"block": "b2"}, "by": "tests"}, "at"), + ({"command": "mark ignore", "args": {"block": 12}, "by": "tests"}, "12"), + ({"command": "question add", "args": {"text": 12}, "by": "tests"}, "12"), ], ids=[ "not an object", @@ -128,6 +147,10 @@ def test_a_log_naming_a_command_nothing_has_is_refused( "args is null", "args is a list", "no block argument", + "both a text and a literal", + "no at argument", + "a block that is a number", + "a text that is a number", ], ) def test_a_log_entry_that_is_not_a_command_is_refused( @@ -151,7 +174,21 @@ def test_a_log_entry_that_is_not_a_command_is_refused( @pytest.mark.parametrize( - ("field", "value"), [("log", 5), ("fields", [])], ids=["log", "fields"] + ("field", "value"), + [ + ("log", 5), + ("fields", []), + ("fields", {"b1.ignore": 5}), + ("fields", {"b1.ignore": {"ranges": "s1"}}), + ("fields", {"b1.ignore": {"ranges": [[1]]}}), + ], + ids=[ + "log", + "fields", + "a field that is a number", + "ranges that are not a list", + "a range that is not a pair", + ], ) @pytest.mark.parametrize( "arguments", @@ -209,3 +246,97 @@ def test_marking_a_block_that_is_not_there_says_so(tmp_path: Path, monkeypatch) assert "b99" in result.output assert "in2lambda source show" in result.output assert draft_path.read_bytes() == built + + +def test_lines_another_field_was_taken_from_are_refused( + tmp_path: Path, monkeypatch +) -> None: + """Two fields of the same lines is a mistake about one of them, so neither is guessed.""" + monkeypatch.setenv("COLUMNS", "200") + monkeypatch.chdir(tmp_path) + draft_path = _built(TWO_QUESTIONS, tmp_path) + built = draft_path.read_bytes() + + # Line 16 is where q1's solution came from, so it is not also a third question. + result = CliRunner().invoke(cli, ["draft", "question", "add", "--text", "s16"]) + + assert result.exit_code != 0 + # Both halves of it: which field has the lines, and which one wanted them. + assert "q1.solution" in result.output + assert "q3.text" in result.output + assert draft_path.read_bytes() == built + + +@pytest.mark.parametrize( + ("arguments", "named"), + [ + (["draft", "question", "add", "--text", "s99:100"], "s99:100"), + (["draft", "question", "add", "--text", "s6:5"], "s6:5"), + (["draft", "question", "add", "--text", "sixteen"], "sixteen"), + (["draft", "question", "add", "--text", "s8", "--literal", "Words."], "both"), + (["draft", "question", "add"], "neither"), + (["draft", "part", "add", "q9", "--text", "s8"], "q9"), + (["draft", "split", "block", "b3", "5"], "b3 is lines 5-6"), + (["draft", "split", "block", "b3", "7"], "b3 is lines 5-6"), + ], + ids=[ + "lines the source has not got", + "a range that runs backwards", + "a text that is no kind of address", + "a text and a literal", + "no text and no literal", + "a question nothing has written", + "a split at the line the block starts on", + "a split past the line it ends on", + ], +) +def test_a_command_naming_what_the_draft_has_not_got_is_refused( + arguments: list[str], named: str, tmp_path: Path, monkeypatch +) -> None: + """Every one of these is a typo, and a typo is a message rather than a field.""" + monkeypatch.setenv("COLUMNS", "200") + monkeypatch.chdir(tmp_path) + draft_path = _built(TWO_QUESTIONS, tmp_path) + built = draft_path.read_bytes() + + result = CliRunner().invoke(cli, arguments) + + assert result.exit_code != 0, result.output + assert named in result.output + assert draft_path.read_bytes() == built + + +def test_a_command_says_what_it_wrote(tmp_path: Path, monkeypatch) -> None: + """What a command wrote is what the next one names, so it is said rather than hunted.""" + monkeypatch.chdir(tmp_path) + draft_path = _built(TWO_QUESTIONS, tmp_path) + runner = CliRunner() + + result = runner.invoke(cli, ["draft", "question", "add", "--literal", "Words."]) + + assert result.exit_code == 0, result.output + assert result.output == "Wrote q3.text.\n" + # And the field of that name, so that what is said and what is written cannot part. + field = json.loads(draft_path.read_text())["fields"]["q3.text"] + assert (field["value"], field["layer"]) == ("Words.", 4) + + # A split writes no field, so what it names is the two blocks it left behind. + result = runner.invoke(cli, ["draft", "split", "block", "b3", "6"]) + + assert result.exit_code == 0, result.output + assert result.output == "Wrote b3a and b3b.\n" + + +def test_the_halves_of_a_split_block_are_blocks_like_any_other( + tmp_path: Path, monkeypatch +) -> None: + """Splitting is only worth anything if what it leaves can be quoted by its id.""" + monkeypatch.chdir(tmp_path) + _built(TWO_QUESTIONS, tmp_path) + + result = CliRunner().invoke(cli, ["source", "show"]) + + assert result.exit_code == 0, result.output + # In the margin against the first line of each half, which is where the ids are. + assert "b5a 10" in result.output + assert "b5b 12" in result.output