codeplain 0.3.9__py3-none-any.whl → 0.3.10.dev2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ """Tests for what a headless render says while it is running.
2
+
3
+ Headless suppresses Rich output and attaches no TUI handler, so before this the only
4
+ sink was the log file — the process was silent on stdout for its entire run and a
5
+ CI job could not tell a render wedged for hours from a healthy one until the file was
6
+ collected at the end. One four-hour render produced 858 lines, all of them in its first
7
+ two minutes.
8
+
9
+ The second half of this file guards the hazard that adding a second handler exposes:
10
+ one record is handed to every handler in turn, so a formatter that rewrites
11
+ `record.msg` in place corrupts the output of the handlers after it.
12
+ """
13
+
14
+ import logging
15
+ import sys
16
+ from unittest.mock import MagicMock
17
+
18
+ import pytest
19
+
20
+ from plain2code import setup_logging
21
+ from plain2code_console import console
22
+ from plain2code_logger import (
23
+ LOGGER_NAME,
24
+ ElapsedTimeFormatter,
25
+ IndentedFormatter,
26
+ stop_narrating,
27
+ )
28
+
29
+
30
+ @pytest.fixture
31
+ def run_state():
32
+ state = MagicMock()
33
+ state.get_live_render_time.return_value = 3661 # 01:01:01
34
+ return state
35
+
36
+
37
+ def record(message, args=None):
38
+ return logging.LogRecord(
39
+ name="codeplain",
40
+ level=logging.INFO,
41
+ pathname=__file__,
42
+ lineno=1,
43
+ msg=message,
44
+ args=args,
45
+ exc_info=None,
46
+ )
47
+
48
+
49
+ def test_the_elapsed_formatter_stamps_the_render_time(run_state):
50
+ assert ElapsedTimeFormatter(run_state).format(record("hello")) == "[01:01:01] INFO codeplain: hello"
51
+
52
+
53
+ def test_continuation_lines_are_indented_past_the_timestamp(run_state):
54
+ formatted = ElapsedTimeFormatter(run_state).format(record("first\nsecond"))
55
+
56
+ assert formatted == "[01:01:01] INFO codeplain: first\n second"
57
+
58
+
59
+ def test_formatting_twice_does_not_indent_twice(run_state):
60
+ """Two handlers share one record. Formatting must be idempotent from the record's
61
+ point of view, or the file log inherits the stdout log's indentation."""
62
+ formatter = ElapsedTimeFormatter(run_state)
63
+ entry = record("first\nsecond")
64
+
65
+ first_pass = formatter.format(entry)
66
+ second_pass = formatter.format(entry)
67
+
68
+ assert first_pass == second_pass
69
+
70
+
71
+ def test_two_different_formatters_do_not_corrupt_each_other(run_state):
72
+ """The real pairing in a headless render that also logs to a file."""
73
+ entry = record("first\nsecond")
74
+
75
+ IndentedFormatter("%(levelname)s:%(name)s:%(message)s").format(entry)
76
+ elapsed = ElapsedTimeFormatter(run_state).format(entry)
77
+
78
+ assert elapsed == "[01:01:01] INFO codeplain: first\n second"
79
+
80
+
81
+ def test_the_indented_formatter_leaves_the_record_alone(run_state):
82
+ entry = record("first\nsecond")
83
+
84
+ IndentedFormatter("%(levelname)s:%(name)s:%(message)s").format(entry)
85
+
86
+ assert entry.msg == "first\nsecond"
87
+
88
+
89
+ def test_arguments_are_interpolated_exactly_once(run_state):
90
+ """The copy carries an already-interpolated message, so its args must be cleared —
91
+ otherwise the parent formatter interpolates a second time and raises."""
92
+ assert ElapsedTimeFormatter(run_state).format(record("value is %s", ("x",))).endswith("value is x")
93
+
94
+
95
+ @pytest.fixture
96
+ def configured_handlers(run_state):
97
+ """setup_logging mutates the process-wide "codeplain" logger; restore it after."""
98
+ logger = logging.getLogger(LOGGER_NAME)
99
+ saved_handlers, saved_level = list(logger.handlers), logger.level
100
+
101
+ def configure(headless):
102
+ logger.handlers = []
103
+ args = MagicMock()
104
+ args.verbose = False
105
+ args.logging_config_path = None
106
+ setup_logging(args, MagicMock(), run_state, log_to_file=False, log_file_path="", headless=headless)
107
+ return logger.handlers
108
+
109
+ yield configure
110
+
111
+ logger.handlers, logger.level = saved_handlers, saved_level
112
+
113
+
114
+ def stdout_handlers(handlers):
115
+ return [h for h in handlers if type(h) is logging.StreamHandler and h.stream is sys.stdout]
116
+
117
+
118
+ def test_a_headless_render_narrates_to_stdout(configured_handlers):
119
+ assert len(stdout_handlers(configured_handlers(headless=True))) == 1
120
+
121
+
122
+ def test_the_stdout_handler_carries_the_elapsed_time_format(configured_handlers):
123
+ """It has to be readable as a render log, not just present."""
124
+ handler = stdout_handlers(configured_handlers(headless=True))[0]
125
+
126
+ assert isinstance(handler.formatter, ElapsedTimeFormatter)
127
+
128
+
129
+ def test_an_interactive_render_does_not_duplicate_output_on_stdout(configured_handlers):
130
+ """The TUI already draws the log; a second copy on stdout would fight it for the
131
+ terminal."""
132
+ assert stdout_handlers(configured_handlers(headless=False)) == []
133
+
134
+
135
+ def test_the_formatter_survives_a_run_state_that_cannot_report_time():
136
+ """A record can be logged before the render clock exists; it must still be readable."""
137
+ broken = MagicMock()
138
+ broken.get_live_render_time.side_effect = RuntimeError("no clock yet")
139
+
140
+ assert ElapsedTimeFormatter(broken).format(record("hello")) == "[00:00:00] INFO codeplain: hello"
141
+
142
+
143
+ @pytest.fixture
144
+ def quiet_console():
145
+ """Headless suppresses Rich output, so stdout carries the log sink and nothing else."""
146
+ saved, console.quiet = console.quiet, True
147
+ yield
148
+ console.quiet = saved
149
+
150
+
151
+ def test_specification_text_reaches_the_stdout_sink(configured_handlers, quiet_console, capsys):
152
+ """The narration carries the functional requirement, like every other record.
153
+
154
+ These logs are the user's own, on the user's own machine, and the specification is
155
+ theirs. Withholding it here buys nothing and costs them the detail that says which
156
+ requirement a wedged render is stuck on. The rule this sink was briefly written
157
+ against is about what *we* retain - see the Sentry and analytics scrubbers, which
158
+ keep specification text out of anything leaving the machine."""
159
+ configured_handlers(headless=True)
160
+
161
+ console.info("Rendering functionality 3: the user's whole functional requirement")
162
+ console.info("Running unit tests.")
163
+
164
+ printed = capsys.readouterr().out
165
+ assert "whole functional requirement" in printed
166
+ assert "Running unit tests." in printed
167
+
168
+
169
+ def test_narration_stops_before_the_exit_summary(configured_handlers, quiet_console, capsys):
170
+ """The summary bypasses logging, so a still-attached sink reports a failure twice."""
171
+ configured_handlers(headless=True)
172
+ capsys.readouterr()
173
+
174
+ stop_narrating()
175
+ console.info("this arrives after the render is over")
176
+
177
+ assert "after the render is over" not in capsys.readouterr().out
@@ -7,14 +7,16 @@ These tests build real ``PlainModule`` instances from fixtures in
7
7
 
8
8
  import json
9
9
  import os
10
+ import shutil
10
11
  import tempfile
12
+ import zipfile
11
13
  from pathlib import Path
12
14
 
13
15
  import pytest
14
16
 
15
17
  from change_detection import determine_partial_render_start
16
18
  from git_utils import FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE, add_all_files_and_commit, init_git_repo
17
- from plain2code_exceptions import ModuleDoesNotExistError
19
+ from plain2code_exceptions import InvalidModuleArchiveError, ModuleDoesNotExistError
18
20
  from plain_modules import MODULE_METADATA_FILENAME, PlainModule
19
21
 
20
22
  # --------------------------------------------------------------------------
@@ -361,6 +363,270 @@ def test_wipe_module_removes_whole_module_folder(solo_module):
361
363
  assert not os.path.exists(solo_module.module_folder)
362
364
 
363
365
 
366
+ # --------------------------------------------------------------------------
367
+ # Zipped modules (<module>.module archives)
368
+ # --------------------------------------------------------------------------
369
+
370
+
371
+ def _init_repo_with_finished_frid(repo_path: str, module_name: str, frid: str) -> None:
372
+ """Init a git repo at ``repo_path`` and commit a FUNCTIONAL_REQUIREMENT_FINISHED checkpoint."""
373
+ os.makedirs(repo_path, exist_ok=True)
374
+ init_git_repo(repo_path, module_name=module_name)
375
+ marker = Path(repo_path) / f"frid_{frid}.txt"
376
+ marker.write_text(f"frid {frid}\n")
377
+ add_all_files_and_commit(
378
+ repo_path,
379
+ FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format(frid),
380
+ module_name=module_name,
381
+ frid=frid,
382
+ )
383
+
384
+
385
+ def _setup_full_module(module: PlainModule, frid: str = "1") -> None:
386
+ """Create a realistic on-disk module: code/ and tests/ git repos plus metadata."""
387
+ _init_repo_with_finished_frid(module.module_build_folder, module.module_name, frid)
388
+ _init_repo_with_finished_frid(module.module_conformance_tests_folder, module.module_name, frid)
389
+ _write_metadata(module, {"source_hash": "seed", "functionalities": ["fr1"]})
390
+
391
+
392
+ def _zip_module_flat(module_folder: str, archive_path: str) -> None:
393
+ """Zip the *contents* of ``module_folder`` (code/, tests/, .codeplain/ at the archive
394
+ root, including .git) into ``archive_path``."""
395
+ with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zf:
396
+ for root, _dirs, files in os.walk(module_folder):
397
+ for name in files:
398
+ full = os.path.join(root, name)
399
+ zf.write(full, os.path.relpath(full, module_folder))
400
+
401
+
402
+ def _archive_module(module: PlainModule) -> str:
403
+ """Turn an on-disk module into an archive-only module: zip it, then remove the folder."""
404
+ archive_path = module.module_archive_path
405
+ _zip_module_flat(module.module_folder, archive_path)
406
+ shutil.rmtree(module.module_folder)
407
+ return archive_path
408
+
409
+
410
+ def test_folder_takes_precedence_over_archive(solo_module):
411
+ """When both the folder and the archive exist, the folder is used and the archive untouched."""
412
+ _setup_full_module(solo_module)
413
+ # Create a stray archive alongside the real folder.
414
+ with zipfile.ZipFile(solo_module.module_archive_path, "w") as zf:
415
+ zf.writestr("code/marker.txt", "x")
416
+
417
+ default = solo_module._default_module_folder
418
+ solo_module.materialize()
419
+
420
+ assert solo_module.module_folder == default
421
+ assert solo_module.module_build_folder == os.path.join(default, "code")
422
+ assert os.path.isfile(solo_module.module_archive_path) # untouched
423
+
424
+
425
+ def test_materialize_resolves_paths_to_scratch(solo_module):
426
+ _setup_full_module(solo_module)
427
+ default = solo_module._default_module_folder
428
+ _archive_module(solo_module)
429
+
430
+ assert solo_module.is_archived_only()
431
+ solo_module.materialize()
432
+
433
+ # Paths now resolve into a scratch extraction, not the real (absent) folder.
434
+ assert solo_module.module_folder != default
435
+ assert not os.path.exists(default) # real folder NOT created
436
+ assert os.path.isfile(solo_module.module_archive_path) # archive preserved
437
+ assert os.path.isdir(os.path.join(solo_module.module_build_folder, ".git"))
438
+ assert os.path.isdir(os.path.join(solo_module.module_conformance_tests_folder, ".git"))
439
+ # Git-backed reads work against the scratch tree.
440
+ name, frid = solo_module.get_module_render_status()
441
+ assert name == solo_module.module_name
442
+ assert frid == "1"
443
+
444
+ solo_module.cleanup_scratch()
445
+ assert solo_module.module_folder == default # resolution reset
446
+
447
+
448
+ def test_get_repo_none_before_materialize_and_present_after(solo_module):
449
+ """Guards the skip-detection bug: an archive-only module reads as "no repo" until materialized,
450
+ so the orchestrator's `get_repo() is not None` check only passes after materialize()."""
451
+ _setup_full_module(solo_module)
452
+ _archive_module(solo_module)
453
+
454
+ assert solo_module.get_repo() is None # would wrongly force a re-render if consumed as-is
455
+ solo_module.materialize()
456
+ assert solo_module.get_repo() is not None # now recognised as already rendered
457
+
458
+ solo_module.cleanup_scratch()
459
+
460
+
461
+ def test_materialize_is_idempotent(solo_module):
462
+ _setup_full_module(solo_module)
463
+ _archive_module(solo_module)
464
+ solo_module.materialize()
465
+ first = solo_module.module_folder
466
+ solo_module.materialize()
467
+ assert solo_module.module_folder == first
468
+ solo_module.cleanup_scratch()
469
+
470
+
471
+ def test_ensure_module_unpacked_from_archive(solo_module):
472
+ _setup_full_module(solo_module)
473
+ default = solo_module._default_module_folder
474
+ _archive_module(solo_module)
475
+
476
+ solo_module.ensure_module_unpacked()
477
+
478
+ assert solo_module.module_folder == default
479
+ assert os.path.isdir(os.path.join(default, "code", ".git"))
480
+ assert os.path.isdir(os.path.join(default, "tests", ".git"))
481
+ assert not os.path.exists(solo_module.module_archive_path) # archive removed
482
+ # Idempotent: a second call is a no-op.
483
+ solo_module.ensure_module_unpacked()
484
+ assert os.path.isdir(default)
485
+
486
+
487
+ def test_ensure_module_unpacked_after_materialize(solo_module):
488
+ """materialize() then ensure_module_unpacked() lands content in the real folder."""
489
+ _setup_full_module(solo_module)
490
+ default = solo_module._default_module_folder
491
+ _archive_module(solo_module)
492
+
493
+ solo_module.materialize()
494
+ solo_module.ensure_module_unpacked()
495
+
496
+ assert solo_module.module_folder == default
497
+ assert os.path.isdir(os.path.join(default, "code", ".git"))
498
+ assert not os.path.exists(solo_module.module_archive_path)
499
+
500
+
501
+ def test_ensure_module_unpacked_noop_when_folder_exists(solo_module):
502
+ """A stray archive next to an existing folder is removed; the folder is kept."""
503
+ _setup_full_module(solo_module)
504
+ with zipfile.ZipFile(solo_module.module_archive_path, "w") as zf:
505
+ zf.writestr("code/marker.txt", "x")
506
+
507
+ solo_module.ensure_module_unpacked()
508
+
509
+ assert os.path.isdir(solo_module.module_build_folder)
510
+ assert not os.path.exists(solo_module.module_archive_path)
511
+
512
+
513
+ def test_wipe_module_removes_archive_and_scratch(solo_module):
514
+ _setup_full_module(solo_module)
515
+ _archive_module(solo_module)
516
+ solo_module.materialize()
517
+ scratch = solo_module._scratch_dir
518
+
519
+ solo_module.wipe_module()
520
+
521
+ assert not os.path.exists(solo_module.module_archive_path)
522
+ assert scratch is not None and not os.path.exists(scratch)
523
+ assert solo_module.module_folder == solo_module._default_module_folder
524
+
525
+
526
+ def test_render_status_parity_folder_vs_archive(fixtures_dir, tmp_build_folder):
527
+ """The same module (same logical path) reports the same status and code hash whether it
528
+ is read as an unpacked folder or from a "<module>.module" archive."""
529
+ module = PlainModule("pr_solo.plain", tmp_build_folder, [fixtures_dir])
530
+ _setup_full_module(module, frid="1")
531
+ folder_status = module.get_module_render_status()
532
+ folder_hash = module.get_module_code_hash()
533
+
534
+ _archive_module(module) # zip in place, remove the folder
535
+ module.materialize()
536
+
537
+ assert module.get_module_render_status() == folder_status
538
+ assert module.get_module_code_hash() == folder_hash
539
+ module.cleanup_scratch()
540
+
541
+
542
+ def test_code_hash_is_location_independent(fixtures_dir):
543
+ """The code hash depends only on content, not the build folder's absolute path, so it is stable
544
+ across directories/machines. This is what makes .module archives distributable: consuming the
545
+ same code at a different location must not report a spurious 'required module code changed'."""
546
+ with tempfile.TemporaryDirectory() as build_a, tempfile.TemporaryDirectory() as build_b:
547
+ mod_a = PlainModule("pr_solo.plain", build_a, [fixtures_dir])
548
+ mod_b = PlainModule("pr_solo.plain", build_b, [fixtures_dir])
549
+ for mod in (mod_a, mod_b):
550
+ os.makedirs(mod.module_build_folder, exist_ok=True)
551
+ (Path(mod.module_build_folder) / "main.py").write_text("print('hi')\n")
552
+
553
+ assert mod_a.module_build_folder != mod_b.module_build_folder
554
+ assert mod_a.get_module_code_hash() == mod_b.get_module_code_hash()
555
+
556
+
557
+ def test_archive_has_conformance_tests(solo_module):
558
+ _setup_full_module(solo_module) # code/ + tests/
559
+ _archive_module(solo_module)
560
+ assert solo_module.archive_has_conformance_tests() is True
561
+
562
+
563
+ def test_code_only_archive_materializes_without_tests(solo_module):
564
+ """A module rendered with conformance tests off has no tests/, so its archive has none. It must
565
+ still validate and materialize (tests/ is optional)."""
566
+ _init_repo_with_finished_frid(solo_module.module_build_folder, solo_module.module_name, "1")
567
+ _write_metadata(solo_module, {"source_hash": "seed"})
568
+ _archive_module(solo_module) # code/ + .codeplain, no tests/
569
+
570
+ assert solo_module.archive_has_conformance_tests() is False
571
+ assert solo_module.is_archived_only()
572
+
573
+ solo_module.materialize() # must not raise
574
+ assert os.path.isdir(os.path.join(solo_module.module_build_folder, ".git"))
575
+ assert not os.path.isdir(solo_module.module_conformance_tests_folder)
576
+ solo_module.cleanup_scratch()
577
+
578
+
579
+ def test_ensure_module_unpacked_code_only_archive(solo_module):
580
+ _init_repo_with_finished_frid(solo_module.module_build_folder, solo_module.module_name, "1")
581
+ _archive_module(solo_module) # code/ only
582
+
583
+ solo_module.ensure_module_unpacked()
584
+ assert os.path.isdir(os.path.join(solo_module._default_module_folder, "code", ".git"))
585
+ assert not os.path.isdir(os.path.join(solo_module._default_module_folder, "tests"))
586
+ assert not os.path.exists(solo_module.module_archive_path)
587
+
588
+
589
+ def test_materialize_rejects_non_zip(solo_module):
590
+ with open(solo_module.module_archive_path, "w") as f:
591
+ f.write("not a zip")
592
+ with pytest.raises(InvalidModuleArchiveError):
593
+ solo_module.materialize()
594
+ assert solo_module._scratch_dir is None
595
+
596
+
597
+ def test_materialize_rejects_missing_subfolders(solo_module):
598
+ with zipfile.ZipFile(solo_module.module_archive_path, "w") as zf:
599
+ zf.writestr("code/marker.txt", "x") # tests/ missing, and no .git
600
+ with pytest.raises(InvalidModuleArchiveError):
601
+ solo_module.materialize()
602
+
603
+
604
+ def test_materialize_rejects_missing_git_repo(solo_module):
605
+ """code/ and tests/ present but without a .git directory is rejected."""
606
+ with zipfile.ZipFile(solo_module.module_archive_path, "w") as zf:
607
+ zf.writestr("code/main.py", "print('x')")
608
+ zf.writestr("tests/test_main.py", "def test_x(): pass")
609
+ with pytest.raises(InvalidModuleArchiveError):
610
+ solo_module.materialize()
611
+
612
+
613
+ def test_materialize_rejects_zip_slip(solo_module):
614
+ with zipfile.ZipFile(solo_module.module_archive_path, "w") as zf:
615
+ zf.writestr("../escape.txt", "x")
616
+ with pytest.raises(InvalidModuleArchiveError):
617
+ solo_module.materialize()
618
+
619
+
620
+ def test_ensure_module_unpacked_leaves_no_partial_on_invalid_archive(solo_module):
621
+ default = solo_module._default_module_folder
622
+ with zipfile.ZipFile(solo_module.module_archive_path, "w") as zf:
623
+ zf.writestr("code/marker.txt", "x") # invalid layout
624
+ with pytest.raises(InvalidModuleArchiveError):
625
+ solo_module.ensure_module_unpacked()
626
+ assert not os.path.exists(default) # no half-written module folder
627
+ assert os.path.isfile(solo_module.module_archive_path) # archive preserved on failure
628
+
629
+
364
630
  # --------------------------------------------------------------------------
365
631
  # seed_module_metadata
366
632
  # --------------------------------------------------------------------------
@@ -36,7 +36,7 @@ def test_unknown_section():
36
36
  with pytest.raises(
37
37
  Exception,
38
38
  match=re.escape(
39
- "Syntax error at line 3: Invalid specification heading (`Unknown Section:`). Allowed headings: definitions, implementation reqs, test reqs, functional specs, acceptance tests"
39
+ "Syntax error at line 4: Invalid specification heading (`Unknown Section:`). Allowed headings: definitions, implementation reqs, test reqs, functional specs, acceptance tests"
40
40
  ),
41
41
  ):
42
42
  plain_file.parse_plain_source(plain_source, {}, [], [], [])
@@ -50,7 +50,7 @@ def test_duplicate_section():
50
50
  """
51
51
  with pytest.raises(
52
52
  Exception,
53
- match=re.escape("Syntax error at line 3: Duplicate specification heading (`definitions`)"),
53
+ match=re.escape("Syntax error at line 4: Duplicate specification heading (`definitions`)"),
54
54
  ):
55
55
  plain_file.parse_plain_source(plain_source, {}, [], [], [])
56
56
 
@@ -64,11 +64,48 @@ code block
64
64
  """
65
65
  with pytest.raises(
66
66
  Exception,
67
- match=re.escape("Syntax error at line 2: Invalid source structure (`code block`)"),
67
+ match=re.escape("Syntax error at line 3: Invalid source structure (`code block`)"),
68
68
  ):
69
69
  plain_file.parse_plain_source(plain_source, {}, [], [], [])
70
70
 
71
71
 
72
+ def test_syntax_error_line_number_accounts_for_frontmatter():
73
+ plain_source = """---
74
+ description: 'Plain file with frontmatter'
75
+ ---
76
+
77
+ ***definitions***
78
+
79
+ ***Unknown Section:***
80
+ """
81
+ with pytest.raises(
82
+ Exception,
83
+ match=re.escape("Syntax error at line 7: Invalid specification heading (`Unknown Section:`)"),
84
+ ):
85
+ plain_file.parse_plain_source(plain_source, {}, [], [], [])
86
+
87
+
88
+ def test_syntax_error_line_number_with_windows_line_endings():
89
+ plain_source = (
90
+ "---\r\n"
91
+ "description: 'Plain file with frontmatter'\r\n"
92
+ "---\r\n"
93
+ "\r\n"
94
+ "***definitions***\r\n"
95
+ "\r\n"
96
+ "***Unknown Section:***\r\n"
97
+ )
98
+ with pytest.raises(
99
+ Exception,
100
+ match=re.escape("Syntax error at line 7: Invalid specification heading (`Unknown Section:`)"),
101
+ ):
102
+ plain_file.parse_plain_source(plain_source, {}, [], [], [])
103
+
104
+
105
+ def test_normalize_line_endings_does_not_duplicate_newlines():
106
+ assert plain_file.normalize_line_endings("a\r\nb\rc\nd") == "a\nb\nc\nd"
107
+
108
+
72
109
  def test_plain_file_parser_with_comments(get_test_data_path):
73
110
  _, plain_sections, _ = plain_file.plain_file_parser(
74
111
  "plain_file_parser_with_comments.plain",
@@ -423,7 +460,7 @@ def test_acceptance_tests_top_level_rejected():
423
460
  with pytest.raises(
424
461
  PlainSyntaxError,
425
462
  match=re.escape(
426
- "Syntax error at line 1: acceptance tests heading should be nested under specific functional spec."
463
+ "Syntax error at line 2: acceptance tests heading should be nested under specific functional spec."
427
464
  ),
428
465
  ):
429
466
  plain_file.parse_plain_source(plain_source, {}, [], [], [])