Skip to content

Commit 2a30c99

Browse files
committed
backend/sdoc: allow using "DESCRIPTION" or "CONTENT" field instead of "STATEMENT"
Closes #1823
1 parent bd0968d commit 2a30c99

File tree

20 files changed

+267
-44
lines changed

20 files changed

+267
-44
lines changed

strictdoc/backend/sdoc/error_handling.py

+27-3
Original file line numberDiff line numberDiff line change
@@ -261,12 +261,36 @@ def grammar_missing_reserved_statement(
261261
return StrictDocSemanticError(
262262
title=(
263263
f"Grammar element '{grammar_element.tag}' is missing a reserved"
264-
" STATEMENT field declaration."
264+
" content field declaration, one of {STATEMENT, DESCRIPTION, CONTENT}."
265265
),
266266
hint=(
267-
"STATEMENT plays a key role in StrictDoc's HTML user interface "
267+
"A content field plays a key role in StrictDoc's HTML user interface "
268268
"as well as in the other export formats. It is a reserved field"
269-
" that any grammar must provide."
269+
" that any grammar element must have."
270+
),
271+
example=None,
272+
line=line,
273+
col=column,
274+
filename=path_to_sdoc_file,
275+
)
276+
277+
@staticmethod
278+
def grammar_reserved_statement_must_be_required(
279+
grammar_element: GrammarElement,
280+
field_title: str,
281+
path_to_sdoc_file: str,
282+
line: int,
283+
column: int,
284+
):
285+
return StrictDocSemanticError(
286+
title=(
287+
f"Grammar element '{grammar_element.tag}'s {field_title} field "
288+
f"must be declared as 'REQUIRED: True'."
289+
),
290+
hint=(
291+
"A content field plays a key role in StrictDoc's HTML user interface "
292+
"as well as in the other export formats. It is a reserved field"
293+
" that any grammar element must have with 'REQUIRED: True'."
270294
),
271295
example=None,
272296
line=line,

strictdoc/backend/sdoc/models/document_grammar.py

+21-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# mypy: disable-error-code="no-redef,no-untyped-call,no-untyped-def,union-attr,type-arg"
22
from collections import OrderedDict
3-
from typing import Dict, List, Optional, Set, Union
3+
from typing import Dict, List, Optional, Set, Tuple, Union
44

55
from strictdoc.backend.sdoc.models.type_system import (
66
RESERVED_NON_META_FIELDS,
@@ -75,9 +75,27 @@ def __init__(
7575
else create_default_relations(self)
7676
)
7777
fields_map: OrderedDict = OrderedDict()
78-
for field in fields:
79-
fields_map[field.title] = field
78+
79+
statement_field: Optional[Tuple[str, int]] = None
80+
description_field: Optional[Tuple[str, int]] = None
81+
content_field: Optional[Tuple[str, int]] = None
82+
for field_idx_, field_ in enumerate(fields):
83+
fields_map[field_.title] = field_
84+
if field_.title == RequirementFieldName.STATEMENT:
85+
statement_field = (RequirementFieldName.STATEMENT, field_idx_)
86+
elif field_.title == "DESCRIPTION":
87+
description_field = (
88+
RequirementFieldName.DESCRIPTION,
89+
field_idx_,
90+
)
91+
elif field_.title == "CONTENT":
92+
content_field = (RequirementFieldName.CONTENT, field_idx_)
93+
else:
94+
pass
8095
self.fields_map: Dict[str, GrammarElementField] = fields_map
96+
self.content_field: Tuple[str, int] = (
97+
statement_field or description_field or content_field or ("", -1)
98+
)
8199
self.mid: MID = MID.create()
82100

83101
@staticmethod

strictdoc/backend/sdoc/models/free_text.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import html
2-
from typing import List, Any, Optional
2+
from typing import Any, List, Optional
33

44
from strictdoc.backend.sdoc.models.anchor import Anchor
55
from strictdoc.backend.sdoc.models.inline_link import InlineLink

strictdoc/backend/sdoc/models/node.py

+19-19
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,11 @@ def reserved_title(self) -> Optional[str]:
205205

206206
@property
207207
def reserved_statement(self) -> Optional[str]:
208+
element: GrammarElement = self.document.grammar.elements_by_type[
209+
self.requirement_type
210+
]
208211
return self._get_cached_field(
209-
RequirementFieldName.STATEMENT, singleline_only=False
212+
element.content_field[0], singleline_only=False
210213
)
211214

212215
@property
@@ -276,6 +279,12 @@ def get_requirement_style_mode(self) -> str:
276279
assert self.ng_document_reference.get_document() is not None
277280
return self.ng_document_reference.get_document().config.get_requirement_style_mode()
278281

282+
def get_content_field_name(self) -> str:
283+
element: GrammarElement = self.document.grammar.elements_by_type[
284+
self.requirement_type
285+
]
286+
return element.content_field[0]
287+
279288
def has_requirement_references(self, ref_type: str) -> bool:
280289
if len(self.relations) == 0:
281290
return False
@@ -374,9 +383,7 @@ def enumerate_meta_fields(
374383
self.requirement_type
375384
]
376385
grammar_field_titles = list(map(lambda f: f.title, element.fields))
377-
statement_field_index = grammar_field_titles.index(
378-
RequirementFieldName.STATEMENT
379-
)
386+
statement_field_index: int = element.content_field[1]
380387
for field in self.enumerate_fields():
381388
if field.field_name in RESERVED_NON_META_FIELDS:
382389
continue
@@ -412,6 +419,13 @@ def get_field_human_title(self, field_name: str) -> str:
412419
field_human_title = element.fields_map[field_name]
413420
return field_human_title.get_field_human_name()
414421

422+
def get_field_human_title_for_statement(self) -> str:
423+
element: GrammarElement = self.document.grammar.elements_by_type[
424+
self.requirement_type
425+
]
426+
field_human_title = element.fields_map[element.content_field[0]]
427+
return field_human_title.get_field_human_name()
428+
415429
def get_requirement_prefix(self) -> str:
416430
parent: Union[SDocSection, SDocDocument] = assert_cast(
417431
self.parent, (SDocSection, SDocDocument, SDocCompositeNode)
@@ -479,25 +493,11 @@ def set_field_value(
479493
self.requirement_type
480494
]
481495
grammar_field_titles = list(map(lambda f: f.title, element.fields))
482-
# FIXME: This will go away very soon when the RELATIONS become a
483-
# separate field in SDoc REQUIREMENT's grammar.
484-
grammar_field_titles.append("REFS")
485496
field_index = grammar_field_titles.index(field_name)
486497

487-
try:
488-
title_field_index = grammar_field_titles.index(
489-
RequirementFieldName.TITLE
490-
)
491-
except ValueError:
492-
# It is a rare edge case when a grammar is without a TITLE but if it
493-
# happens, use STATEMENT as a fallback.
494-
title_field_index = grammar_field_titles.index(
495-
RequirementFieldName.STATEMENT
496-
)
497-
498498
field_value = None
499499
field_value_multiline = None
500-
if field_index <= title_field_index:
500+
if field_index < element.content_field[1]:
501501
field_value = value
502502
else:
503503
field_value_multiline = value

strictdoc/backend/sdoc/models/type_system.py

+8
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,22 @@ class RequirementFieldName:
1111
STATUS = "STATUS"
1212
TAGS = "TAGS"
1313
TITLE = "TITLE"
14+
15+
# {STATEMENT, DESCRIPTION, CONTENT} are aliases.
16+
# It is assumed that either field is provided for each node.
1417
STATEMENT = "STATEMENT"
18+
DESCRIPTION = "DESCRIPTION"
19+
CONTENT = "CONTENT"
20+
1521
RATIONALE = "RATIONALE"
1622
COMMENT = "COMMENT"
1723

1824

1925
RESERVED_NON_META_FIELDS = [
2026
RequirementFieldName.TITLE,
2127
RequirementFieldName.STATEMENT,
28+
RequirementFieldName.DESCRIPTION,
29+
RequirementFieldName.CONTENT,
2230
RequirementFieldName.COMMENT,
2331
RequirementFieldName.RATIONALE,
2432
RequirementFieldName.LEVEL,

strictdoc/backend/sdoc/processor.py

+26-8
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
SDocNode,
2020
)
2121
from strictdoc.backend.sdoc.models.section import SDocSection
22+
from strictdoc.backend.sdoc.models.type_system import (
23+
GrammarElementField,
24+
RequirementFieldName,
25+
)
2226
from strictdoc.helpers.exception import StrictDocException
2327

2428

@@ -29,13 +33,10 @@ def __init__(self, path_to_sdoc_file: Optional[str]):
2933
if path_to_sdoc_file is not None:
3034
assert os.path.isfile(path_to_sdoc_file), path_to_sdoc_file
3135
self.path_to_sdoc_dir = os.path.dirname(path_to_sdoc_file)
32-
36+
self.document_grammar: Optional[DocumentGrammar] = None
3337
self.document_reference: DocumentReference = DocumentReference()
3438
self.context_document_reference: DocumentReference = DocumentReference()
3539
self.document_config: Optional[DocumentConfig] = None
36-
self.document_grammar: DocumentGrammar = DocumentGrammar.create_default(
37-
None
38-
)
3940
self.document_view: Optional[DocumentView] = None
4041
self.document_has_requirements = False
4142

@@ -48,7 +49,10 @@ def __init__(self, parse_context: ParseContext):
4849
self.parse_context: ParseContext = parse_context
4950

5051
def process_document(self, document: SDocDocument):
51-
document.grammar = self.parse_context.document_grammar
52+
document.grammar = (
53+
self.parse_context.document_grammar
54+
or DocumentGrammar.create_default(document)
55+
)
5256
self.parse_context.document = document
5357
document.ng_including_document_reference = (
5458
self.parse_context.context_document_reference
@@ -86,12 +90,26 @@ def process_document_grammar_element(self, grammar_element: GrammarElement):
8690
grammar_element._tx_position
8791
)
8892

93+
if grammar_element.content_field[0] not in grammar_element.fields_map:
94+
raise StrictDocSemanticError.grammar_missing_reserved_statement(
95+
grammar_element,
96+
self.parse_context.path_to_sdoc_file,
97+
line_start,
98+
col_start,
99+
)
100+
content_field: GrammarElementField = grammar_element.fields_map[
101+
grammar_element.content_field[0]
102+
]
103+
# FIXME: Enable for STATEMENT as well. For now, don't want to break
104+
# backward compatibility.
89105
if (
90-
grammar_element.tag == "REQUIREMENT"
91-
and "STATEMENT" not in grammar_element.fields_map
106+
content_field.title
107+
in (RequirementFieldName.DESCRIPTION, RequirementFieldName.CONTENT)
108+
and not content_field.required
92109
):
93-
raise StrictDocSemanticError.grammar_missing_reserved_statement(
110+
raise StrictDocSemanticError.grammar_reserved_statement_must_be_required(
94111
grammar_element,
112+
content_field.title,
95113
self.parse_context.path_to_sdoc_file,
96114
line_start,
97115
col_start,

strictdoc/export/html/form_objects/requirement_form_object.py

+6-4
Original file line numberDiff line numberDiff line change
@@ -644,13 +644,15 @@ def validate(
644644
),
645645
)
646646

647-
requirement_statement = self.fields["STATEMENT"][
647+
requirement_element = self.grammar.elements_by_type[self.element_type]
648+
statement_field_name = requirement_element.content_field[0]
649+
requirement_statement = self.fields[statement_field_name][
648650
0
649651
].field_unescaped_value
650652
if requirement_statement is None or len(requirement_statement) == 0:
651653
self.add_error(
652-
"STATEMENT",
653-
"Node statement must not be empty.",
654+
statement_field_name,
655+
f"Node {statement_field_name.lower()} must not be empty.",
654656
)
655657
else:
656658
(
@@ -661,7 +663,7 @@ def validate(
661663
context_document=context_document,
662664
).write_with_validation(requirement_statement)
663665
if parsed_html is None:
664-
self.add_error("STATEMENT", rst_error)
666+
self.add_error(statement_field_name, rst_error)
665667

666668
requirement_uid: Optional[str] = (
667669
self.fields["UID"][0].field_unescaped_value

strictdoc/export/html/templates/components/requirement/statement/index.jinja

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
{%- if requirement.reserved_statement and view_object.current_view.includes_field(requirement.requirement_type, "STATEMENT") -%}
1+
{%- if requirement.reserved_statement and view_object.current_view.includes_field(requirement.requirement_type, requirement.get_content_field_name()) -%}
22
{%- if truncated_statement is true -%}
33
{# truncated in DTR tiny card #}
44
<sdoc-requirement-field data-field-label="statement">
55
<sdoc-autogen>{{ view_object.markup_renderer.render_truncated_requirement_statement(requirement) }}</sdoc-autogen>
66
</sdoc-requirement-field>
77
{%- else -%}
88
{# default with label #}
9-
<sdoc-requirement-field-label>{{ requirement.get_field_human_title("STATEMENT") }}:</sdoc-requirement-field-label>
9+
<sdoc-requirement-field-label>{{ requirement.get_field_human_title_for_statement() }}:</sdoc-requirement-field-label>
1010
<sdoc-requirement-field
1111
data-field-label="statement"
1212
>

tests/end2end/screens/document/update_requirement/_validation/update_requirement_empty_statement_DESCRIPTION_instead_STATEMENT/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[DOCUMENT]
2+
TITLE: Document 1
3+
4+
[GRAMMAR]
5+
ELEMENTS:
6+
- TAG: REQUIREMENT
7+
FIELDS:
8+
- TITLE: TITLE
9+
TYPE: String
10+
REQUIRED: True
11+
- TITLE: DESCRIPTION
12+
TYPE: String
13+
REQUIRED: True
14+
15+
[FREETEXT]
16+
Hello world!
17+
[/FREETEXT]
18+
19+
[REQUIREMENT]
20+
TITLE: Requirement title
21+
DESCRIPTION: >>>
22+
Requirement statement.
23+
<<<
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[DOCUMENT]
2+
TITLE: Document 1
3+
4+
[GRAMMAR]
5+
ELEMENTS:
6+
- TAG: REQUIREMENT
7+
FIELDS:
8+
- TITLE: TITLE
9+
TYPE: String
10+
REQUIRED: True
11+
- TITLE: DESCRIPTION
12+
TYPE: String
13+
REQUIRED: True
14+
15+
[FREETEXT]
16+
Hello world!
17+
[/FREETEXT]
18+
19+
[REQUIREMENT]
20+
TITLE: Requirement title
21+
DESCRIPTION: >>>
22+
Requirement statement.
23+
<<<
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from tests.end2end.e2e_case import E2ECase
2+
from tests.end2end.end2end_test_setup import End2EndTestSetup
3+
from tests.end2end.helpers.screens.document.form_edit_requirement import (
4+
Form_EditRequirement,
5+
)
6+
from tests.end2end.helpers.screens.project_index.screen_project_index import (
7+
Screen_ProjectIndex,
8+
)
9+
from tests.end2end.server import SDocTestServer
10+
11+
12+
class Test(E2ECase):
13+
def test(self):
14+
test_setup = End2EndTestSetup(path_to_test_file=__file__)
15+
16+
with SDocTestServer(
17+
input_path=test_setup.path_to_sandbox
18+
) as test_server:
19+
self.open(test_server.get_host_and_port())
20+
21+
screen_project_index = Screen_ProjectIndex(self)
22+
23+
screen_project_index.assert_on_screen()
24+
screen_project_index.assert_contains_document("Document 1")
25+
26+
screen_document = screen_project_index.do_click_on_first_document()
27+
28+
screen_document.assert_on_screen_document()
29+
screen_document.assert_header_document_title("Document 1")
30+
31+
screen_document.assert_text("Hello world!")
32+
33+
requirement = screen_document.get_requirement()
34+
form_edit_requirement: Form_EditRequirement = (
35+
requirement.do_open_form_edit_requirement()
36+
)
37+
form_edit_requirement.do_clear_field("DESCRIPTION")
38+
form_edit_requirement.do_form_submit_and_catch_error(
39+
"Node description must not be empty."
40+
)
41+
42+
assert test_setup.compare_sandbox_and_expected_output()

tests/integration/expect_exit.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
if unexpected_exit_code:
5353
print( # noqa: T201
5454
"error: expect_exit: expected exit code: "
55-
"f{expected_exit_code}, actual: {process.returncode}."
55+
f"{expected_exit_code}, actual: {process.returncode}."
5656
)
5757

5858
unexpected_content = expect_no_content and len(stdout) > 0

0 commit comments

Comments
 (0)