From 71874aad9af7b26d80c33bd1edcb6e9cd520f9ee Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Sun, 20 Sep 2026 16:36:20 +0100 Subject: [PATCH] implement: Report a missing solution as a warning, not a refusal (t39) --- in2lambda/draft/export.py | 22 +++- in2lambda/draft/report.py | 104 ++++++++++++++---- in2lambda/main.py | 49 ++++++--- tests/fixtures/drafts/README.md | 9 +- tests/fixtures/drafts/empty_field/report.json | 25 +++++ .../fixtures/drafts/field_replace/report.json | 2 + tests/fixtures/drafts/mark_ignore/report.json | 5 + tests/fixtures/drafts/nested_list/report.json | 3 + .../drafts/part_without_solution/report.json | 1 + .../drafts/question_without_parts/report.json | 14 +++ .../drafts/questions_only/commands.json | 39 +++++++ .../drafts/questions_only/expected.json | 62 +++++++++++ .../drafts/questions_only/report.json | 38 +++++++ .../fixtures/drafts/questions_only/source.md | 9 ++ tests/test_draft.py | 22 +++- 15 files changed, 357 insertions(+), 47 deletions(-) create mode 100644 tests/fixtures/drafts/question_without_parts/report.json create mode 100644 tests/fixtures/drafts/questions_only/commands.json create mode 100644 tests/fixtures/drafts/questions_only/expected.json create mode 100644 tests/fixtures/drafts/questions_only/report.json create mode 100644 tests/fixtures/drafts/questions_only/source.md diff --git a/in2lambda/draft/export.py b/in2lambda/draft/export.py index 911fb0f..8958bf7 100644 --- a/in2lambda/draft/export.py +++ b/in2lambda/draft/export.py @@ -5,8 +5,10 @@ is the one place that reads the one as the other, so both what is written out and what is rendered for review come from the same reading of the draft. -:func:`build` refuses a draft the checks have not looked at, or have something to say -about. There is no timestamp in that: every command that changes a draft takes its +:func:`build` refuses a draft the checks have not looked at, or have found an error in; +what they found at level warning - a question or part nothing answers - it says and +exports anyway, since a sheet whose solutions are in another file or nowhere is still a +sheet. There is no timestamp in that: every command that changes a draft takes its report with it, so a draft holding one has been checked since it last changed, and `in2lambda.source.frozen` refuses one whose source has moved on underneath it. :func:`render` is gated on nothing, since looking at a draft is how what the checks @@ -21,6 +23,7 @@ from in2lambda.api.part import Part from in2lambda.api.question import Question from in2lambda.api.set import Set +from in2lambda.draft.report import errors from in2lambda.json_convert.json_convert import _question_stem, _question_title from in2lambda.source import DRAFT, ConversionToolsMissing, SourceError, frozen from in2lambda.validation import _IMAGE, pdf @@ -131,11 +134,15 @@ def build(directory: str = ".", output_dir: str = "out") -> Path: Raises: NotValidated: the draft has not been checked since it last changed, or the - checks found something. Either way what would be uploaded is not what + checks found an error in it. Either way what would be uploaded is not what anybody has looked at. MissingImage: a field refers to an image file that is not beside the draft. SourceError: the draft is missing, is not one of ours, or was written from markdown that has changed since. + + Warns: + UserWarning: once per finding the checks made at level warning, which is a + question or part the draft has no solution for. The set is written with it. """ draft, _ = frozen(directory) if "report" not in draft: @@ -143,13 +150,18 @@ def build(directory: str = ".", output_dir: str = "out") -> Path: f"{DRAFT} has not been validated since it last changed, so what it would " "export is what nothing has checked. Run in2lambda validate." ) - if draft["report"]: + if refusing := errors(draft["report"]): raise NotValidated( - "\n".join(finding["message"] for finding in draft["report"]) + "\n".join(finding["message"] for finding in refusing) + f"\n{DRAFT} is not exported while its report says this. Fix what it " "names, or mark the blocks it is about as ignored, and run in2lambda " "validate again." ) + for finding in draft["report"]: + # Said rather than refused: a sheet whose solutions are elsewhere or absent is + # one to export as it stands, and writing one in to quiet this would put wording + # into the set that no source of it says. + warnings.warn(finding["message"], stacklevel=2) exported = as_set(draft, directory) # The export carries every image a field refers to into media/, which is the only # place Lambda Feedback looks for one, so a file that is not there is not something diff --git a/in2lambda/draft/report.py b/in2lambda/draft/report.py index 0367d54..26751b7 100644 --- a/in2lambda/draft/report.py +++ b/in2lambda/draft/report.py @@ -11,6 +11,14 @@ deciding that is whoever is writing the draft's to do. Only what is in the draft is looked at - its blocks, its field keys, their ranges and their values - because what the text of a question says is `in2lambda.validation`'s, at export. + +Each finding carries the level it is found at, which is what `in2lambda.draft.export` +goes by. An error is the draft contradicting its own source - lines nothing accounts for, +two fields quoting the same ones, a numbering with a hole in it, a quotation of nothing - +and there is no sheet those are right about. A warning is something that may well be +right: half the sheets there are write their solutions in another file, or have none, so +a question nothing answers is said to whoever is building the set rather than stopping +them - inventing a solution to quiet it is the one thing nobody wanted. """ import re @@ -20,19 +28,29 @@ from in2lambda.source import DRAFT, frozen, save Finding = dict[str, Any] -"""One thing a check found: ``{"check", "field", "ranges", "message"}``. +"""One thing a check found: ``{"check", "level", "field", "ranges", "message"}``. -``check`` is which check found it, ``field`` the block id or field key it is about, -``ranges`` the lines in question as ``[[start, end], ...]``, and ``message`` a sentence -naming all of that, so that a line of the report can be acted on by itself. +``check`` is which check found it, ``level`` :data:`ERROR` or :data:`WARNING`, ``field`` +the block id or field key it is about, ``ranges`` the lines in question as +``[[start, end], ...]``, and ``message`` a sentence naming all of that, so that a line of +the report can be acted on by itself. """ +ERROR = "error" +"""A finding the draft cannot be exported over: it says something its source does not.""" + +WARNING = "warning" +"""A finding the export says and goes on past: it may be what the sheet really is.""" + _NUMBERED = re.compile(r"((?:q\d+\.p)|q)(\d+)\.text") """A question's or a part's text, split into what numbers it and the number.""" _PART = re.compile(r"(q\d+)\.p\d+\.text") """A part's text, and the question it belongs to.""" +_QUESTION = re.compile(r"(q\d+)\.text") +"""A question's text, and the question it is.""" + _UNPLACED = float("inf") """Where a finding about no particular line sorts: after every finding about one.""" @@ -114,6 +132,7 @@ def uncovered(draft: dict[str, Any]) -> list[Finding]: found.append( { "check": "uncovered", + "level": ERROR, "field": block["id"], "ranges": free, "message": f"{block['id']}{_where(free)} is in no field and not " @@ -134,6 +153,7 @@ def _overlaps(draft: dict[str, Any]) -> list[Finding]: return [ { "check": "overlap", + "level": ERROR, "field": key, "ranges": fields[key]["ranges"], "message": f"{key}{_where(fields[key]['ranges'])} and {other}" @@ -158,6 +178,7 @@ def _gaps(draft: dict[str, Any]) -> list[Finding]: return [ { "check": "gap", + "level": ERROR, "field": f"{prefix}{missing}.text", "ranges": [], "message": f"There is no {prefix}{missing}.text, though " @@ -170,28 +191,47 @@ def _gaps(draft: dict[str, Any]) -> list[Finding]: def _without_solutions(draft: dict[str, Any]) -> list[Finding]: - """Parts that nothing in the draft answers. + """Parts, and questions written without any, that nothing in the draft answers. A part is answered by its own solution or by the solution of the question it belongs - to, since a sheet often writes one worked solution covering every part at once. + to, since a sheet often writes one worked solution covering every part at once. A + question with parts is answered through them and is not reported itself; one with + none is a question in its own right, and is reported where nothing answers it. """ fields = draft["fields"] found = [] for key in sorted(fields): - if (named := _PART.fullmatch(key)) is None: - continue - part = key.removesuffix(".text") - if f"{part}.solution" in fields or f"{named[1]}.solution" in fields: - continue - found.append( - { - "check": "no-solution", - "field": part, - "ranges": fields[key]["ranges"], - "message": f"{part}{_where(fields[key]['ranges'])} has no solution: " - f"neither {part}.solution nor {named[1]}.solution is written.", - } - ) + if named := _PART.fullmatch(key): + part = key.removesuffix(".text") + if f"{part}.solution" in fields or f"{named[1]}.solution" in fields: + continue + found.append( + { + "check": "no-solution", + "level": WARNING, + "field": part, + "ranges": fields[key]["ranges"], + "message": f"{part}{_where(fields[key]['ranges'])} has no solution: " + f"neither {part}.solution nor {named[1]}.solution is written.", + } + ) + elif named := _QUESTION.fullmatch(key): + question = named[1] + if f"{question}.solution" in fields or any( + (belongs := _PART.fullmatch(other)) and belongs[1] == question + for other in fields + ): + continue + found.append( + { + "check": "no-solution", + "level": WARNING, + "field": question, + "ranges": fields[key]["ranges"], + "message": f"{question}{_where(fields[key]['ranges'])} has no " + f"solution: {question}.solution is not written, and it has no parts.", + } + ) return found @@ -200,6 +240,7 @@ def _empty(draft: dict[str, Any]) -> list[Finding]: return [ { "check": "empty", + "level": ERROR, "field": key, "ranges": field["ranges"], "message": f"{key}{_where(field['ranges'])} is empty.", @@ -218,7 +259,9 @@ def checks(draft: dict[str, Any]) -> list[Finding]: Returns: One :data:`Finding` per thing found, earliest line first and then by what it is about, with the findings about no particular line last. An empty list means the - draft covers its source once each, with nothing missing from its numbering. + draft covers its source once each, with nothing missing from its numbering; a + list holding only warnings is one `in2lambda.draft.export.build` says and + exports over. Examples: >>> from in2lambda.draft.report import checks @@ -245,6 +288,25 @@ def checks(draft: dict[str, Any]) -> list[Finding]: ) +def errors(findings: list[Finding]) -> list[Finding]: + """The findings of a report that a draft cannot be exported over. + + Args: + findings: A report, as :func:`checks` or :func:`validate` writes one. + + Returns: + Those at level :data:`ERROR`, in the order they were reported. The rest are + warnings, which `in2lambda.draft.export.build` says and exports anyway. + + Examples: + >>> from in2lambda.draft.report import errors + >>> report = [{"level": "warning"}, {"level": "error", "check": "gap"}] + >>> errors(report) + [{'level': 'error', 'check': 'gap'}] + """ + return [finding for finding in findings if finding["level"] == ERROR] + + def validate(directory: str = ".") -> list[Finding]: """Checks the draft in a directory over and writes the report into it. diff --git a/in2lambda/main.py b/in2lambda/main.py index 81319ba..4546d62 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -52,6 +52,23 @@ def _message_not_traceback() -> Iterator[None]: raise click.ClickException(str(error)) from None +@contextmanager +def _warnings_said() -> Iterator[None]: + """Echoes whatever is warned inside it as a line, as `runner` says its problems. + + What `build` and `render` warn about is something they wrote out anyway - a question + nothing answers, a question xelatex gave up on - so it belongs beside what they + wrote, and is said even where the command goes on to refuse for another reason. + """ + with warnings.catch_warnings(record=True) as said: + warnings.simplefilter("always") + try: + yield + finally: + for warning in said: + click.echo(f"Warning: {warning.message}") + + def docx_to_md(docx_file: str) -> str: """Converts .docx files to markdown. @@ -423,14 +440,20 @@ def validate() -> None: """Checks the draft in this directory over and writes the report into it. Reports source blocks in no field and not marked ignore, two fields taken from the - same lines, gaps in the numbering of the questions or their parts, parts nothing - answers, and fields holding nothing. Finding something is not a failure: the report - is written into draft.json either way, and replaced by the next one. + same lines, gaps in the numbering of the questions or their parts, and fields holding + nothing, all of which in2lambda build refuses; a question or part nothing answers is + reported as a warning, which it builds over. Finding something is not a failure: the + report is written into draft.json either way, and replaced by the next one. """ with _message_not_traceback(): report = in2lambda.draft.report.validate() for finding in report: - click.echo(finding["message"]) + # Marked as such, since the two are acted on differently and the report is often + # read off the terminal rather than out of the draft. + if finding["level"] == in2lambda.draft.report.WARNING: + click.echo(f"Warning: {finding['message']}") + else: + click.echo(finding["message"]) if not report: click.echo("Nothing to report.") @@ -453,9 +476,11 @@ def build(output_dir: str) -> None: """Writes the draft in this directory out as a Lambda Feedback set. Refused unless in2lambda validate has been run since the draft last changed and - found nothing, so that what is uploaded is what the checks have been over. + found no error, so that what is uploaded is what the checks have been over. What it + found at level warning - a question or part with no solution written for it - is + said, and the set written all the same. """ - with _message_not_traceback(): + with _message_not_traceback(), _warnings_said(): written = in2lambda.draft.export.build(output_dir=output_dir) click.echo(f"Wrote {written}") @@ -469,14 +494,10 @@ def render(output_dir: str) -> None: needs pandoc and xelatex. What the checks have to say about the draft is not asked: a draft is rendered to look at, including one there is something to fix in. """ - with _message_not_traceback(): - # As `runner` does: a question xelatex complains about is still written out, and - # what it refused is a line to read rather than a traceback. - with warnings.catch_warnings(record=True) as refused: - warnings.simplefilter("always") - written = in2lambda.draft.export.render(output_dir=output_dir) - for warning in refused: - click.echo(f"Warning: {warning.message}") + # A question xelatex complains about is still written out, and what it refused is a + # line to read rather than a traceback. + with _message_not_traceback(), _warnings_said(): + written = in2lambda.draft.export.render(output_dir=output_dir) for pdf in written: click.echo(f"Wrote {pdf}") diff --git a/tests/fixtures/drafts/README.md b/tests/fixtures/drafts/README.md index e94fbc4..acd33a9 100644 --- a/tests/fixtures/drafts/README.md +++ b/tests/fixtures/drafts/README.md @@ -3,7 +3,9 @@ Each folder here is one run: a `source.md` to freeze, the `commands.json` to apply to the draft of it, the `expected.json` those commands should leave in the draft's `fields`, and the `report.json` that `in2lambda validate` should then find in it - a folder with no `report.json` -is a draft with nothing wrong with it. The test freezes the source, applies each command, checks +is a draft with nothing wrong with it, and one whose findings are all at level `warning` is a +draft `in2lambda build` says them over and exports anyway, where a `level` of `error` refuses +it. The test freezes the source, applies each command, checks the draft over, compares the fields and the report, and then replays the draft from its log and checks the file is unchanged byte for byte - so a folder covers both what a command writes and that it can be rebuilt from what it recorded. @@ -32,5 +34,8 @@ it as a part of its own rather than dropping the wording. `nested_list` is a num 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. `question_without_parts` is a question -and nothing else, which the checks have nothing to say about: it is here because a question with +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. +`questions_only` is the sheet half the corpus is - a title, two questions, two parts under the +first of them and no solution written anywhere - so every one of its findings is a warning, and +it is the folder that says a draft with nothing answering it is still exported. diff --git a/tests/fixtures/drafts/empty_field/report.json b/tests/fixtures/drafts/empty_field/report.json index 015677f..c435686 100644 --- a/tests/fixtures/drafts/empty_field/report.json +++ b/tests/fixtures/drafts/empty_field/report.json @@ -1,7 +1,20 @@ [ + { + "check": "no-solution", + "field": "q2", + "level": "warning", + "message": "q2 (lines 2-2) has no solution: q2.solution is not written, and it has no parts.", + "ranges": [ + [ + 2, + 2 + ] + ] + }, { "check": "empty", "field": "q2.text", + "level": "error", "message": "q2.text (lines 2-2) is empty.", "ranges": [ [ @@ -9,5 +22,17 @@ 2 ] ] + }, + { + "check": "no-solution", + "field": "q1", + "level": "warning", + "message": "q1 (lines 3-3) has no solution: q1.solution is not written, and it has no parts.", + "ranges": [ + [ + 3, + 3 + ] + ] } ] diff --git a/tests/fixtures/drafts/field_replace/report.json b/tests/fixtures/drafts/field_replace/report.json index 30d6ccd..d36a251 100644 --- a/tests/fixtures/drafts/field_replace/report.json +++ b/tests/fixtures/drafts/field_replace/report.json @@ -2,6 +2,7 @@ { "check": "uncovered", "field": "b1", + "level": "error", "message": "b1 (lines 1-1) is in no field and not marked ignore.", "ranges": [ [ @@ -13,6 +14,7 @@ { "check": "uncovered", "field": "b3", + "level": "error", "message": "b3 (lines 6-6) is in no field and not marked ignore.", "ranges": [ [ diff --git a/tests/fixtures/drafts/mark_ignore/report.json b/tests/fixtures/drafts/mark_ignore/report.json index d23d4f2..08cd014 100644 --- a/tests/fixtures/drafts/mark_ignore/report.json +++ b/tests/fixtures/drafts/mark_ignore/report.json @@ -2,6 +2,7 @@ { "check": "uncovered", "field": "b2", + "level": "error", "message": "b2 (lines 3-3) is in no field and not marked ignore.", "ranges": [ [ @@ -13,6 +14,7 @@ { "check": "uncovered", "field": "b3", + "level": "error", "message": "b3 (lines 5-5) is in no field and not marked ignore.", "ranges": [ [ @@ -24,6 +26,7 @@ { "check": "uncovered", "field": "b4", + "level": "error", "message": "b4 (lines 6-6) is in no field and not marked ignore.", "ranges": [ [ @@ -35,6 +38,7 @@ { "check": "uncovered", "field": "b5", + "level": "error", "message": "b5 (lines 8-10) is in no field and not marked ignore.", "ranges": [ [ @@ -46,6 +50,7 @@ { "check": "uncovered", "field": "b6", + "level": "error", "message": "b6 (lines 12-12) is in no field and not marked ignore.", "ranges": [ [ diff --git a/tests/fixtures/drafts/nested_list/report.json b/tests/fixtures/drafts/nested_list/report.json index 6c931f3..c582b7d 100644 --- a/tests/fixtures/drafts/nested_list/report.json +++ b/tests/fixtures/drafts/nested_list/report.json @@ -2,6 +2,7 @@ { "check": "uncovered", "field": "b2", + "level": "error", "message": "b2 (lines 5-5, 8-8) is in no field and not marked ignore.", "ranges": [ [ @@ -17,6 +18,7 @@ { "check": "no-solution", "field": "q1.p1", + "level": "warning", "message": "q1.p1 (lines 6-7) has no solution: neither q1.p1.solution nor q1.solution is written.", "ranges": [ [ @@ -28,6 +30,7 @@ { "check": "no-solution", "field": "q1.p2", + "level": "warning", "message": "q1.p2 (lines 9-9) has no solution: neither q1.p2.solution nor q1.solution is written.", "ranges": [ [ diff --git a/tests/fixtures/drafts/part_without_solution/report.json b/tests/fixtures/drafts/part_without_solution/report.json index 49e68ab..02dde55 100644 --- a/tests/fixtures/drafts/part_without_solution/report.json +++ b/tests/fixtures/drafts/part_without_solution/report.json @@ -2,6 +2,7 @@ { "check": "no-solution", "field": "q1.p1", + "level": "warning", "message": "q1.p1 (lines 5-5) has no solution: neither q1.p1.solution nor q1.solution is written.", "ranges": [ [ diff --git a/tests/fixtures/drafts/question_without_parts/report.json b/tests/fixtures/drafts/question_without_parts/report.json new file mode 100644 index 0000000..b701bcd --- /dev/null +++ b/tests/fixtures/drafts/question_without_parts/report.json @@ -0,0 +1,14 @@ +[ + { + "check": "no-solution", + "field": "q1", + "level": "warning", + "message": "q1 (lines 1-1) has no solution: q1.solution is not written, and it has no parts.", + "ranges": [ + [ + 1, + 1 + ] + ] + } +] diff --git a/tests/fixtures/drafts/questions_only/commands.json b/tests/fixtures/drafts/questions_only/commands.json new file mode 100644 index 0000000..3f2b3c0 --- /dev/null +++ b/tests/fixtures/drafts/questions_only/commands.json @@ -0,0 +1,39 @@ +[ + { + "args": { + "block": "b1" + }, + "by": "tests", + "command": "mark ignore" + }, + { + "args": { + "text": "b2" + }, + "by": "tests", + "command": "question add" + }, + { + "args": { + "question": "q1", + "text": "b3" + }, + "by": "tests", + "command": "part add" + }, + { + "args": { + "question": "q1", + "text": "b4" + }, + "by": "tests", + "command": "part add" + }, + { + "args": { + "text": "b5" + }, + "by": "tests", + "command": "question add" + } +] diff --git a/tests/fixtures/drafts/questions_only/expected.json b/tests/fixtures/drafts/questions_only/expected.json new file mode 100644 index 0000000..b141282 --- /dev/null +++ b/tests/fixtures/drafts/questions_only/expected.json @@ -0,0 +1,62 @@ +{ + "b1.ignore": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 1, + 1 + ] + ], + "value": true + }, + "q1.p1.text": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 5, + 5 + ] + ], + "value": "Find the time of flight." + }, + "q1.p2.text": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 7, + 7 + ] + ], + "value": "Find the horizontal range." + }, + "q1.text": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 3, + 3 + ] + ], + "value": "A ball is thrown at speed $u$ at an angle $\\theta$ to the horizontal." + }, + "q2.text": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 9, + 9 + ] + ], + "value": "A train brakes uniformly from $30$ m/s to rest in $20$ s. Find the deceleration." + } +} diff --git a/tests/fixtures/drafts/questions_only/report.json b/tests/fixtures/drafts/questions_only/report.json new file mode 100644 index 0000000..f70fb6b --- /dev/null +++ b/tests/fixtures/drafts/questions_only/report.json @@ -0,0 +1,38 @@ +[ + { + "check": "no-solution", + "field": "q1.p1", + "level": "warning", + "message": "q1.p1 (lines 5-5) has no solution: neither q1.p1.solution nor q1.solution is written.", + "ranges": [ + [ + 5, + 5 + ] + ] + }, + { + "check": "no-solution", + "field": "q1.p2", + "level": "warning", + "message": "q1.p2 (lines 7-7) has no solution: neither q1.p2.solution nor q1.solution is written.", + "ranges": [ + [ + 7, + 7 + ] + ] + }, + { + "check": "no-solution", + "field": "q2", + "level": "warning", + "message": "q2 (lines 9-9) has no solution: q2.solution is not written, and it has no parts.", + "ranges": [ + [ + 9, + 9 + ] + ] + } +] diff --git a/tests/fixtures/drafts/questions_only/source.md b/tests/fixtures/drafts/questions_only/source.md new file mode 100644 index 0000000..07c803d --- /dev/null +++ b/tests/fixtures/drafts/questions_only/source.md @@ -0,0 +1,9 @@ +# Projectile and braking problems + +A ball is thrown at speed $u$ at an angle $\theta$ to the horizontal. + +(a) Find the time of flight. + +(b) Find the horizontal range. + +A train brakes uniformly from $30$ m/s to rest in $20$ s. Find the deceleration. diff --git a/tests/test_draft.py b/tests/test_draft.py index 27349dc..7d52338 100644 --- a/tests/test_draft.py +++ b/tests/test_draft.py @@ -3,7 +3,9 @@ Each folder in ``fixtures/drafts`` is a document, the commands to run against its draft and the fields they should write, so covering another command means adding a folder rather than a test. Each is also what `in2lambda build` and `in2lambda render` make of -it: the one with no ``report.json`` is exported, the rest are refused. The remainder is +it: one whose ``report.json`` holds a finding at level error is refused, and one holding +only warnings - a question or part nothing answers - is exported with them said. The +remainder is what the command line does when a replay cannot be trusted - a source that has moved on, a log naming a command nothing has, a draft edited by hand - which is not something a fixture can say. @@ -473,6 +475,8 @@ def test_a_draft_edited_into_an_overlap_or_a_gap_is_reported( ("overlap", "q1.p1.text"), ("gap", "q2.text"), ] + # Both are the draft contradicting its own source, so neither is one to export over. + assert [finding["level"] for finding in report] == ["error", "error"] # Both sides of the overlap, so that either field can be looked at without the draft. assert "q1.text" in report[0]["message"] assert result.output == f"{report[0]['message']}\n{report[1]['message']}\n" @@ -541,23 +545,31 @@ def test_build_refuses_a_draft_that_has_not_been_validated( @pytest.mark.parametrize("folder", DRAFTS, ids=lambda path: path.name) def test_build_follows_the_report(folder: Path, tmp_path: Path, monkeypatch) -> None: - """A draft is exported once the checks have been over it and found nothing.""" + """A draft is exported once the checks have found no error in it. + + A warning does not stop it: a sheet whose solutions are in another file or nowhere at + all is a sheet to export, and is exported with what was found said beside it. + """ monkeypatch.setenv("COLUMNS", "200") monkeypatch.chdir(tmp_path) _built(folder, tmp_path) fields = json.loads((folder / "expected.json").read_text()) + report = _reported(folder) result = CliRunner().invoke(cli, ["build"]) - if report := _reported(folder): + if refusing := [finding for finding in report if finding["level"] == "error"]: assert result.exit_code != 0, result.output - # Every finding, so that what is left to do can be read off the refusal itself. - for finding in report: + # Every error, so that what is left to do can be read off the refusal itself. + for finding in refusing: assert finding["message"] in result.output assert not (tmp_path / "out").exists() return assert result.exit_code == 0, result.output + # And each warning said, since the set was written over it rather than without it. + for finding in report: + assert finding["message"] in result.output exported = tmp_path / "out" / "set.zip" assert exported.is_file()