diff --git a/strictdoc/backend/sdoc/models/node.py b/strictdoc/backend/sdoc/models/node.py index be64e6844..13b109d14 100644 --- a/strictdoc/backend/sdoc/models/node.py +++ b/strictdoc/backend/sdoc/models/node.py @@ -676,6 +676,25 @@ def get_prefix_for_new_node(self, node_type: str) -> Optional[str]: return self.get_prefix() + def is_managed_by_source_code(self) -> bool: + """ + Helper method to check if a node is partially managed by source code. + """ + + # Is the node entirely generated from source code? + if self.autogen: + return True + + # Check if fields were merged from source-files. + for field_list in self.ordered_fields_lookup.values(): + for field in field_list: + # If any field did not originate from the document, + # the node's content is partially managed by source code... + if not field.is_document_origin(): + return True + + return False + def dump_fields_as_parsed(self) -> str: # FIXME: # - The name of the method can be improved (used in error messages). diff --git a/strictdoc/backend/sdoc/writer.py b/strictdoc/backend/sdoc/writer.py index 1d7481680..e9b7f9d33 100644 --- a/strictdoc/backend/sdoc/writer.py +++ b/strictdoc/backend/sdoc/writer.py @@ -429,7 +429,14 @@ def _print_requirement_fields( fields = section_content.ordered_fields_lookup[field_name] for field in fields: - if not field.is_document_origin(): + # When writing the document back, we skip (normalize) any non-required + # fields that were merged-in from source code (single source of truth). + # But we need to keep UID or MID around for merging. + if ( + not field.is_document_origin() + and not element_field.required + and field_name not in ("UID", "MID") + ): continue field_value = field.get_text_value() diff --git a/strictdoc/core/file_traceability_index.py b/strictdoc/core/file_traceability_index.py index 46460027a..46f1e7f2f 100644 --- a/strictdoc/core/file_traceability_index.py +++ b/strictdoc/core/file_traceability_index.py @@ -1085,19 +1085,18 @@ def set_sdoc_node_fields( sdoc_node: SDocNode, sdoc_node_fields: dict[str, str] ) -> None: for field_name, field_value in sdoc_node_fields.items(): - sdoc_node_has_field = field_name in sdoc_node.ordered_fields_lookup - sdoc_node.set_field_value( field_name=field_name, form_field_index=0, value=field_value, ) - if not sdoc_node_has_field: - new_field: SDocNodeField = sdoc_node.ordered_fields_lookup[ - field_name - ][0] - new_field.mark_as_source_origin() + # As we overwrite the field's content from the source code, + # we mark the field as source_origin here. + new_field: SDocNodeField = sdoc_node.ordered_fields_lookup[ + field_name + ][0] + new_field.mark_as_source_origin() @staticmethod def create_source_node_section( diff --git a/strictdoc/core/transforms/update_requirement.py b/strictdoc/core/transforms/update_requirement.py index c710903c2..a1a38ad85 100644 --- a/strictdoc/core/transforms/update_requirement.py +++ b/strictdoc/core/transforms/update_requirement.py @@ -200,7 +200,10 @@ def perform(self) -> Optional[CreateOrUpdateNodeResult]: requirement.ordered_fields_lookup.clear() self.populate_node_fields_from_form_object( - requirement, form_object, map_form_to_requirement_fields + requirement, + form_object, + map_form_to_requirement_fields, + existing_node_fields, ) # Updating Traceability Index: UID. @@ -298,7 +301,7 @@ def perform(self) -> Optional[CreateOrUpdateNodeResult]: parent.section_contents.insert(insert_to_idx, requirement) self.populate_node_fields_from_form_object( - requirement, form_object, map_form_to_requirement_fields + requirement, form_object, map_form_to_requirement_fields, [] ) traceability_index.create_requirement(requirement=requirement) @@ -480,7 +483,17 @@ def populate_node_fields_from_form_object( map_form_to_requirement_fields: Dict[ RequirementFormField, Optional[FreeTextContainer] ], + existing_node_fields: Optional[List[SDocNodeField]] = None, ) -> None: + if existing_node_fields is None: + existing_node_fields = [] + + existing_fields_lookup: Dict[str, List[SDocNodeField]] = {} + for f in existing_node_fields: + if f.field_name not in existing_fields_lookup: + existing_fields_lookup[f.field_name] = [] + existing_fields_lookup[f.field_name].append(f) + # FIXME: Leave only one method based on set_field_value(). for form_field_name, form_fields in form_object.fields.items(): for form_field_index, form_field in enumerate(form_fields): @@ -490,34 +503,51 @@ def populate_node_fields_from_form_object( form_field_index=form_field_index, value=form_field.field_value, ) - continue - - free_text_content: Optional[FreeTextContainer] = ( - map_form_to_requirement_fields[form_field] - ) - requirement_field: Optional[SDocNodeField] = ( - SDocNodeField( - node, + else: + free_text_content: Optional[FreeTextContainer] = ( + map_form_to_requirement_fields[form_field] + ) + requirement_field: Optional[SDocNodeField] = ( + SDocNodeField( + node, + field_name=form_field_name, + parts=free_text_content.parts, + multiline__="true" + if form_field.field_type + == RequirementFormFieldType.MULTILINE + else None, + ) + if free_text_content is not None + else None + ) + node.set_field_value( field_name=form_field_name, - parts=free_text_content.parts, - multiline__="true" - if form_field.field_type - == RequirementFormFieldType.MULTILINE - else None, + form_field_index=form_field_index, + value=requirement_field, ) - if free_text_content is not None - else None - ) - node.set_field_value( - field_name=form_field_name, - form_field_index=form_field_index, - value=requirement_field, - ) - if ( - requirement_field is not None - and free_text_content is not None - ): - for part_ in requirement_field.parts: - if isinstance(part_, str): - continue - part_.parent = requirement_field + if ( + requirement_field is not None + and free_text_content is not None + ): + for part_ in requirement_field.parts: + if isinstance(part_, str): + continue + part_.parent = requirement_field + + # Preserve the existing origin metadata as is was not passed round-trip via the form. + new_field_list = node.ordered_fields_lookup.get(form_field_name) + if new_field_list and form_field_index < len(new_field_list): + new_field = new_field_list[form_field_index] + + # Find the corresponding old field (if it existed) + if ( + form_field_name in existing_fields_lookup + and form_field_index + < len(existing_fields_lookup[form_field_name]) + ): + old_field = existing_fields_lookup[form_field_name][ + form_field_index + ] + + if hasattr(old_field, "origin"): + new_field.origin = old_field.origin diff --git a/strictdoc/export/html/_static/controllers/draggable_list_controller.js b/strictdoc/export/html/_static/controllers/draggable_list_controller.js index b73331b55..9b240db46 100644 --- a/strictdoc/export/html/_static/controllers/draggable_list_controller.js +++ b/strictdoc/export/html/_static/controllers/draggable_list_controller.js @@ -58,6 +58,10 @@ ${DL_ITEM_SELECTOR}[draggable="true"] .dragIndicator::before { top: 0; left: 100%; content: "⋮⋮"; } +${DL_ITEM_SELECTOR}[data-can-move="false"]:active, +${DL_ITEM_SELECTOR}[data-can-move="false"]:active * { + cursor: not-allowed; +} [data-last_moved="true"] { position: relative; } @@ -121,21 +125,27 @@ ${DL_ITEM_SELECTOR}[draggable="true"] .dragIndicator::before { // Add event listeners. const draggableList = [...this.element.querySelectorAll(DL_ITEM_SELECTOR)]; draggableList.forEach((item) => { - item.addEventListener('dragstart', dragStart); - item.addEventListener('dragend', dragEnd); - item.addEventListener('dragover', dragOver); - item.addEventListener('dragenter', dragEnter); - item.addEventListener('dragleave', dragLeave); - item.addEventListener('drop', dragDrop); - - item.addEventListener("mouseover", mouseOver); - item.addEventListener("mouseleave", mouseLeave); last_moved_node_id && (item.dataset.nodeid === last_moved_node_id) && (item.dataset.last_moved = 'true'); - item.setAttribute('draggable', true); + if (item.dataset.canMove !== 'false') { + item.setAttribute('draggable', true); + + item.addEventListener('dragstart', dragStart); + item.addEventListener('dragend', dragEnd); + item.addEventListener("mouseover", mouseOver); + item.addEventListener("mouseleave", mouseLeave); + } else { + // This item cannot move: Explicitly disable dragging + item.setAttribute('draggable', false); + } + + item.addEventListener('dragover', dragOver); + item.addEventListener('dragenter', dragEnter); + item.addEventListener('dragleave', dragLeave); + item.addEventListener('drop', dragDrop); }); // Prevent drag for links inside the list. diff --git a/strictdoc/export/html/_static/element.css b/strictdoc/export/html/_static/element.css index d680b2336..99477ae39 100644 --- a/strictdoc/export/html/_static/element.css +++ b/strictdoc/export/html/_static/element.css @@ -284,6 +284,20 @@ sdoc-node-controls .action_button { vertical-align: top; } +.action_button--disabled { + opacity: 0.4; + cursor: not-allowed; + filter: grayscale(100%); + pointer-events: none; +} + +/* We re-enable pointer-events on hover, + * so the tooltip (title attribute) shows up. + */ +.action_button--disabled:hover { + pointer-events: auto; +} + /* field_action */ /* TODO: optimize code with .action_icon & .action_button */ diff --git a/strictdoc/export/html/_static/form.css b/strictdoc/export/html/_static/form.css index 4807ce2d6..e6f035ac2 100644 --- a/strictdoc/export/html/_static/form.css +++ b/strictdoc/export/html/_static/form.css @@ -438,7 +438,8 @@ sdoc-autocompletable[data-field-suffix]:not(:empty)::after { /* data-field-suffix */ -[contenteditable=true] { +sdoc-contenteditable, +sdoc-autocompletable { white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; @@ -459,7 +460,8 @@ sdoc-autocompletable[data-field-suffix]:not(:empty)::after { width: 100%; } -[contenteditable=true][data-field-type="multiline"] { +sdoc-contenteditable[data-field-type="multiline"], +sdoc-autocompletable[data-field-type="multiline"] { font-family: var(--code-font-family); } diff --git a/strictdoc/export/html/form_objects/requirement_form_object.py b/strictdoc/export/html/form_objects/requirement_form_object.py index 3cfbbf659..3ac9be2c8 100644 --- a/strictdoc/export/html/form_objects/requirement_form_object.py +++ b/strictdoc/export/html/form_objects/requirement_form_object.py @@ -62,6 +62,7 @@ def __init__( field_type: RequirementFormFieldType, field_value: str, field_gef_type: str = RequirementFieldType.STRING, + is_editable: bool = True, ) -> None: assert isinstance(field_value, str) self.field_mid: str = field_mid @@ -69,6 +70,7 @@ def __init__( self.field_value: str = field_value self.field_type = field_type self.field_gef_type: str = field_gef_type + self.is_editable = is_editable def is_multiline(self) -> bool: return self.field_type == RequirementFormFieldType.MULTILINE @@ -146,6 +148,7 @@ def create_existing_from_grammar_field( ), field_value=field_value, field_gef_type=grammar_field.gef_type, + is_editable=requirement_field.is_document_origin(), ) raise NotImplementedError(grammar_field) diff --git a/strictdoc/export/html/generators/view_objects/document_screen_view_object.py b/strictdoc/export/html/generators/view_objects/document_screen_view_object.py index 4932c873b..d702f698f 100644 --- a/strictdoc/export/html/generators/view_objects/document_screen_view_object.py +++ b/strictdoc/export/html/generators/view_objects/document_screen_view_object.py @@ -468,3 +468,84 @@ def get_html2pdf_classes(self, node: SDocNode) -> str: html2pdf4doc_classes.append("html2pdf4doc-no-hanging") return " ".join(html2pdf4doc_classes) + + # + # Document Level Actions + # + + def can_edit_document(self, document: SDocDocument) -> bool: + """ + Determines if the document's root configuration (title, metadata) can be edited. + """ + # If the entire document is auto-generated, we can't edit it. + if document.autogen: + return False + + # If the document is a physical .sdoc file, its metadata is editable, + # regardless of whether it contains source-synced nodes inside it. + return True + + # + # Node Level Actions + # + + def can_edit_node(self, node: Union[SDocDocument, SDocNode]) -> bool: + if isinstance(node, SDocDocument): + return not node.autogen + + # Ephemeral test reports/auto-docs cannot be edited at all. + if node.get_parent_or_including_document().autogen or node.autogen: + return False + + # NOTE: We intentionally DO NOT check for hybrid fields here! + # If it's a hybrid node, we let them open the form. The field-level + # UI wrappers will mark the specific input fields that belong to the C-file as read-only. + return True + + def can_delete_node(self, node: Union[SDocDocument, SDocNode]) -> bool: + if isinstance(node, SDocDocument): + return not node.autogen + + # Ephemeral/Virtual documents cannot be edited + if node.get_parent_or_including_document().autogen: + return False + + # Pure source-code nodes and Hybrid nodes cannot be deleted from the UI. + if node.is_managed_by_source_code(): + return False + + return True + + def can_clone_node(self, node: Union[SDocDocument, SDocNode]) -> bool: + if isinstance(node, SDocDocument): + return False + + # We cannot clone a source-managed node because the UI cannot write to the C-file. + # In this sense, the policy is identical to can_delete + return self.can_delete_node(node) + + def can_add_node(self, node: Union[SDocDocument, SDocNode]) -> bool: + if isinstance(node, SDocDocument): + return not node.autogen + + # Cannot attach manual nodes to volatile, purely autogenerated nodes/docs. + # If the parent vanishes on the next sync, the children are lost. + if node.get_parent_or_including_document().autogen or node.autogen: + return False + + # NOTE: We do not check for source managed fields here. + # A hybrid node has a permanent anchor in the .sdoc file, so it is safe + # to attach new manual siblings or children to it. + return True + + def can_move_node(self, node: Union[SDocDocument, SDocNode]) -> bool: + if isinstance(node, SDocDocument): + return not node.autogen + + # Can't move nodes in ephemeral / autogenerated documents. + if node.get_parent_or_including_document().autogen: + return False + + # But we can move a source-managed node, because the updates is limited + # to the sdoc document, moving does not require updating the source code. + return True diff --git a/strictdoc/export/html/templates/components/form/field/autocompletable/index.jinja b/strictdoc/export/html/templates/components/form/field/autocompletable/index.jinja index 359b5b6f6..c13585056 100644 --- a/strictdoc/export/html/templates/components/form/field/autocompletable/index.jinja +++ b/strictdoc/export/html/templates/components/form/field/autocompletable/index.jinja @@ -3,8 +3,6 @@ {%- assert field_input_name is defined, "field_input_name is defined" -%} {%- assert field_label is defined, "field_label is defined" -%} {%- assert field_label is not none, "field_label is not none" -%} -{# FIXME: Remove #} -{%- assert field_name is not defined, "field_name is not defined" -%} {%- assert field_placeholder is defined, "field_placeholder is defined" -%} {%- assert field_placeholder is not none, "field_placeholder is not none" -%} {%- assert field_value is defined, "field_value is defined" -%} diff --git a/strictdoc/export/html/templates/components/form/row/row_with_text_field.jinja b/strictdoc/export/html/templates/components/form/row/row_with_text_field.jinja index bc7c54ee2..75d398b94 100644 --- a/strictdoc/export/html/templates/components/form/row/row_with_text_field.jinja +++ b/strictdoc/export/html/templates/components/form/row/row_with_text_field.jinja @@ -30,12 +30,17 @@ {%- set placeholder_name = text_field_row_context.field.field_name %} + {%- set field_name = text_field_row_context.field.field_name %} + {%- if not text_field_row_context.field_editable %} + {%- set field_name = field_name ~ " (READ-ONLY)" %} + {%- endif %} + {%- with mid = text_field_row_context.field.field_mid, field_class_name = none, field_editable = text_field_row_context.field_editable, field_input_name = text_field_row_context.field.get_input_field_name(), - field_label = text_field_row_context.field.field_name, + field_label = field_name, field_placeholder = "Enter "~placeholder_name~" here...", field_type = text_field_row_context.field_type, field_value = text_field_row_context.field.field_value, diff --git a/strictdoc/export/html/templates/components/node/index.jinja b/strictdoc/export/html/templates/components/node/index.jinja index e5712f602..cb88fa4cb 100644 --- a/strictdoc/export/html/templates/components/node/index.jinja +++ b/strictdoc/export/html/templates/components/node/index.jinja @@ -15,6 +15,7 @@ {% set node_type = sdoc_entity.get_type_string() %} {% set is_included = sdoc_entity.document_is_included() %} {% set node_type_string = sdoc_entity.get_node_type_string() %} +{% set is_editable = view_object.can_edit_node(sdoc_entity) %} @@ -27,7 +28,7 @@ node-view="{{ sdoc_entity.get_requirement_style_mode() }}" {%- endif -%} {%- if node_type_string is not none %}{# and node_type_string != 'TEXT' #} - show-node-type-name="{{ node_type_string }}" + show-node-type-name="{{ node_type_string }}{% if not is_editable %} (READ-ONLY){% endif %}" {%- endif %} data-included-document="{{ is_included }}" data-testid="node-{{ node_type }}" diff --git a/strictdoc/export/html/templates/components/node/node_controls/index.jinja b/strictdoc/export/html/templates/components/node/node_controls/index.jinja index 9beaea195..61e7da7cb 100644 --- a/strictdoc/export/html/templates/components/node/node_controls/index.jinja +++ b/strictdoc/export/html/templates/components/node/node_controls/index.jinja @@ -18,12 +18,18 @@ {# META #} {% include "icons/ico16_edit.svg" %} {%- endif -%} @@ -35,20 +41,32 @@ {%- if sdoc_entity.get_type_string() != 'document' -%} {% if not sdoc_entity.is_root_included_document %} {% include "icons/ico16_edit.svg" %} {% include "icons/ico16_delete.svg" %} {% else %} {% include "icons/ico16_copy.svg" %} {%- endif -%} @@ -85,7 +109,7 @@ + document ONLY 'not document.section_contents' #} {%- if sdoc_entity.get_type_string() != 'document' or not view_object.document.section_contents -%} - + {% if view_object.can_add_node(sdoc_entity) %} {# ADD NODES: dropdown menu #} {# djlint:off H025 #} + {% else %} + {% include "icons/ico16_add.svg" %} + {% endif %} {%- endif -%} diff --git a/strictdoc/export/html/templates/screens/document/_shared/toc.jinja b/strictdoc/export/html/templates/screens/document/_shared/toc.jinja index 82e4e80c3..698dfb418 100644 --- a/strictdoc/export/html/templates/screens/document/_shared/toc.jinja +++ b/strictdoc/export/html/templates/screens/document/_shared/toc.jinja @@ -9,7 +9,8 @@ {% endif -%} > {%- for section, node_context_ in view_object.table_of_contents() -%} -
  • + {%- set can_move = view_object.can_move_node(section) -%} +
  • {%- if section.is_document() -%} {%- if not section.ng_has_requirements and view_object.is_deeptrace() -%} diff --git a/strictdoc/export/html/templates/screens/document/document/frame_requirement_form.jinja b/strictdoc/export/html/templates/screens/document/document/frame_requirement_form.jinja index 3c5710a63..a7c3579af 100644 --- a/strictdoc/export/html/templates/screens/document/document/frame_requirement_form.jinja +++ b/strictdoc/export/html/templates/screens/document/document/frame_requirement_form.jinja @@ -49,7 +49,7 @@ {%- for field_ in field_values_ -%} {% set text_field_row_context.errors=form_object.get_errors(field_.field_name) %} {% set text_field_row_context.field = field_ %} - {% set text_field_row_context.field_editable = true %} + {% set text_field_row_context.field_editable = field_.is_editable %} {% set text_field_row_context.field_type = "singleline" %} {% set text_field_row_context.reference_mid = form_object.requirement_mid %} {%- if form_object.element_type != "TEXT" and field_.field_name == "UID" and field_.field_value == "" -%} @@ -69,7 +69,7 @@ {% if field_.field_name != "COMMENT" -%} {% set text_field_row_context.errors=form_object.get_errors(field_.field_name) %} {% set text_field_row_context.field = field_ %} - {% set text_field_row_context.field_editable = true %} + {% set text_field_row_context.field_editable = field_.is_editable %} {% set text_field_row_context.field_type = "multiline" %} {% include "components/form/row/row_with_text_field.jinja" %} {%- endif -%} diff --git a/tests/end2end/end2end_test_setup.py b/tests/end2end/end2end_test_setup.py index e8ed841f1..19d149eff 100644 --- a/tests/end2end/end2end_test_setup.py +++ b/tests/end2end/end2end_test_setup.py @@ -27,9 +27,6 @@ def __init__(self, path_to_test_file): self.path_to_expected_output_dir = os.path.join( path_to_test_dir, "expected_output" ) - assert os.path.isdir(self.path_to_expected_output_dir), ( - self.path_to_expected_output_dir - ) # Sandbox is where the StrictDoc server will find its documents. self.path_to_sandbox = os.path.join(path_to_test_dir, ".sandbox") @@ -56,6 +53,10 @@ def path_to_expected_output_dir_file(self, file_name): return path_to_expected_output_dir_file def compare_sandbox_and_expected_output(self): + assert os.path.isdir(self.path_to_expected_output_dir), ( + self.path_to_expected_output_dir + ) + sandbox_files = End2EndTestSetup.find_files(self.path_to_sandbox) expected_files = End2EndTestSetup.find_files( self.path_to_expected_output_dir diff --git a/tests/end2end/helpers/components/node/requirement.py b/tests/end2end/helpers/components/node/requirement.py index 69b47f283..0289f4010 100644 --- a/tests/end2end/helpers/components/node/requirement.py +++ b/tests/end2end/helpers/components/node/requirement.py @@ -298,3 +298,51 @@ def do_copy_stable_link_to_buffer(self) -> None: hover_by=By.XPATH, click_by=By.XPATH, ) + + def _assert_action_is_locked(self, testid: str): + """Verifies the action identified by testid is locked.""" + self.test_case.assert_element_absent( + f"{self.node_xpath}//sdoc-node-controls//*[@data-testid='{testid}']", + by=By.XPATH, + ) + action_btn_class = self.test_case.get_attribute( + f"{self.node_xpath}//sdoc-node-controls//*[@data-testid='{testid}-disabled']", + "class", + by=By.XPATH, + ) + assert "action_button--disabled" in action_btn_class + + def _assert_action_is_unlocked(self, testid: str): + """Verifies the action identified by testid is unlocked.""" + self.test_case.assert_element_present( + f"{self.node_xpath}//sdoc-node-controls//*[@data-testid='{testid}']", + by=By.XPATH, + ) + self.test_case.assert_element_absent( + f"{self.node_xpath}//sdoc-node-controls//*[@data-testid='{testid}-disabled']", + by=By.XPATH, + ) + + def assert_add_action_is_locked(self): + self._assert_action_is_locked("node-menu-handler") + + def assert_add_action_is_unlocked(self): + self._assert_action_is_unlocked("node-menu-handler") + + def assert_edit_node_action_is_locked(self): + self._assert_action_is_locked("node-edit-action") + + def assert_edit_node_action_is_unlocked(self): + self._assert_action_is_unlocked("node-edit-action") + + def assert_clone_node_action_is_locked(self): + self._assert_action_is_locked("node-clone-action") + + def assert_clone_node_action_is_unlocked(self): + self._assert_action_is_unlocked("node-clone-action") + + def assert_delete_node_action_is_locked(self): + self._assert_action_is_locked("node-delete-action") + + def assert_delete_node_action_is_unlocked(self): + self._assert_action_is_unlocked("node-delete-action") diff --git a/tests/end2end/helpers/screens/document/form_edit_requirement.py b/tests/end2end/helpers/screens/document/form_edit_requirement.py index d1ef37dba..752a760c7 100644 --- a/tests/end2end/helpers/screens/document/form_edit_requirement.py +++ b/tests/end2end/helpers/screens/document/form_edit_requirement.py @@ -172,3 +172,21 @@ def assert_uid_field_has_not_reset_button(self) -> None: def do_reset_uid_field(self, field_name: str = "") -> None: assert isinstance(field_name, str) self.test_case.click_xpath("//*[@data-testid='reset-uid-field-action']") + + def assert_field_is_readonly(self, field_name: str) -> None: + """Verifies that a specific field has contenteditable set to false.""" + selector = ( + f"sdoc-contenteditable[data-testid='form-field-{field_name}']" + ) + is_editable = self.test_case.get_attribute(selector, "contenteditable") + + assert is_editable == "false" + + def assert_field_is_editable(self, field_name: str) -> None: + """Verifies that a specific field has contenteditable set to true.""" + selector = ( + f"sdoc-contenteditable[data-testid='form-field-{field_name}']" + ) + is_editable = self.test_case.get_attribute(selector, "contenteditable") + + assert is_editable == "true" diff --git a/tests/end2end/helpers/screens/document/screen_document.py b/tests/end2end/helpers/screens/document/screen_document.py index 78eade3bd..1c55ec359 100644 --- a/tests/end2end/helpers/screens/document/screen_document.py +++ b/tests/end2end/helpers/screens/document/screen_document.py @@ -117,3 +117,34 @@ def do_click_on_tree_document(self, doc_order: int = 1) -> None: self.test_case.click_xpath( f'(//*[@data-testid="tree-document-link"])[{doc_order}]' ) + + def assert_document_config_edit_is_locked(self): + """Verifies the document-level edit button is disabled.""" + element_class = self.test_case.get_attribute( + '[data-testid="document-edit-config-action-disabled"]', "class" + ) + assert "action_button--disabled" in element_class + + def assert_document_config_edit_is_unlocked(self): + """Verifies the document-level edit button is fully interactive.""" + self.test_case.assert_element_present( + '[data-testid="document-edit-config-action"]' + ) + + element_class = self.test_case.get_attribute( + '[data-testid="document-edit-config-action"]', "class" + ) + assert "action_button--disabled" not in element_class + + def assert_first_toc_node_is_not_draggable(self): + # We look for the first li inside the toc-list and verify it has draggable="false" + xpath_first_toc_node = ( + '(//ul[@data-testid="toc-list"]//li[@data-nodeid])[1]' + ) + + self.test_case.assert_attribute( + xpath_first_toc_node, "draggable", "false" + ) + self.test_case.assert_attribute( + xpath_first_toc_node, "data-can-move", "false" + ) diff --git a/tests/end2end/screens/document/_action_policy/01_autogenerated_document/input/mock_system.py b/tests/end2end/screens/document/_action_policy/01_autogenerated_document/input/mock_system.py new file mode 100644 index 000000000..4fbfc10bf --- /dev/null +++ b/tests/end2end/screens/document/_action_policy/01_autogenerated_document/input/mock_system.py @@ -0,0 +1,9 @@ +# input/test_system.py + + +def login_successful(): + pass + + +def login_unsuccessful(): + pass diff --git a/tests/end2end/screens/document/_action_policy/01_autogenerated_document/input/report.pytest.junit.xml b/tests/end2end/screens/document/_action_policy/01_autogenerated_document/input/report.pytest.junit.xml new file mode 100644 index 000000000..bd34d34ef --- /dev/null +++ b/tests/end2end/screens/document/_action_policy/01_autogenerated_document/input/report.pytest.junit.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/tests/end2end/screens/document/_action_policy/01_autogenerated_document/input/strictdoc_config.py b/tests/end2end/screens/document/_action_policy/01_autogenerated_document/input/strictdoc_config.py new file mode 100644 index 000000000..fb2a8d47e --- /dev/null +++ b/tests/end2end/screens/document/_action_policy/01_autogenerated_document/input/strictdoc_config.py @@ -0,0 +1,11 @@ +from strictdoc.core.project_config import ProjectConfig + + +def create_config() -> ProjectConfig: + config = ProjectConfig( + project_title="Autogen E2E Test", + project_features=[ + "REQUIREMENT_TO_SOURCE_TRACEABILITY", + ], + ) + return config diff --git a/tests/end2end/screens/document/_action_policy/01_autogenerated_document/test_case.py b/tests/end2end/screens/document/_action_policy/01_autogenerated_document/test_case.py new file mode 100644 index 000000000..d71c0e907 --- /dev/null +++ b/tests/end2end/screens/document/_action_policy/01_autogenerated_document/test_case.py @@ -0,0 +1,39 @@ +from tests.end2end.e2e_case import E2ECase +from tests.end2end.end2end_test_setup import End2EndTestSetup +from tests.end2end.helpers.screens.project_index.screen_project_index import ( + Screen_ProjectIndex, +) +from tests.end2end.server import SDocTestServer + + +class Test(E2ECase): + """ + E2E Test: Verifies that when a document is fully autogenerated + (e.g., from a JUnit XML report), all structural UI actions are visually + and functionally disabled. + """ + + def test_autogenerated_document_ui_is_locked(self): + test_setup = End2EndTestSetup(path_to_test_file=__file__) + + with SDocTestServer( + input_path=test_setup.path_to_sandbox + ) as test_server: + self.open(test_server.get_host_and_port()) + + # Navigate to the project index + screen_project_index = Screen_ProjectIndex(self) + screen_project_index.assert_on_screen() + + screen_document = screen_project_index.do_click_on_first_document() + screen_document.assert_on_screen_document() + + screen_document.assert_document_config_edit_is_locked() + + node = screen_document.get_node() + node.assert_add_action_is_locked() + node.assert_edit_node_action_is_locked() + node.assert_clone_node_action_is_locked() + node.assert_delete_node_action_is_locked() + + screen_document.assert_first_toc_node_is_not_draggable() diff --git a/tests/end2end/screens/document/_action_policy/02_normal_document/input/input.sdoc b/tests/end2end/screens/document/_action_policy/02_normal_document/input/input.sdoc new file mode 100644 index 000000000..f5a3e0a31 --- /dev/null +++ b/tests/end2end/screens/document/_action_policy/02_normal_document/input/input.sdoc @@ -0,0 +1,6 @@ +[DOCUMENT] +TITLE: Normal Document + +[REQUIREMENT] +UID: REQ-1 +STATEMENT: This is a completely standard requirement. diff --git a/tests/end2end/screens/document/_action_policy/02_normal_document/input/strictdoc_config.py b/tests/end2end/screens/document/_action_policy/02_normal_document/input/strictdoc_config.py new file mode 100644 index 000000000..68055d9ef --- /dev/null +++ b/tests/end2end/screens/document/_action_policy/02_normal_document/input/strictdoc_config.py @@ -0,0 +1,7 @@ +from strictdoc.core.project_config import ProjectConfig + + +def create_config() -> ProjectConfig: + return ProjectConfig( + project_title="Normal Document E2E Test", + ) diff --git a/tests/end2end/screens/document/_action_policy/02_normal_document/test_case.py b/tests/end2end/screens/document/_action_policy/02_normal_document/test_case.py new file mode 100644 index 000000000..ca8e92ba9 --- /dev/null +++ b/tests/end2end/screens/document/_action_policy/02_normal_document/test_case.py @@ -0,0 +1,39 @@ +from tests.end2end.e2e_case import E2ECase +from tests.end2end.end2end_test_setup import End2EndTestSetup +from tests.end2end.helpers.screens.project_index.screen_project_index import ( + Screen_ProjectIndex, +) +from tests.end2end.server import SDocTestServer + + +class Test(E2ECase): + """ + Verify that a standard document parsed from an .sdoc file with no source-code + inclusions should have all document and node actions fully enabled. + """ + + def test_normal_document_ui_actions(self): + test_setup = End2EndTestSetup(path_to_test_file=__file__) + + with SDocTestServer( + input_path=test_setup.path_to_sandbox + ) as test_server: + self.open(test_server.get_host_and_port()) + + screen_project_index = Screen_ProjectIndex(self) + screen_project_index.assert_on_screen() + + screen_project_index.assert_contains_document("Normal Document") + screen_document = screen_project_index.do_click_on_first_document() + + screen_document.assert_on_screen_document() + + # VERIFY: Document Level Unlocked + screen_document.assert_document_config_edit_is_unlocked() + + # VERIFY: Node Level Unlocked + node = screen_document.get_node() + node.assert_add_action_is_unlocked() + node.assert_edit_node_action_is_unlocked() + node.assert_clone_node_action_is_unlocked() + node.assert_delete_node_action_is_unlocked() diff --git a/tests/end2end/screens/document/_action_policy/03_merged_nodes/input/example.c b/tests/end2end/screens/document/_action_policy/03_merged_nodes/input/example.c new file mode 100644 index 000000000..e72dc88a5 --- /dev/null +++ b/tests/end2end/screens/document/_action_policy/03_merged_nodes/input/example.c @@ -0,0 +1,14 @@ +#include + +/** + * Some text. + * + * UID: REQ-1 + * + * TITLE: Title from example.c + * + * STATEMENT: Statement from example.c + */ +void example_1(void) { + print("hello world\n"); +} diff --git a/tests/end2end/screens/document/_action_policy/03_merged_nodes/input/input.sdoc b/tests/end2end/screens/document/_action_policy/03_merged_nodes/input/input.sdoc new file mode 100644 index 000000000..ada0766dd --- /dev/null +++ b/tests/end2end/screens/document/_action_policy/03_merged_nodes/input/input.sdoc @@ -0,0 +1,8 @@ +[DOCUMENT] +TITLE: Hybrid Document +UID: DOC + +[REQUIREMENT] +UID: REQ-1 +TITLE: The title will be overridden by the source file. +STATEMENT: This statement will be overridden by the source file. diff --git a/tests/end2end/screens/document/_action_policy/03_merged_nodes/input/strictdoc_config.py b/tests/end2end/screens/document/_action_policy/03_merged_nodes/input/strictdoc_config.py new file mode 100644 index 000000000..ae648f9e3 --- /dev/null +++ b/tests/end2end/screens/document/_action_policy/03_merged_nodes/input/strictdoc_config.py @@ -0,0 +1,13 @@ +from strictdoc.core.project_config import ProjectConfig, SourceNodesEntry + + +def create_config() -> ProjectConfig: + return ProjectConfig( + project_title="Hybrid Node E2E Test", + project_features=[ + "REQUIREMENT_TO_SOURCE_TRACEABILITY", + ], + source_nodes=[ + SourceNodesEntry(path=".", uid="DOC", node_type="REQUIREMENT") + ], + ) diff --git a/tests/end2end/screens/document/_action_policy/03_merged_nodes/test_case.py b/tests/end2end/screens/document/_action_policy/03_merged_nodes/test_case.py new file mode 100644 index 000000000..a544622c4 --- /dev/null +++ b/tests/end2end/screens/document/_action_policy/03_merged_nodes/test_case.py @@ -0,0 +1,49 @@ +from tests.end2end.e2e_case import E2ECase +from tests.end2end.end2end_test_setup import End2EndTestSetup +from tests.end2end.helpers.screens.document.form_edit_requirement import ( + Form_EditRequirement, +) +from tests.end2end.helpers.screens.project_index.screen_project_index import ( + Screen_ProjectIndex, +) +from tests.end2end.server import SDocTestServer + + +class Test(E2ECase): + """ + E2E Test: Verifies that when a node is hybrid (exists in .sdoc but fields + are populated from source code), destructive actions (Delete, Clone) are + disabled, but constructive actions (Edit, Add Child) remain enabled. + """ + + def test_merged_node_ui_actions(self): + test_setup = End2EndTestSetup(path_to_test_file=__file__) + + with SDocTestServer( + input_path=test_setup.path_to_sandbox + ) as test_server: + self.open(test_server.get_host_and_port()) + + screen_project_index = Screen_ProjectIndex(self) + screen_project_index.assert_on_screen() + + screen_project_index.assert_contains_document("Hybrid Document") + screen_document = screen_project_index.do_click_on_first_document() + + screen_document.assert_on_screen_document() + + node = screen_document.get_node() + + # Edit & Add are allowed. Clone & Delete are blocked! + node.assert_add_action_is_unlocked() + node.assert_edit_node_action_is_unlocked() + node.assert_clone_node_action_is_locked() + node.assert_delete_node_action_is_locked() + + form_edit_requirement: Form_EditRequirement = ( + node.do_open_form_edit_requirement() + ) + + form_edit_requirement.assert_field_is_readonly("TITLE") + form_edit_requirement.assert_field_is_readonly("STATEMENT") + form_edit_requirement.assert_field_is_editable("RATIONALE") diff --git a/tests/end2end/screens/document/update_node/update_requirement_when_source_node/expected_output/example.c b/tests/end2end/screens/document/update_node/update_requirement_when_source_node/expected_output/example.c new file mode 100644 index 000000000..e72dc88a5 --- /dev/null +++ b/tests/end2end/screens/document/update_node/update_requirement_when_source_node/expected_output/example.c @@ -0,0 +1,14 @@ +#include + +/** + * Some text. + * + * UID: REQ-1 + * + * TITLE: Title from example.c + * + * STATEMENT: Statement from example.c + */ +void example_1(void) { + print("hello world\n"); +} diff --git a/tests/end2end/screens/document/update_node/update_requirement_when_source_node/expected_output/input.sdoc b/tests/end2end/screens/document/update_node/update_requirement_when_source_node/expected_output/input.sdoc new file mode 100644 index 000000000..354150d6d --- /dev/null +++ b/tests/end2end/screens/document/update_node/update_requirement_when_source_node/expected_output/input.sdoc @@ -0,0 +1,9 @@ +[DOCUMENT] +TITLE: Hybrid Document +UID: DOC + +[REQUIREMENT] +UID: REQ-1 +RATIONALE: >>> +This is a rationale. +<<< diff --git a/tests/end2end/screens/document/update_node/update_requirement_when_source_node/expected_output/strictdoc_config.py b/tests/end2end/screens/document/update_node/update_requirement_when_source_node/expected_output/strictdoc_config.py new file mode 100644 index 000000000..ae648f9e3 --- /dev/null +++ b/tests/end2end/screens/document/update_node/update_requirement_when_source_node/expected_output/strictdoc_config.py @@ -0,0 +1,13 @@ +from strictdoc.core.project_config import ProjectConfig, SourceNodesEntry + + +def create_config() -> ProjectConfig: + return ProjectConfig( + project_title="Hybrid Node E2E Test", + project_features=[ + "REQUIREMENT_TO_SOURCE_TRACEABILITY", + ], + source_nodes=[ + SourceNodesEntry(path=".", uid="DOC", node_type="REQUIREMENT") + ], + ) diff --git a/tests/end2end/screens/document/update_node/update_requirement_when_source_node/input/example.c b/tests/end2end/screens/document/update_node/update_requirement_when_source_node/input/example.c new file mode 100644 index 000000000..e72dc88a5 --- /dev/null +++ b/tests/end2end/screens/document/update_node/update_requirement_when_source_node/input/example.c @@ -0,0 +1,14 @@ +#include + +/** + * Some text. + * + * UID: REQ-1 + * + * TITLE: Title from example.c + * + * STATEMENT: Statement from example.c + */ +void example_1(void) { + print("hello world\n"); +} diff --git a/tests/end2end/screens/document/update_node/update_requirement_when_source_node/input/input.sdoc b/tests/end2end/screens/document/update_node/update_requirement_when_source_node/input/input.sdoc new file mode 100644 index 000000000..5461a1011 --- /dev/null +++ b/tests/end2end/screens/document/update_node/update_requirement_when_source_node/input/input.sdoc @@ -0,0 +1,8 @@ +[DOCUMENT] +TITLE: Hybrid Document +UID: DOC + +[REQUIREMENT] +UID: REQ-1 +TITLE: The title will be overridden by the source file, and normalized-out on save. +STATEMENT: This statement will be overridden by the source file, and normalized-out on save. diff --git a/tests/end2end/screens/document/update_node/update_requirement_when_source_node/input/strictdoc_config.py b/tests/end2end/screens/document/update_node/update_requirement_when_source_node/input/strictdoc_config.py new file mode 100644 index 000000000..ae648f9e3 --- /dev/null +++ b/tests/end2end/screens/document/update_node/update_requirement_when_source_node/input/strictdoc_config.py @@ -0,0 +1,13 @@ +from strictdoc.core.project_config import ProjectConfig, SourceNodesEntry + + +def create_config() -> ProjectConfig: + return ProjectConfig( + project_title="Hybrid Node E2E Test", + project_features=[ + "REQUIREMENT_TO_SOURCE_TRACEABILITY", + ], + source_nodes=[ + SourceNodesEntry(path=".", uid="DOC", node_type="REQUIREMENT") + ], + ) diff --git a/tests/end2end/screens/document/update_node/update_requirement_when_source_node/test_case.py b/tests/end2end/screens/document/update_node/update_requirement_when_source_node/test_case.py new file mode 100644 index 000000000..01fb11853 --- /dev/null +++ b/tests/end2end/screens/document/update_node/update_requirement_when_source_node/test_case.py @@ -0,0 +1,47 @@ +from tests.end2end.e2e_case import E2ECase +from tests.end2end.end2end_test_setup import End2EndTestSetup +from tests.end2end.helpers.screens.document.form_edit_requirement import ( + Form_EditRequirement, +) +from tests.end2end.helpers.screens.project_index.screen_project_index import ( + Screen_ProjectIndex, +) +from tests.end2end.server import SDocTestServer + + +class MergedNodeUpdateAndNormalizationE2ECase(E2ECase): + """ + E2E Test: Verifies that for a merged node (which exists in .sdoc but fields + are populated from source code) we can still edit the fields that are in the .sdoc document. + When such a node is saved, we verify that any non-required fields are removed (normalized) + as the source of truth for these fields is not the source code. + """ + + def test_merged_node_update(self): + test_setup = End2EndTestSetup(path_to_test_file=__file__) + + with SDocTestServer( + input_path=test_setup.path_to_sandbox + ) as test_server: + self.open(test_server.get_host_and_port()) + + screen_project_index = Screen_ProjectIndex(self) + screen_project_index.assert_on_screen() + + screen_project_index.assert_contains_document("Hybrid Document") + screen_document = screen_project_index.do_click_on_first_document() + + screen_document.assert_on_screen_document() + + node = screen_document.get_node() + + form_edit_requirement: Form_EditRequirement = ( + node.do_open_form_edit_requirement() + ) + + form_edit_requirement.do_fill_in_field_rationale( + "This is a rationale." + ) + form_edit_requirement.do_form_submit() + + test_setup.compare_sandbox_and_expected_output() diff --git a/tests/integration/features/markdown/07_relations_all_types/strictdoc_config.py b/tests/integration/features/markdown/07_relations_all_types/strictdoc_config.py index 8e465fd6a..fb39a97e1 100644 --- a/tests/integration/features/markdown/07_relations_all_types/strictdoc_config.py +++ b/tests/integration/features/markdown/07_relations_all_types/strictdoc_config.py @@ -5,7 +5,6 @@ def create_config() -> ProjectConfig: config = ProjectConfig( project_features=[ "REQUIREMENT_TO_SOURCE_TRACEABILITY", - "SOURCE_FILE_LANGUAGE_PARSERS", ], ) return config