diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9347128..cbeba63 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,19 +3,19 @@ repos: hooks: - id: flake8 name: flake8 - entry: "pdm run flake8" + entry: "uv run flake8 api/ db/" pass_filenames: false always_run: true language: system - id: prettier name: prettier - entry: "npx prettier . --write --log-level error" + entry: "cd app/src/ && npx prettier . --write --log-level error" pass_filenames: false always_run: true language: system - id: eslint name: eslint - entry: "cd app && npx eslint --quiet src" + entry: "cd app/src/ && npx eslint --quiet ." pass_filenames: false always_run: true language: system @@ -27,7 +27,7 @@ repos: language: system - id: tmt name: tmt - entry: "tmt lint . --disable-check P005" + entry: "uv run tmt lint . --disable-check P005 --disable-check P009" pass_filenames: false always_run: true language: system diff --git a/api/api.py b/api/api.py index 23c506a..15d9f9b 100644 --- a/api/api.py +++ b/api/api.py @@ -4656,6 +4656,136 @@ def get( return api_response.return_ok() +class ApiDynamicViewMapping(Resource): + route = "/mapping/api/dynamic-view" + + @api_response_decorator + @check_api_user_read_permission + def get( + self, + api: ApiModel = None, + user: UserModel = None, + dbi: db_orm.DbInterface = None, + api_response: ApiResponse = None, + ): + """ + Dynamic view: returns full specification + all direct work items + deduplicated by work item ID, each with a list of snippet mappings. + """ + request_data = get_query_string_args(request.args) + api_response.set_logger(logger) + api_response.set_args(request_data) + + api_specification = get_api_specification(api.raw_specification_url) + if api_specification is None: + api_response.set_message("Unable to find the Api Specification") + return api_response.return_not_found() + + def _group_mappings(mapping_rows, work_item_key, work_item_id_field): + """Group mapping rows by work item ID, collecting snippets.""" + groups = {} + for row in mapping_rows: + row_dict = row.as_dict(db_session=dbi.session) + current_offset = row_dict["offset"] + current_section = row_dict["section"] + match = ( + api_specification[current_offset: current_offset + len(current_section)] + == current_section + ) + wi_id = row_dict[work_item_key]["id"] + snippet = { + "section": row_dict["section"], + "offset": row_dict["offset"], + "relation_id": row_dict["relation_id"], + "coverage": row_dict["coverage"], + "covered": row_dict.get("covered", row_dict["coverage"]), + "match": match, + "__tablename__": row_dict["__tablename__"], + } + if wi_id not in groups: + groups[wi_id] = { + work_item_key: row_dict[work_item_key], + "snippets": [], + "created_by": row_dict.get("created_by", ""), + } + if "version" in row_dict: + groups[wi_id]["version"] = row_dict["version"] + groups[wi_id]["snippets"].append(snippet) + return list(groups.values()) + + sr_rows = ( + dbi.session.query(ApiSwRequirementModel) + .filter(ApiSwRequirementModel.api_id == api.id) + .order_by(ApiSwRequirementModel.offset.asc()) + .all() + ) + grouped_srs = _group_mappings(sr_rows, _SR, "sw_requirement_id") + for sr_group in grouped_srs: + for snippet in sr_group["snippets"]: + if snippet["match"]: + dummy_srm = { + "relation_id": snippet["relation_id"], + "__tablename__": snippet["__tablename__"], + } + children = get_sw_requirement_children(dbi, dummy_srm) + snippet[_SRs] = children.get(_SRs, []) + snippet[_TSs] = children.get(_TSs, []) + snippet[_TCs] = children.get(_TCs, []) + + ts_rows = ( + dbi.session.query(ApiTestSpecificationModel) + .filter(ApiTestSpecificationModel.api_id == api.id) + .order_by(ApiTestSpecificationModel.offset.asc()) + .all() + ) + grouped_tss = _group_mappings(ts_rows, _TS, "test_specification_id") + + tc_rows = ( + dbi.session.query(ApiTestCaseModel) + .filter(ApiTestCaseModel.api_id == api.id) + .order_by(ApiTestCaseModel.offset.asc()) + .all() + ) + grouped_tcs = _group_mappings(tc_rows, _TC, "test_case_id") + + j_rows = ( + dbi.session.query(ApiJustificationModel) + .filter(ApiJustificationModel.api_id == api.id) + .order_by(ApiJustificationModel.offset.asc()) + .all() + ) + grouped_js = _group_mappings(j_rows, _J, "justification_id") + + doc_rows = ( + dbi.session.query(ApiDocumentModel) + .filter(ApiDocumentModel.api_id == api.id) + .order_by(ApiDocumentModel.offset.asc()) + .all() + ) + grouped_docs = _group_mappings(doc_rows, _D, "document_id") + for doc_group in grouped_docs: + for snippet in doc_group["snippets"]: + if snippet["match"]: + dummy_dm = { + "relation_id": snippet["relation_id"], + "__tablename__": snippet["__tablename__"], + } + children = get_document_children(dbi, dummy_dm) + snippet[_Ds] = children.get(_Ds, []) + + ret = { + "specification": api_specification, + _SRs: grouped_srs, + _TSs: grouped_tss, + _TCs: grouped_tcs, + _Js: grouped_js, + _Ds: grouped_docs, + } + + api_response.set_data(ret) + return api_response.return_ok() + + class ApiJustificationsMapping(Resource): route = "/mapping/api/justifications" fields = ["api-id", "justification", "section", "offset", "coverage"] @@ -11994,6 +12124,7 @@ def get(self, api_response: ApiResponse = None): # Mapping # - Direct api.add_resource(ApiSpecificationsMapping, ApiSpecificationsMapping.route) +api.add_resource(ApiDynamicViewMapping, ApiDynamicViewMapping.route) api.add_resource(ApiDocumentsMapping, ApiDocumentsMapping.route) api.add_resource(ApiJustificationsMapping, ApiJustificationsMapping.route) api.add_resource(ApiLastCoverage, ApiLastCoverage.route) diff --git a/api/test/test_api_dynamic_view_mapping.py b/api/test/test_api_dynamic_view_mapping.py new file mode 100644 index 0000000..989bc2f --- /dev/null +++ b/api/test/test_api_dynamic_view_mapping.py @@ -0,0 +1,440 @@ +import os +import pytest +import tempfile +from http import HTTPStatus + +from db.models.user import UserModel +from db.models.api import ApiModel +from db.models.sw_requirement import SwRequirementModel +from db.models.api_sw_requirement import ApiSwRequirementModel +from db.models.test_specification import TestSpecificationModel +from db.models.api_test_specification import ApiTestSpecificationModel +from db.models.test_case import TestCaseModel +from db.models.api_test_case import ApiTestCaseModel +from db.models.justification import JustificationModel +from db.models.api_justification import ApiJustificationModel +from db.models.document import DocumentModel +from db.models.api_document import ApiDocumentModel +from conftest import UT_USER_EMAIL + + +_DYNAMIC_VIEW_URL = "/mapping/api/dynamic-view" + +_UT_API_NAME = "ut_api" +_UT_API_LIBRARY = "ut_api_library" +_UT_API_LIBRARY_VERSION = "v1.0.0" +_UT_API_CATEGORY = "ut_api_category" +_UT_API_IMPLEMENTATION_FILE_FROM_ROW = 0 +_UT_API_IMPLEMENTATION_FILE_TO_ROW = 42 +_UT_API_TAGS = "ut_api_tags" + +_UT_SECTION_A = "Section Alpha for mapping." +_UT_SECTION_B = "Section Beta for mapping." +_UT_RAW_SPEC = f"BASIL UT: {_UT_SECTION_A} {_UT_SECTION_B} End." +_UT_RAW_RESTRICTED_SPEC = ( + 'BASIL UT: "Reader" user is not able to read this API.' + f" Used for {_DYNAMIC_VIEW_URL}." +) + +_ALL_WORK_ITEM_KEYS = [ + "sw_requirements", + "test_specifications", + "test_cases", + "justifications", + "documents", +] + + +def _create_api_with_spec(client_db, utilities, raw_spec_content): + user = client_db.session.query(UserModel).filter(UserModel.email == UT_USER_EMAIL).one() + + raw_spec = tempfile.NamedTemporaryFile(mode="w", delete=False) + raw_spec.write(raw_spec_content) + raw_spec.close() + + ut_api = ApiModel( + _UT_API_NAME + "#" + utilities.generate_random_hex_string8(), + _UT_API_LIBRARY, + _UT_API_LIBRARY_VERSION, + raw_spec.name, + _UT_API_CATEGORY, + utilities.generate_random_hex_string8(), + raw_spec.name + "impl", + _UT_API_IMPLEMENTATION_FILE_FROM_ROW, + _UT_API_IMPLEMENTATION_FILE_TO_ROW, + _UT_API_TAGS, + user, + ) + client_db.session.add(ut_api) + client_db.session.commit() + return ut_api, raw_spec.name, user + + +@pytest.fixture() +def api_db(client_db, ut_user_db, utilities): + """Create an Api whose specification can be read (no mappings).""" + ut_api, spec_path, _ = _create_api_with_spec(client_db, utilities, _UT_RAW_SPEC) + yield ut_api + if os.path.isfile(spec_path): + os.remove(spec_path) + + +@pytest.fixture() +def restricted_api_db(client_db, ut_user_db, ut_reader_user_db, utilities): + """Create an Api with read restriction for the 'reader' user.""" + ut_api, spec_path, _ = _create_api_with_spec(client_db, utilities, _UT_RAW_RESTRICTED_SPEC) + ut_api.read_denials = f"[{ut_reader_user_db.id}]" + client_db.session.commit() + yield ut_api + if os.path.isfile(spec_path): + os.remove(spec_path) + + +@pytest.fixture() +def api_missing_spec_db(client_db, ut_user_db, utilities): + """Create an Api whose raw_specification_url points to a nonexistent file.""" + user = client_db.session.query(UserModel).filter(UserModel.email == UT_USER_EMAIL).one() + ut_api = ApiModel( + _UT_API_NAME + "#" + utilities.generate_random_hex_string8(), + _UT_API_LIBRARY, + _UT_API_LIBRARY_VERSION, + "/tmp/basil_ut_nonexistent_spec_" + utilities.generate_random_hex_string8(), + _UT_API_CATEGORY, + utilities.generate_random_hex_string8(), + "stub.impl", + _UT_API_IMPLEMENTATION_FILE_FROM_ROW, + _UT_API_IMPLEMENTATION_FILE_TO_ROW, + _UT_API_TAGS, + user, + ) + client_db.session.add(ut_api) + client_db.session.commit() + yield ut_api + + +@pytest.fixture() +def mapped_api_db(client_db, ut_user_db, utilities): + """Create an Api with one mapping of each work-item type on _UT_SECTION_A.""" + ut_api, spec_path, user = _create_api_with_spec(client_db, utilities, _UT_RAW_SPEC) + offset_a = _UT_RAW_SPEC.find(_UT_SECTION_A) + + sr = SwRequirementModel( + f"SR #{utilities.generate_random_hex_string8()}", + "SW shall work.", user, + ) + client_db.session.add(sr) + client_db.session.flush() + client_db.session.add(ApiSwRequirementModel(ut_api, sr, _UT_SECTION_A, offset_a, 0, user)) + + ts = TestSpecificationModel( + f"TS #{utilities.generate_random_hex_string8()}", + "preconditions", "test desc", "expected", user, + ) + client_db.session.add(ts) + client_db.session.flush() + client_db.session.add(ApiTestSpecificationModel(ut_api, ts, _UT_SECTION_A, offset_a, 0, user)) + + tc = TestCaseModel( + "repo", "path/to/test.py", + f"TC #{utilities.generate_random_hex_string8()}", + "test case desc", user, + ) + client_db.session.add(tc) + client_db.session.flush() + client_db.session.add(ApiTestCaseModel(ut_api, tc, _UT_SECTION_A, offset_a, 0, user)) + + j = JustificationModel("justification desc", user) + client_db.session.add(j) + client_db.session.flush() + client_db.session.add(ApiJustificationModel(ut_api, j, _UT_SECTION_A, offset_a, 0, user)) + + doc = DocumentModel( + f"Doc #{utilities.generate_random_hex_string8()}", + "document desc", "file", "", "", "", -1, -1, user, + ) + client_db.session.add(doc) + client_db.session.flush() + client_db.session.add(ApiDocumentModel(ut_api, doc, _UT_SECTION_A, offset_a, 0, user)) + + client_db.session.commit() + + yield ut_api + if os.path.isfile(spec_path): + os.remove(spec_path) + + +@pytest.fixture() +def multi_snippet_api_db(client_db, ut_user_db, utilities): + """Create an Api where the same SW requirement is mapped to two different sections.""" + ut_api, spec_path, user = _create_api_with_spec(client_db, utilities, _UT_RAW_SPEC) + offset_a = _UT_RAW_SPEC.find(_UT_SECTION_A) + offset_b = _UT_RAW_SPEC.find(_UT_SECTION_B) + + sr = SwRequirementModel( + f"SR #{utilities.generate_random_hex_string8()}", + "Shared requirement.", user, + ) + client_db.session.add(sr) + client_db.session.flush() + client_db.session.add(ApiSwRequirementModel(ut_api, sr, _UT_SECTION_A, offset_a, 10, user)) + client_db.session.add(ApiSwRequirementModel(ut_api, sr, _UT_SECTION_B, offset_b, 20, user)) + client_db.session.commit() + + yield ut_api + if os.path.isfile(spec_path): + os.remove(spec_path) + + +def test_login(user_authentication): + assert user_authentication.status_code == HTTPStatus.OK + + +# --- GET: auth & error handling --- + + +def test_get_unauthorized_ok(client, api_db): + """GET without credentials on a non-restricted Api succeeds.""" + response = client.get(_DYNAMIC_VIEW_URL, query_string={"api-id": api_db.id}) + assert response.status_code == HTTPStatus.OK + + +def test_get_unauthorized_fail(client, restricted_api_db): + """GET without credentials on a restricted Api is rejected.""" + response = client.get(_DYNAMIC_VIEW_URL, query_string={"api-id": restricted_api_db.id}) + assert response.status_code == HTTPStatus.UNAUTHORIZED + + +def test_get_authorized_restricted_fail(client, reader_authentication, restricted_api_db): + """GET as the denied reader on a restricted Api is rejected.""" + get_query = { + "user-id": reader_authentication.json["id"], + "token": reader_authentication.json["token"], + "api-id": restricted_api_db.id, + } + response = client.get(_DYNAMIC_VIEW_URL, query_string=get_query) + assert response.status_code == HTTPStatus.UNAUTHORIZED + + +def test_get_authorized_restricted_ok(client, user_authentication, restricted_api_db): + """GET as the Api author on a restricted Api succeeds.""" + get_query = { + "user-id": user_authentication.json["id"], + "token": user_authentication.json["token"], + "api-id": restricted_api_db.id, + } + response = client.get(_DYNAMIC_VIEW_URL, query_string=get_query) + assert response.status_code == HTTPStatus.OK + + +@pytest.mark.usefixtures("api_db") +def test_get_missing_api_id(client, user_authentication): + """GET without api-id returns BAD_REQUEST.""" + get_query = { + "user-id": user_authentication.json["id"], + "token": user_authentication.json["token"], + } + response = client.get(_DYNAMIC_VIEW_URL, query_string=get_query) + assert response.status_code == HTTPStatus.BAD_REQUEST + + +@pytest.mark.usefixtures("api_db") +def test_get_nonexistent_api(client_db, client, user_authentication): + """GET with a non-existent api-id returns NOT_FOUND.""" + non_existent_id = 42000 + assert client_db.session.query(ApiModel).get(non_existent_id) is None + + get_query = { + "user-id": user_authentication.json["id"], + "token": user_authentication.json["token"], + "api-id": non_existent_id, + } + response = client.get(_DYNAMIC_VIEW_URL, query_string=get_query) + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_get_missing_specification_file(client, user_authentication, api_missing_spec_db): + """GET when the raw specification file does not exist returns NOT_FOUND.""" + get_query = { + "user-id": user_authentication.json["id"], + "token": user_authentication.json["token"], + "api-id": api_missing_spec_db.id, + } + response = client.get(_DYNAMIC_VIEW_URL, query_string=get_query) + assert response.status_code == HTTPStatus.NOT_FOUND + + +# --- GET: response structure --- + + +def test_get_returns_specification_text(client, api_db): + """Response contains the full specification string.""" + response = client.get(_DYNAMIC_VIEW_URL, query_string={"api-id": api_db.id}) + assert response.status_code == HTTPStatus.OK + assert response.json["specification"] == _UT_RAW_SPEC + + +def test_get_empty_api_has_all_keys(client, api_db): + """An Api with no mappings returns empty lists for every work-item type.""" + response = client.get(_DYNAMIC_VIEW_URL, query_string={"api-id": api_db.id}) + assert response.status_code == HTTPStatus.OK + + data = response.json + assert "specification" in data + for key in _ALL_WORK_ITEM_KEYS: + assert key in data, f"missing key '{key}'" + assert data[key] == [] + + +# --- GET: with mappings --- + + +def test_get_mapped_returns_all_types(client, mapped_api_db): + """An Api with one mapping of each type returns exactly one group per type.""" + response = client.get(_DYNAMIC_VIEW_URL, query_string={"api-id": mapped_api_db.id}) + assert response.status_code == HTTPStatus.OK + + data = response.json + assert len(data["sw_requirements"]) == 1 + assert len(data["test_specifications"]) == 1 + assert len(data["test_cases"]) == 1 + assert len(data["justifications"]) == 1 + assert len(data["documents"]) == 1 + + +def test_get_mapped_group_has_snippets(client, mapped_api_db): + """Each work-item group contains a 'snippets' list with at least one entry.""" + response = client.get(_DYNAMIC_VIEW_URL, query_string={"api-id": mapped_api_db.id}) + assert response.status_code == HTTPStatus.OK + + data = response.json + for key in _ALL_WORK_ITEM_KEYS: + for group in data[key]: + assert "snippets" in group + assert len(group["snippets"]) >= 1 + + +def test_get_mapped_snippet_fields(client, mapped_api_db): + """Snippets carry section, offset, relation_id, coverage, covered, and match.""" + response = client.get(_DYNAMIC_VIEW_URL, query_string={"api-id": mapped_api_db.id}) + assert response.status_code == HTTPStatus.OK + + expected_snippet_keys = {"section", "offset", "relation_id", "coverage", "covered", "match", "__tablename__"} + data = response.json + for key in _ALL_WORK_ITEM_KEYS: + for group in data[key]: + for snippet in group["snippets"]: + assert expected_snippet_keys.issubset(snippet.keys()), ( + f"snippet in '{key}' missing keys: {expected_snippet_keys - snippet.keys()}" + ) + + +def test_get_mapped_snippet_match_flag(client, mapped_api_db): + """Snippets whose section text matches the specification at the given offset have match=True.""" + response = client.get(_DYNAMIC_VIEW_URL, query_string={"api-id": mapped_api_db.id}) + assert response.status_code == HTTPStatus.OK + + data = response.json + for key in _ALL_WORK_ITEM_KEYS: + for group in data[key]: + for snippet in group["snippets"]: + assert snippet["section"] == _UT_SECTION_A + assert snippet["match"] is True + + +def test_get_mapped_sr_group_has_work_item(client, mapped_api_db): + """SW-requirement groups contain the 'sw_requirement' dict with at least a title.""" + response = client.get(_DYNAMIC_VIEW_URL, query_string={"api-id": mapped_api_db.id}) + assert response.status_code == HTTPStatus.OK + + sr_groups = response.json["sw_requirements"] + assert len(sr_groups) == 1 + assert "sw_requirement" in sr_groups[0] + assert "title" in sr_groups[0]["sw_requirement"] + + +def test_get_mapped_ts_group_has_work_item(client, mapped_api_db): + """Test-specification groups contain the 'test_specification' dict.""" + response = client.get(_DYNAMIC_VIEW_URL, query_string={"api-id": mapped_api_db.id}) + assert response.status_code == HTTPStatus.OK + + ts_groups = response.json["test_specifications"] + assert len(ts_groups) == 1 + assert "test_specification" in ts_groups[0] + assert "title" in ts_groups[0]["test_specification"] + + +def test_get_mapped_tc_group_has_work_item(client, mapped_api_db): + """Test-case groups contain the 'test_case' dict.""" + response = client.get(_DYNAMIC_VIEW_URL, query_string={"api-id": mapped_api_db.id}) + assert response.status_code == HTTPStatus.OK + + tc_groups = response.json["test_cases"] + assert len(tc_groups) == 1 + assert "test_case" in tc_groups[0] + assert "title" in tc_groups[0]["test_case"] + + +def test_get_mapped_justification_group_has_work_item(client, mapped_api_db): + """Justification groups contain the 'justification' dict.""" + response = client.get(_DYNAMIC_VIEW_URL, query_string={"api-id": mapped_api_db.id}) + assert response.status_code == HTTPStatus.OK + + j_groups = response.json["justifications"] + assert len(j_groups) == 1 + assert "justification" in j_groups[0] + + +def test_get_mapped_document_group_has_work_item(client, mapped_api_db): + """Document groups contain the 'document' dict.""" + response = client.get(_DYNAMIC_VIEW_URL, query_string={"api-id": mapped_api_db.id}) + assert response.status_code == HTTPStatus.OK + + doc_groups = response.json["documents"] + assert len(doc_groups) == 1 + assert "document" in doc_groups[0] + assert "title" in doc_groups[0]["document"] + + +# --- GET: deduplication --- + + +def test_get_multi_snippet_deduplication(client, multi_snippet_api_db): + """Two mappings of the same SW requirement produce one group with two snippets.""" + response = client.get(_DYNAMIC_VIEW_URL, query_string={"api-id": multi_snippet_api_db.id}) + assert response.status_code == HTTPStatus.OK + + sr_groups = response.json["sw_requirements"] + assert len(sr_groups) == 1 + snippets = sr_groups[0]["snippets"] + assert len(snippets) == 2 + + sections = {s["section"] for s in snippets} + assert sections == {_UT_SECTION_A, _UT_SECTION_B} + + +def test_get_multi_snippet_coverage_values(client, multi_snippet_api_db): + """Each snippet in a deduplicated group retains its own coverage value.""" + response = client.get(_DYNAMIC_VIEW_URL, query_string={"api-id": multi_snippet_api_db.id}) + assert response.status_code == HTTPStatus.OK + + snippets = response.json["sw_requirements"][0]["snippets"] + coverages = {s["section"]: s["coverage"] for s in snippets} + assert coverages[_UT_SECTION_A] == 10 + assert coverages[_UT_SECTION_B] == 20 + + +# --- GET: SR children on matching snippets --- + + +def test_get_sr_snippet_has_children_keys(client, mapped_api_db): + """Matching SR snippets carry children keys for nested work items.""" + response = client.get(_DYNAMIC_VIEW_URL, query_string={"api-id": mapped_api_db.id}) + assert response.status_code == HTTPStatus.OK + + sr_groups = response.json["sw_requirements"] + for group in sr_groups: + for snippet in group["snippets"]: + if snippet["match"]: + assert "sw_requirements" in snippet + assert "test_specifications" in snippet + assert "test_cases" in snippet diff --git a/app/cypress.config.ts b/app/cypress.config.ts index e4d79a7..baa2027 100644 --- a/app/cypress.config.ts +++ b/app/cypress.config.ts @@ -6,6 +6,6 @@ export default defineConfig({ setupNodeEvents(on, config) { failFastPlugin(on, config) return config - }, - }, + } + } }) diff --git a/app/cypress/e2e/mapping_dynamic_view.cy.js b/app/cypress/e2e/mapping_dynamic_view.cy.js new file mode 100644 index 0000000..e0c6481 --- /dev/null +++ b/app/cypress/e2e/mapping_dynamic_view.cy.js @@ -0,0 +1,232 @@ +/// + +import '../support/e2e.js' +import api_data_fixture from '../fixtures/api.json' +import const_data from '../fixtures/consts.json' +import sr_data_fixture from '../fixtures/sw_requirement.json' +import tc_data_fixture from '../fixtures/test_case.json' +import j_data_fixture from '../fixtures/justification.json' +import { createUniqWorkItems } from '../support/utils.js' + +let api_data = createUniqWorkItems(api_data_fixture, ['api']) +let sr_data = createUniqWorkItems(sr_data_fixture, ['title']) +let tc_data = createUniqWorkItems(tc_data_fixture, ['title']) +let j_data = createUniqWorkItems(j_data_fixture, ['description']) + +describe('Dynamic View Mapping', () => { + beforeEach(() => { + cy.login_admin() + }) + + it('Add SW Component', () => { + cy.get('#btn-add-sw-component').click() + cy.fill_form_api('0', 'add', api_data.first, true, false) + cy.get('#btn-modal-api-confirm').click() + cy.wait(2000) + cy.filter_api_from_dashboard(api_data.first) + }) + + it('Switch to Dynamic View with no work items', () => { + let id + + cy.filter_api_from_dashboard(api_data.first) + + cy.get(const_data.api.table_listing_id) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .invoke('text') + .then((elem) => { + id = elem + cy.visit(const_data.app_base_url + '/mapping/' + id) + cy.wait(const_data.long_wait) + + cy.get(const_data.mapping.select_view_id).select('dynamic-view', { force: true }) + cy.wait(const_data.long_wait) + + cy.get('#table-dynamic-view').should('exist') + cy.get('#table-dynamic-view').contains('No work items mapped to this specification.') + }) + }) + + it('Dynamic View shows mapped Software Requirement', () => { + let id + + cy.filter_api_from_dashboard(api_data.first) + + cy.get(const_data.api.table_listing_id) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .invoke('text') + .then((elem) => { + id = elem + cy.visit(const_data.app_base_url + '/mapping/' + id) + cy.wait(const_data.long_wait) + + cy.assign_work_item(-1, 0, '', 'sw-requirement', sr_data.first) + + cy.get(const_data.mapping.select_view_id).select('dynamic-view', { force: true }) + cy.wait(const_data.long_wait) + + cy.get('#table-dynamic-view').should('exist') + cy.get('#table-dynamic-view').find('.pf-v5-c-card').should('have.length.greaterThan', 0) + cy.get('#table-dynamic-view').contains('h3', 'Software Requirements') + cy.get('#table-dynamic-view').contains('h5', sr_data.first.title) + + cy.get(const_data.mapping.select_view_id).select('sw-requirements', { force: true }) + cy.wait(const_data.long_wait) + + cy.delete_work_item(0, 'sw-requirement') + cy.wait(const_data.long_wait) + }) + }) + + it('Dynamic View shows mapped Justification', () => { + let id + + cy.filter_api_from_dashboard(api_data.first) + + cy.get(const_data.api.table_listing_id) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .invoke('text') + .then((elem) => { + id = elem + cy.visit(const_data.app_base_url + '/mapping/' + id) + cy.wait(const_data.long_wait) + + cy.assign_work_item(-1, 0, '', 'justification', j_data.first) + + cy.get(const_data.mapping.select_view_id).select('dynamic-view', { force: true }) + cy.wait(const_data.long_wait) + + cy.get('#table-dynamic-view').should('exist') + cy.get('#table-dynamic-view').contains('h3', 'Justifications') + cy.get('#table-dynamic-view').contains(j_data.first.description) + + cy.get(const_data.mapping.select_view_id).select('sw-requirements', { force: true }) + cy.wait(const_data.long_wait) + + cy.delete_work_item(0, 'justification') + cy.wait(const_data.long_wait) + }) + }) + + it('Dynamic View card selection highlights snippets', () => { + let id + + cy.filter_api_from_dashboard(api_data.first) + + cy.get(const_data.api.table_listing_id) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .invoke('text') + .then((elem) => { + id = elem + cy.visit(const_data.app_base_url + '/mapping/' + id) + cy.wait(const_data.long_wait) + + cy.assign_work_item(-1, 0, '', 'sw-requirement', sr_data.second) + + cy.get(const_data.mapping.select_view_id).select('dynamic-view', { force: true }) + cy.wait(const_data.long_wait) + + cy.get('#table-dynamic-view').find('.pf-v5-c-card').first().click() + cy.wait(const_data.fast_wait) + + cy.get('#table-dynamic-view').contains('button', 'Show full document').should('exist') + + cy.get('#table-dynamic-view').contains('button', 'Show full document').scrollIntoView().click({ force: true }) + cy.wait(const_data.fast_wait) + + cy.get('#table-dynamic-view').contains('button', 'Show full document').should('not.exist') + + cy.get(const_data.mapping.select_view_id).select('sw-requirements', { force: true }) + cy.wait(const_data.long_wait) + + cy.delete_work_item(0, 'sw-requirement') + cy.wait(const_data.long_wait) + }) + }) + + it('Dynamic View shows multiple work item types', () => { + let id + + cy.filter_api_from_dashboard(api_data.first) + + cy.get(const_data.api.table_listing_id) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .invoke('text') + .then((elem) => { + id = elem + cy.visit(const_data.app_base_url + '/mapping/' + id) + cy.wait(const_data.long_wait) + + cy.assign_work_item(-1, 0, '', 'sw-requirement', sr_data.third) + + cy.get(const_data.mapping.select_view_id).select('test-cases', { force: true }) + cy.wait(const_data.long_wait) + + cy.assign_work_item(-1, 0, '', 'test-case', tc_data.first) + + cy.get(const_data.mapping.select_view_id).select('dynamic-view', { force: true }) + cy.wait(const_data.long_wait) + + cy.get('#table-dynamic-view').should('exist') + cy.get('#table-dynamic-view').contains('h3', 'Software Requirements') + cy.get('#table-dynamic-view').contains('h5', sr_data.third.title) + cy.get('#table-dynamic-view').contains('h3', 'Test Cases') + cy.get('#table-dynamic-view').contains('h5', tc_data.first.title) + + cy.get(const_data.mapping.select_view_id).select('test-cases', { force: true }) + cy.wait(const_data.long_wait) + cy.delete_work_item(0, 'test-case') + cy.wait(const_data.long_wait) + + cy.get(const_data.mapping.select_view_id).select('sw-requirements', { force: true }) + cy.wait(const_data.long_wait) + cy.delete_work_item(0, 'sw-requirement') + cy.wait(const_data.long_wait) + }) + }) + + it('Dynamic View displays specification content', () => { + let id + + cy.filter_api_from_dashboard(api_data.first) + + cy.get(const_data.api.table_listing_id) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .invoke('text') + .then((elem) => { + id = elem + cy.visit(const_data.app_base_url + '/mapping/' + id) + cy.wait(const_data.long_wait) + + cy.get(const_data.mapping.select_view_id).select('dynamic-view', { force: true }) + cy.wait(const_data.long_wait) + + cy.get('#table-dynamic-view').find('pre').should('exist') + cy.get('#table-dynamic-view').find('pre').invoke('text').should('have.length.greaterThan', 0) + }) + }) +}) diff --git a/app/cypress/e2e/mapping_nested_documents.cy.js b/app/cypress/e2e/mapping_nested_documents.cy.js new file mode 100644 index 0000000..77984f0 --- /dev/null +++ b/app/cypress/e2e/mapping_nested_documents.cy.js @@ -0,0 +1,61 @@ +/// + +import '../support/e2e.js' +import api_data_fixture from '../fixtures/api.json' +import const_data from '../fixtures/consts.json' +import doc_data_fixture from '../fixtures/document.json' +import { createUniqWorkItems } from '../support/utils.js' + +// Create uniq work items +// Appending to each dictionary of the fixture data a date string to a target field +let api_data = createUniqWorkItems(api_data_fixture, ['api']) +let doc_data = createUniqWorkItems(doc_data_fixture, ['title']) + +describe('Nested Document Mapping', () => { + beforeEach(() => { + cy.login_admin() + }) + + it('Add SW Component', () => { + // Add SW Component + cy.get('#btn-add-sw-component').click() + cy.fill_form_api('0', 'add', api_data.first, true, false) + cy.get('#btn-modal-api-confirm').click() + cy.wait(2000) + // Check Sw component has been created + cy.get('#input-api-list-search').type('{selectAll}{del}' + api_data.first.api + '{enter}') + cy.wait(2000) + cy.get(const_data.api.table_listing_id).find('tbody').find('tr').find('td').contains(api_data.first.api) + }) + + it('Matching Nested Documents', () => { + let id + // Select the api from the list + cy.filter_api_from_dashboard(api_data.first) + + cy.get(const_data.api.table_listing_id) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .invoke('text') + .then((elem) => { + id = elem + cy.visit(const_data.app_base_url + '/mapping/' + id) + cy.wait(const_data.long_wait) + + // Map a new top-level Document to the API + cy.assign_work_item(-1, 0, '', 'document', doc_data.first) + cy.edit_work_item(0, 'document', doc_data.first_mod) + + // Map a nested Document under the first Document + cy.assign_work_item(0, 1, 'document', 'document', doc_data.second) + cy.edit_work_item(1, 'document', doc_data.second_mod) + + // Clean up in reverse order + cy.delete_work_item(1, 'document') + cy.delete_work_item(0, 'document') + }) + }) +}) diff --git a/app/cypress/e2e/test_failing_tmt_test_case.cy.js b/app/cypress/e2e/test_failing_tmt_test_case.cy.js index ab88c4c..f8f2e3a 100644 --- a/app/cypress/e2e/test_failing_tmt_test_case.cy.js +++ b/app/cypress/e2e/test_failing_tmt_test_case.cy.js @@ -51,7 +51,7 @@ const test_case_data = { title: 'Failing TMT Test ' + new Date().getTime(), description: 'Test case for dummy failing TMT test', repository: deployment_base_path, - relative_path: '/api/user-files/1/tmt-dummy-failing-test.fmf', + relative_path: '/api/user-files/1/tmt-dummy-failing-test.fmf' } // Test run data diff --git a/app/cypress/e2e/test_passing_tmt_test_case.cy.js b/app/cypress/e2e/test_passing_tmt_test_case.cy.js index aaa6616..42f1b15 100644 --- a/app/cypress/e2e/test_passing_tmt_test_case.cy.js +++ b/app/cypress/e2e/test_passing_tmt_test_case.cy.js @@ -43,7 +43,7 @@ const test_case_data = { title: 'Passing TMT Test ' + new Date().getTime(), description: 'Test case for dummy passing TMT test', repository: 'https://github.com/elisa-tech/BASIL.git', - relative_path: 'examples/tmt/local/tmt-dummy-test.fmf', + relative_path: 'examples/tmt/local/tmt-dummy-test.fmf' } // Test run data diff --git a/app/cypress/e2e/user_files.cy.js b/app/cypress/e2e/user_files.cy.js index 2c7d298..8817f0c 100644 --- a/app/cypress/e2e/user_files.cy.js +++ b/app/cypress/e2e/user_files.cy.js @@ -23,11 +23,17 @@ describe('User Files - Nested Folder Support', { testIsolation: false }, () => { cy.get('#input-create-folder-name').type('test_folder_' + UNIQUE) cy.get('#btn-user-file-modal-confirm').click() cy.wait(const_data.long_wait) - cy.get('#table-user-files').find('tbody').contains('test_folder_' + UNIQUE).should('exist') + cy.get('#table-user-files') + .find('tbody') + .contains('test_folder_' + UNIQUE) + .should('exist') }) it('Navigate into folder via click', () => { - cy.get('#table-user-files').find('tbody').contains('test_folder_' + UNIQUE).click({ force: true }) + cy.get('#table-user-files') + .find('tbody') + .contains('test_folder_' + UNIQUE) + .click({ force: true }) cy.wait(const_data.mid_wait) cy.get('#breadcrumb-0').should('contain.text', 'test_folder_' + UNIQUE) cy.get('#table-user-files').find('tbody').should('contain.text', 'empty') @@ -47,13 +53,19 @@ describe('User Files - Nested Folder Support', { testIsolation: false }, () => { cy.wait(const_data.mid_wait) cy.get('#btn-user-file-modal-confirm').click() cy.wait(const_data.long_wait) - cy.get('#table-user-files').find('tbody').contains('nested_file_' + UNIQUE + '.yaml').should('exist') + cy.get('#table-user-files') + .find('tbody') + .contains('nested_file_' + UNIQUE + '.yaml') + .should('exist') }) it('Navigate back to root via breadcrumb', () => { cy.get('#breadcrumb-root').click({ force: true }) cy.wait(const_data.mid_wait) - cy.get('#table-user-files').find('tbody').contains('test_folder_' + UNIQUE).should('exist') + cy.get('#table-user-files') + .find('tbody') + .contains('test_folder_' + UNIQUE) + .should('exist') }) it('Create a subfolder for move test', () => { @@ -62,7 +74,10 @@ describe('User Files - Nested Folder Support', { testIsolation: false }, () => { cy.get('#input-create-folder-name').type('move_dest_' + UNIQUE) cy.get('#btn-user-file-modal-confirm').click() cy.wait(const_data.long_wait) - cy.get('#table-user-files').find('tbody').contains('move_dest_' + UNIQUE).should('exist') + cy.get('#table-user-files') + .find('tbody') + .contains('move_dest_' + UNIQUE) + .should('exist') }) it('Upload a file at root for move test', () => { @@ -79,7 +94,10 @@ describe('User Files - Nested Folder Support', { testIsolation: false }, () => { cy.wait(const_data.mid_wait) cy.get('#btn-user-file-modal-confirm').click() cy.wait(const_data.long_wait) - cy.get('#table-user-files').find('tbody').contains('movable_' + UNIQUE + '.txt').should('exist') + cy.get('#table-user-files') + .find('tbody') + .contains('movable_' + UNIQUE + '.txt') + .should('exist') }) it('Move file into folder', () => { @@ -95,11 +113,20 @@ describe('User Files - Nested Folder Support', { testIsolation: false }, () => { cy.get('#input-move-destination').type('move_dest_' + UNIQUE) cy.get('#btn-user-file-modal-confirm').click() cy.wait(const_data.long_wait) - cy.get('#table-user-files').find('tbody').contains('movable_' + UNIQUE + '.txt').should('not.exist') + cy.get('#table-user-files') + .find('tbody') + .contains('movable_' + UNIQUE + '.txt') + .should('not.exist') - cy.get('#table-user-files').find('tbody').contains('move_dest_' + UNIQUE).click({ force: true }) + cy.get('#table-user-files') + .find('tbody') + .contains('move_dest_' + UNIQUE) + .click({ force: true }) cy.wait(const_data.mid_wait) - cy.get('#table-user-files').find('tbody').contains('movable_' + UNIQUE + '.txt').should('exist') + cy.get('#table-user-files') + .find('tbody') + .contains('movable_' + UNIQUE + '.txt') + .should('exist') cy.get('#breadcrumb-root').click({ force: true }) cy.wait(const_data.mid_wait) }) @@ -114,11 +141,19 @@ describe('User Files - Nested Folder Support', { testIsolation: false }, () => { cy.wait(const_data.fast_wait) cy.get('[id^="btn-menu-user-file-rename-"]').click() cy.wait(const_data.fast_wait) - cy.get('#input-rename-name').clear().type('renamed_' + UNIQUE) + cy.get('#input-rename-name') + .clear() + .type('renamed_' + UNIQUE) cy.get('#btn-user-file-modal-confirm').click() cy.wait(const_data.long_wait) - cy.get('#table-user-files').find('tbody').contains('renamed_' + UNIQUE).should('exist') - cy.get('#table-user-files').find('tbody').contains('move_dest_' + UNIQUE).should('not.exist') + cy.get('#table-user-files') + .find('tbody') + .contains('renamed_' + UNIQUE) + .should('exist') + cy.get('#table-user-files') + .find('tbody') + .contains('move_dest_' + UNIQUE) + .should('not.exist') }) it('Delete a folder', () => { @@ -133,7 +168,10 @@ describe('User Files - Nested Folder Support', { testIsolation: false }, () => { cy.wait(const_data.fast_wait) cy.get('#btn-user-file-modal-confirm').click() cy.wait(const_data.long_wait) - cy.get('#table-user-files').find('tbody').contains('renamed_' + UNIQUE).should('not.exist') + cy.get('#table-user-files') + .find('tbody') + .contains('renamed_' + UNIQUE) + .should('not.exist') }) it('Delete test folder', () => { @@ -148,6 +186,9 @@ describe('User Files - Nested Folder Support', { testIsolation: false }, () => { cy.wait(const_data.fast_wait) cy.get('#btn-user-file-modal-confirm').click() cy.wait(const_data.long_wait) - cy.get('#table-user-files').find('tbody').contains('test_folder_' + UNIQUE).should('not.exist') + cy.get('#table-user-files') + .find('tbody') + .contains('test_folder_' + UNIQUE) + .should('not.exist') }) }) diff --git a/app/cypress/support/commands.js b/app/cypress/support/commands.js index 04bbf9f..1cff4cc 100644 --- a/app/cypress/support/commands.js +++ b/app/cypress/support/commands.js @@ -158,7 +158,14 @@ export function registerCommands() { const tableOptions = { timeout: 15000 } //Type Check - card = cy.get(const_data.mapping.table_matching_id, tableOptions).find('tbody').find('tr').eq(0).find('td').eq(1).find('.pf-v5-c-card') + card = cy + .get(const_data.mapping.table_matching_id, tableOptions) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .find('.pf-v5-c-card', tableOptions) card.each(($el, index, $list) => { if (index == _index) { @@ -170,7 +177,14 @@ export function registerCommands() { }) //Title Check - card = cy.get(const_data.mapping.table_matching_id, tableOptions).find('tbody').find('tr').eq(0).find('td').eq(1).find('.pf-v5-c-card') + card = cy + .get(const_data.mapping.table_matching_id, tableOptions) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .find('.pf-v5-c-card', tableOptions) card.each(($el, index, $list) => { if (index == _index) { @@ -186,7 +200,14 @@ export function registerCommands() { }) //Description Check - card = cy.get(const_data.mapping.table_matching_id, tableOptions).find('tbody').find('tr').eq(0).find('td').eq(1).find('.pf-v5-c-card') + card = cy + .get(const_data.mapping.table_matching_id, tableOptions) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .find('.pf-v5-c-card', tableOptions) card.each(($el, index, $list) => { if (index == _index) { @@ -209,14 +230,21 @@ export function registerCommands() { // Index 0 based let i = 0 let card + const tableOptions = { timeout: 15000 } //Click Toggle Menu - card = cy.get(const_data.mapping.table_matching_id).find('tbody').find('tr').eq(0).find('td').eq(1).find('.pf-v5-c-card') + card = cy + .get(const_data.mapping.table_matching_id, tableOptions) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .find('.pf-v5-c-card', tableOptions) card.find('button[class*="pf-v5-c-menu-toggle"]').each(($el, index, $list) => { // $el is a wrapped jQuery element if (index == _index) { - console.log('index: ' + index) cy.wrap($el).click() } else { // do something else @@ -224,12 +252,18 @@ export function registerCommands() { }) //Click Delete Button - card = cy.get(const_data.mapping.table_matching_id).find('tbody').find('tr').eq(0).find('td').eq(1).find('.pf-v5-c-card') + card = cy + .get(const_data.mapping.table_matching_id, tableOptions) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .find('.pf-v5-c-card', tableOptions) card.each(($el, index, $list) => { // $el is a wrapped jQuery element if (index == _index) { - console.log('index: ' + index) cy.wrap($el) .find('button[id^="btn-menu-' + _type + '-delete"]') .click() @@ -244,10 +278,18 @@ export function registerCommands() { // Index 0 based let i = 0 let card + const tableOptions = { timeout: 15000 } if (_parent_index > -1) { //Click Toggle Menu - card = cy.get(const_data.mapping.table_matching_id).find('tbody').find('tr').eq(0).find('td').eq(1).find('.pf-v5-c-card') + card = cy + .get(const_data.mapping.table_matching_id, tableOptions) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .find('.pf-v5-c-card', tableOptions) card.find('button[class*="pf-v5-c-menu-toggle"]').each(($el, index, $list) => { // $el is a wrapped jQuery element @@ -259,7 +301,14 @@ export function registerCommands() { }) //Click Assign Button - card = cy.get(const_data.mapping.table_matching_id).find('tbody').find('tr').eq(0).find('td').eq(1).find('.pf-v5-c-card') + card = cy + .get(const_data.mapping.table_matching_id, tableOptions) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .find('.pf-v5-c-card', tableOptions) card.each(($el, index, $list) => { // $el is a wrapped jQuery element @@ -338,10 +387,18 @@ export function registerCommands() { // Index 0 based let i = 0 let card + const tableOptions = { timeout: 15000 } if (_parent_index > -1) { //Click Toggle Menu - card = cy.get(const_data.mapping.table_matching_id).find('tbody').find('tr').eq(0).find('td').eq(1).find('.pf-v5-c-card') + card = cy + .get(const_data.mapping.table_matching_id, tableOptions) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .find('.pf-v5-c-card', tableOptions) card.find('button[class*="pf-v5-c-menu-toggle"]').each(($el, index, $list) => { // $el is a wrapped jQuery element @@ -353,7 +410,14 @@ export function registerCommands() { }) //Click Assign Button - card = cy.get(const_data.mapping.table_matching_id).find('tbody').find('tr').eq(0).find('td').eq(1).find('.pf-v5-c-card') + card = cy + .get(const_data.mapping.table_matching_id, tableOptions) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .find('.pf-v5-c-card', tableOptions) card.each(($el, index, $list) => { // $el is a wrapped jQuery element @@ -396,9 +460,17 @@ export function registerCommands() { // Index 0 based let i = 0 let card + const tableOptions = { timeout: 15000 } //Click Toggle Menu - card = cy.get(const_data.mapping.table_matching_id).find('tbody').find('tr').eq(0).find('td').eq(1).find('.pf-v5-c-card') + card = cy + .get(const_data.mapping.table_matching_id, tableOptions) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .find('.pf-v5-c-card', tableOptions) card.find('button[class*="pf-v5-c-menu-toggle"]').each(($el, index, $list) => { // $el is a wrapped jQuery element @@ -410,12 +482,18 @@ export function registerCommands() { }) //Click Edit Button - card = cy.get(const_data.mapping.table_matching_id).find('tbody').find('tr').eq(0).find('td').eq(1).find('.pf-v5-c-card') + card = cy + .get(const_data.mapping.table_matching_id, tableOptions) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .find('.pf-v5-c-card', tableOptions) card.each(($el, index, $list) => { // $el is a wrapped jQuery element if (index == _index) { - console.log('index: ' + index) cy.wrap($el) .find('button[id^="btn-menu-' + _type + '-edit"]') .click() diff --git a/app/package-lock.json b/app/package-lock.json index 961da20..f2797d6 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -46,7 +46,7 @@ "jest-environment-jsdom": "^29.6.1", "mini-css-extract-plugin": "^2.7.6", "postcss": "^8.4.25", - "prettier": "3.2.5", + "prettier": "^3.8.3", "prop-types": "^15.8.1", "raw-loader": "^4.0.2", "react-axe": "^3.5.4", @@ -14608,10 +14608,11 @@ } }, "node_modules/prettier": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", - "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, + "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, @@ -30161,9 +30162,9 @@ "dev": true }, "prettier": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", - "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true }, "prettier-linter-helpers": { diff --git a/app/package.json b/app/package.json index c3afde5..bca3201 100644 --- a/app/package.json +++ b/app/package.json @@ -46,7 +46,7 @@ "jest-environment-jsdom": "^29.6.1", "mini-css-extract-plugin": "^2.7.6", "postcss": "^8.4.25", - "prettier": "3.2.5", + "prettier": "^3.8.3", "prop-types": "^15.8.1", "raw-loader": "^4.0.2", "react-axe": "^3.5.4", diff --git a/app/src/app/Constants/constants.tsx b/app/src/app/Constants/constants.tsx index d13750f..a9aa1be 100644 --- a/app/src/app/Constants/constants.tsx +++ b/app/src/app/Constants/constants.tsx @@ -12,6 +12,7 @@ export const _Ds = 'documents' export const _J = 'justification' export const _Js = 'justifications' export const _M_ = '_mapping_' +export const _DV = 'dynamic-view' export const _RS = 'specifications' export const _SR = 'sw-requirement' export const _SRs = 'sw-requirements' diff --git a/app/src/app/Dashboard/Form/APIForm.tsx b/app/src/app/Dashboard/Form/APIForm.tsx index 18f09e3..ea5cb63 100644 --- a/app/src/app/Dashboard/Form/APIForm.tsx +++ b/app/src/app/Dashboard/Form/APIForm.tsx @@ -94,17 +94,21 @@ export const APIForm: React.FunctionComponent = ({ const radio_dv_sr = document.getElementById('radio-default-view-sw-requirements') as HTMLInputElement const radio_dv_tc = document.getElementById('radio-default-view-test-cases') as HTMLInputElement const radio_dv_ts = document.getElementById('radio-default-view-test-specifications') as HTMLInputElement - - if (radio_dv_rs != null && radio_dv_sr != null && radio_dv_tc != null && radio_dv_ts != null) { - if (radio_dv_rs.checked) { - setDefaultViewValue(Constants._RS) - } else if (radio_dv_sr.checked) { - setDefaultViewValue(Constants._SRs) - } else if (radio_dv_tc.checked) { - setDefaultViewValue(Constants._TCs) - } else if (radio_dv_ts.checked) { - setDefaultViewValue(Constants._TSs) - } + const radio_dv_j = document.getElementById('radio-default-view-justifications') as HTMLInputElement + const radio_dv_dv = document.getElementById('radio-default-view-dynamic-view') as HTMLInputElement + + if (radio_dv_rs?.checked) { + setDefaultViewValue(Constants._RS) + } else if (radio_dv_sr?.checked) { + setDefaultViewValue(Constants._SRs) + } else if (radio_dv_tc?.checked) { + setDefaultViewValue(Constants._TCs) + } else if (radio_dv_ts?.checked) { + setDefaultViewValue(Constants._TSs) + } else if (radio_dv_j?.checked) { + setDefaultViewValue(Constants._Js) + } else if (radio_dv_dv?.checked) { + setDefaultViewValue(Constants._DV) } } @@ -623,6 +627,20 @@ export const APIForm: React.FunctionComponent = ({ label='Test Specifications' id='radio-default-view-test-specifications' > + + diff --git a/app/src/app/Mapping/Mapping.tsx b/app/src/app/Mapping/Mapping.tsx index 98bc729..9d0170d 100644 --- a/app/src/app/Mapping/Mapping.tsx +++ b/app/src/app/Mapping/Mapping.tsx @@ -16,6 +16,7 @@ const Mapping: React.FunctionComponent = () => { const [apiData, setApiData] = React.useState(null) const [mappingData, setMappingData] = React.useState([]) const [unmappingData, setUnmappingData] = React.useState([]) + const [dynamicViewData, setDynamicViewData] = React.useState(null) const [totalCoverage, setTotalCoverage] = React.useState(-1) const { api_id } = useParams<{ api_id: string }>() @@ -51,8 +52,15 @@ const Mapping: React.FunctionComponent = () => { fetch(url) .then((res) => res.json()) .then((data) => { - setMappingData(data['mapped']) - setUnmappingData(data['unmapped']) + if (mappingViewSelectValue === Constants._DV) { + setDynamicViewData(data) + setMappingData([]) + setUnmappingData([]) + } else { + setDynamicViewData(null) + setMappingData(data['mapped']) + setUnmappingData(data['unmapped']) + } }) .catch((err) => { console.log(err.message) @@ -150,7 +158,7 @@ const Mapping: React.FunctionComponent = () => { return ( - + @@ -169,6 +177,7 @@ const Mapping: React.FunctionComponent = () => { = ({ + api, + dynamicViewData, + setDocModalInfo, + setTsModalInfo, + setTcModalInfo, + setSrModalInfo, + setJModalInfo, + setCommentModalInfo, + setDeleteModalInfo, + setDetailsModalInfo, + setForkModalInfo, + setHistoryModalInfo, + setImplementationModalInfo, + setTestResultsModalInfo, + setTestRunModalInfo, + setUsageModalInfo, + setModalNotificationInfo, + setSnippetsModalInfo, + showIndirectTestCases, + showIndirectTestSpecifications +}: MappingDynamicViewTableProps) => { + const auth = useAuth() + const [selectedWorkItem, setSelectedWorkItem] = React.useState(null) + const [selectedWorkItemType, setSelectedWorkItemType] = React.useState('') + const workItemsPanelRef = React.useRef(null) + const specPanelRef = React.useRef(null) + const panelsRef = React.useRef(null) + const specContentPreRef = React.useRef(null) + const [contextMenu, setContextMenu] = React.useState<{ + x: number + y: number + items: { group: any; type: string; label: string; icon: React.ReactNode }[] + } | null>(null) + + React.useEffect(() => { + const panels = panelsRef.current + if (!panels) return + + const scrollParent = document.getElementById('primary-app-container') as HTMLElement + if (!scrollParent) return + + let maxScroll = 0 + + const computeMax = () => { + const panelsRect = panels.getBoundingClientRect() + const scrollParentRect = scrollParent.getBoundingClientRect() + maxScroll = panelsRect.top - scrollParentRect.top + scrollParent.scrollTop + } + + const rafId = requestAnimationFrame(() => { + computeMax() + }) + + const handleScroll = () => { + if (maxScroll <= 0) computeMax() + if (maxScroll > 0 && scrollParent.scrollTop > maxScroll) { + scrollParent.scrollTop = maxScroll + } + } + + const handleWheel = (e: WheelEvent) => { + if (maxScroll <= 0) computeMax() + if (maxScroll > 0 && e.deltaY > 0 && scrollParent.scrollTop >= maxScroll) { + e.preventDefault() + } + } + + scrollParent.addEventListener('scroll', handleScroll) + scrollParent.addEventListener('wheel', handleWheel, { passive: false }) + return () => { + cancelAnimationFrame(rafId) + scrollParent.removeEventListener('scroll', handleScroll) + scrollParent.removeEventListener('wheel', handleWheel) + } + }, [dynamicViewData]) + + React.useEffect(() => { + if (!contextMenu) return + const dismiss = () => setContextMenu(null) + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') dismiss() + } + document.addEventListener('click', dismiss) + document.addEventListener('keydown', handleKeyDown) + const specPanel = specPanelRef.current + const workPanel = workItemsPanelRef.current + if (specPanel) specPanel.addEventListener('scroll', dismiss) + if (workPanel) workPanel.addEventListener('scroll', dismiss) + return () => { + document.removeEventListener('click', dismiss) + document.removeEventListener('keydown', handleKeyDown) + if (specPanel) specPanel.removeEventListener('scroll', dismiss) + if (workPanel) workPanel.removeEventListener('scroll', dismiss) + } + }, [contextMenu]) + + if (!dynamicViewData) { + return null + } + + const specification = dynamicViewData['specification'] || '' + const srGroups = dynamicViewData[Constants._SRs_] || [] + const tsGroups = dynamicViewData[Constants._TSs_] || [] + const tcGroups = dynamicViewData[Constants._TCs_] || [] + const jGroups = dynamicViewData[Constants._Js] || [] + const docGroups = dynamicViewData[Constants._Ds] || [] + + const columnNames = { + specification: 'REFERENCE DOCUMENT', + work_items: 'WORK ITEMS' + } + + const getWorkItemIcon = (work_item_type, indirect) => { + const iconMap = { + [Constants._D]: , + [Constants._J]: , + [Constants._SR]: , + [Constants._TS]: , + [Constants._TC]: + } + const icon = iconMap[work_item_type] + if (!icon) return '' + if (indirect && (work_item_type === Constants._TS || work_item_type === Constants._TC)) { + return ( + + + + + {' '} +   + {icon} + + + ) + } + return ( + + + {icon} + + + ) + } + + const getStatusLabel = (status) => { + let label_color = 'orange' + const status_lc = status.toString().toLowerCase() + if (status_lc === 'new') label_color = 'grey' + else if (status_lc === 'approved') label_color = 'green' + else if (status_lc === 'rejected') label_color = 'red' + return ( + + ) + } + + const handleWorkItemClick = (_e: React.MouseEvent, workItemGroup, workItemType) => { + if (selectedWorkItem && selectedWorkItemType === workItemType && selectedWorkItem === workItemGroup) { + setSelectedWorkItem(null) + setSelectedWorkItemType('') + } else { + setSelectedWorkItem(workItemGroup) + setSelectedWorkItemType(workItemType) + requestAnimationFrame(() => { + if (workItemsPanelRef.current) { + workItemsPanelRef.current.scrollTo({ top: 0, behavior: 'smooth' }) + } + if (specPanelRef.current) { + specPanelRef.current.scrollTo({ top: 0, behavior: 'smooth' }) + } + const scrollParent = document.getElementById('primary-app-container') + if (scrollParent) { + scrollParent.scrollTo({ top: 0, behavior: 'smooth' }) + } + }) + } + } + + const clearSelection = () => { + setSelectedWorkItem(null) + setSelectedWorkItemType('') + } + + const getContextMenuWorkItemLabel = (group, type) => { + const keyMap = { + [Constants._SR]: Constants._SR_, + [Constants._TS]: Constants._TS_, + [Constants._TC]: Constants._TC_, + [Constants._J]: Constants._J, + [Constants._D]: Constants._D + } + const typeLabels = { + [Constants._SR]: 'SW Requirement', + [Constants._TS]: 'Test Specification', + [Constants._TC]: 'Test Case', + [Constants._J]: 'Justification', + [Constants._D]: 'Document' + } + const wi = group[keyMap[type]] + const typeLabel = typeLabels[type] || type + if (!wi) return typeLabel + const title = wi.title || wi.description || '' + const titleStr = title ? ` - ${Constants.getLimitedText(title, 40)}` : '' + return `${typeLabel} ${wi.id}${titleStr}` + } + + const getContextMenuWorkItemIcon = (work_item_type) => { + const iconMap = { + [Constants._D]: , + [Constants._J]: , + [Constants._SR]: , + [Constants._TS]: , + [Constants._TC]: + } + return iconMap[work_item_type] || null + } + + const handleSpecContextMenu = (e: React.MouseEvent) => { + const selection = window.getSelection() + if (!selection || selection.isCollapsed) return + + const preEl = specContentPreRef.current + if (!preEl) return + if (!preEl.contains(selection.anchorNode) || !preEl.contains(selection.focusNode)) return + + const selRange = selection.getRangeAt(0) + + const startRange = document.createRange() + startRange.selectNodeContents(preEl) + startRange.setEnd(selRange.startContainer, selRange.startOffset) + const selStartInPre = startRange.toString().length + + const endRange = document.createRange() + endRange.selectNodeContents(preEl) + endRange.setEnd(selRange.endContainer, selRange.endOffset) + const selEndInPre = endRange.toString().length + + const baseOffset = parseInt(preEl.getAttribute('data-spec-base-offset') || '0', 10) + const specSelStart = baseOffset + selStartInPre + const specSelEnd = baseOffset + selEndInPre + + if (specSelStart >= specSelEnd) return + + const allGroupsByType: { groups: any[]; type: string }[] = [ + { groups: srGroups, type: Constants._SR }, + { groups: tsGroups, type: Constants._TS }, + { groups: tcGroups, type: Constants._TC }, + { groups: jGroups, type: Constants._J }, + { groups: docGroups, type: Constants._D } + ] + + const matchingItems: { group: any; type: string; label: string; icon: React.ReactNode }[] = [] + const seen = new Set() + + for (const { groups, type } of allGroupsByType) { + for (const group of groups) { + if (!group.snippets) continue + for (const s of group.snippets) { + if (!s.match || s.offset == null || !s.section) continue + const snippetStart = s.offset + const snippetEnd = s.offset + s.section.length + if (snippetStart < specSelEnd && specSelStart < snippetEnd) { + const wiKey = { + [Constants._SR]: Constants._SR_, + [Constants._TS]: Constants._TS_, + [Constants._TC]: Constants._TC_, + [Constants._J]: Constants._J, + [Constants._D]: Constants._D + }[type] + const wi = wiKey ? group[wiKey] : null + const key = `${type}-${wi?.id}` + if (!seen.has(key)) { + seen.add(key) + matchingItems.push({ + group, + type, + label: getContextMenuWorkItemLabel(group, type), + icon: getContextMenuWorkItemIcon(type) + }) + } + break + } + } + } + } + + if (matchingItems.length === 0) return + + matchingItems.sort((a, b) => { + if (a.type !== b.type) return a.type.localeCompare(b.type) + return a.label.localeCompare(b.label) + }) + + e.preventDefault() + setContextMenu({ x: e.clientX, y: e.clientY, items: matchingItems }) + } + + const getAllMappedSnippets = () => { + const allGroups = [...srGroups, ...tsGroups, ...tcGroups, ...jGroups, ...docGroups] + const rawSnippets: { offset: number; end: number }[] = [] + for (const group of allGroups) { + if (!group.snippets) continue + for (const s of group.snippets) { + if (s.match && s.offset != null && s.section) { + rawSnippets.push({ offset: s.offset, end: s.offset + s.section.length }) + } + } + } + if (rawSnippets.length === 0) return [] + rawSnippets.sort((a, b) => a.offset - b.offset) + const merged: { offset: number; end: number }[] = [rawSnippets[0]] + for (let i = 1; i < rawSnippets.length; i++) { + const prev = merged[merged.length - 1] + if (rawSnippets[i].offset <= prev.end) { + prev.end = Math.max(prev.end, rawSnippets[i].end) + } else { + merged.push(rawSnippets[i]) + } + } + return merged + } + + const renderSpecificationContent = () => { + if (!selectedWorkItem || !selectedWorkItem.snippets) { + const mergedSnippets = getAllMappedSnippets() + if (mergedSnippets.length === 0) { + return ( +
+            {specification}
+          
+ ) + } + + const segments: { text: string; type: 'covered' | 'uncovered' }[] = [] + let cursor = 0 + for (const snip of mergedSnippets) { + if (snip.offset > cursor) { + segments.push({ text: specification.substring(cursor, snip.offset), type: 'uncovered' }) + } + segments.push({ text: specification.substring(snip.offset, snip.end), type: 'covered' }) + cursor = snip.end + } + if (cursor < specification.length) { + segments.push({ text: specification.substring(cursor), type: 'uncovered' }) + } + + return ( +
+          {segments.map((seg, idx) => (
+            
+              {seg.text}
+            
+          ))}
+        
+ ) + } + + const matchingSnippets = selectedWorkItem.snippets + .filter((s) => s.match && s.offset != null && s.section) + .sort((a, b) => a.offset - b.offset) + + const selectedRanges: { offset: number; end: number }[] = [] + for (const s of matchingSnippets) { + selectedRanges.push({ offset: s.offset, end: s.offset + s.section.length }) + } + if (selectedRanges.length > 1) { + selectedRanges.sort((a, b) => a.offset - b.offset) + const merged: { offset: number; end: number }[] = [selectedRanges[0]] + for (let i = 1; i < selectedRanges.length; i++) { + const prev = merged[merged.length - 1] + if (selectedRanges[i].offset <= prev.end) { + prev.end = Math.max(prev.end, selectedRanges[i].end) + } else { + merged.push(selectedRanges[i]) + } + } + selectedRanges.length = 0 + selectedRanges.push(...merged) + } + + if (selectedRanges.length === 0) { + return ( +
+          {specification}
+        
+ ) + } + + const firstStart = selectedRanges[0].offset + const lastEnd = selectedRanges[selectedRanges.length - 1].end + + const segments: { text: string; type: 'covered' | 'uncovered' }[] = [] + let cursor = firstStart + for (const range of selectedRanges) { + if (range.offset > cursor) { + segments.push({ text: specification.substring(cursor, range.offset), type: 'uncovered' }) + } + segments.push({ text: specification.substring(range.offset, range.end), type: 'covered' }) + cursor = range.end + } + + return ( + + + + + + +
+          {segments.map((seg, idx) => (
+            
+              {seg.text}
+            
+          ))}
+        
+
+ ) + } + + const buildFakeMappingListEntry = (workItemGroup, workItemType, snippet) => { + const entry: any = { + relation_id: snippet.relation_id, + section: snippet.section, + offset: snippet.offset, + coverage: snippet.coverage, + covered: snippet.covered || snippet.coverage, + gap: snippet.coverage - (snippet.covered || snippet.coverage), + created_by: workItemGroup.created_by || '', + version: workItemGroup.version || '1.0', + __tablename__: snippet.__tablename__, + direct: true + } + + const wiKey = workItemType.replace('-', '_') + entry[wiKey] = workItemGroup[wiKey] + + if (workItemType === Constants._SR) { + entry[Constants._SRs_] = snippet[Constants._SRs_] || [] + entry[Constants._TSs_] = snippet[Constants._TSs_] || [] + entry[Constants._TCs_] = snippet[Constants._TCs_] || [] + } + if (workItemType === Constants._D) { + entry[Constants._Ds] = snippet[Constants._Ds] || [] + } + + return entry + } + + const snippetBadgeStyle: React.CSSProperties = { + backgroundColor: 'var(--pf-v5-global--palette--purple-100, #e8daef)', + color: 'var(--pf-v5-global--palette--purple-700, #6c3483)' + } + + const getSnippetsBadge = (workItemGroup) => { + const count = workItemGroup.snippets ? workItemGroup.snippets.length : 0 + return ( + + + Snippets: {count} + + + ) + } + + const renderManageSnippetsButton = (workItemGroup, workItemType) => { + if (!auth.isLogged() || !Constants.hasWritePermission(api)) return null + return ( + + ) + } + + const indentStyle = { paddingLeft: '24px', borderLeft: '2px solid var(--pf-v5-global--palette--black-300, #d2d2d2)' } + + const renderNestedTestCases = (testCases, section, offset, parentType, parentRelatedToType, parentGroup, parentGroupType) => { + if (!showIndirectTestCases) return null + if (!testCases || testCases.length === 0) return null + return testCases.map((tc, idx) => { + const tcData = tc[Constants._TC_] + if (!tcData) return null + return ( + + handleWorkItemClick(e, parentGroup, parentGroupType)} style={{ cursor: 'pointer' }}> + +
+ + + {getWorkItemIcon(Constants._TC, true)} + + + Test Case {tcData.id} + + + + + + + + {tc.version && ( + + ver. {tc.version} + + )} + {getStatusLabel(tcData.status)} + + + + + + + + {Constants.getLimitedText(tcData.title, 0)} + + {Constants.getLimitedText(tcData.description, 0)} + + + + + +
+
+
+ +
+ ) + }) + } + + const renderNestedTestSpecifications = (testSpecs, section, offset, parentType, parentRelatedToType, parentGroup, parentGroupType) => { + if (!showIndirectTestSpecifications) return null + if (!testSpecs || testSpecs.length === 0) return null + return testSpecs.map((ts, idx) => { + const tsData = ts[Constants._TS_] + if (!tsData) return null + return ( + + handleWorkItemClick(e, parentGroup, parentGroupType)} style={{ cursor: 'pointer' }}> + +
+ + + {getWorkItemIcon(Constants._TS, true)} + + + Test Specification {tsData.id} + + + + + + + + {ts.version && ( + + ver. {ts.version} + + )} + {getStatusLabel(tsData.status)} + + + + + + + + {Constants.getLimitedText(tsData.title, 0)} + Preconditions: + + {Constants.getLimitedText(tsData.preconditions, 0)} + + Test Description: + + {Constants.getLimitedText(tsData.test_description, 0)} + + Expected Behavior: + + {Constants.getLimitedText(tsData.expected_behavior, 0)} + + + + + +
+
+ {tsData[Constants._TCs_] && tsData[Constants._TCs_].length > 0 && ( + +
+ {renderNestedTestCases( + tsData[Constants._TCs_], + section, + offset, + 'test-specification', + parentType, + parentGroup, + parentGroupType + )} +
+
+ )} +
+ +
+ ) + }) + } + + const renderNestedSwRequirements = (swReqs, section, offset, parentType, parentRelatedToType, parentGroup, parentGroupType) => { + if (!swReqs || swReqs.length === 0) return null + return swReqs.map((srItem, idx) => { + const srData = srItem[Constants._SR_] + if (!srData) return null + return ( + + handleWorkItemClick(e, parentGroup, parentGroupType)} style={{ cursor: 'pointer' }}> + +
+ + + {getWorkItemIcon(Constants._SR, false)} + + + Software Requirement {srData.id} + + + + + + + + {srItem.version && ( + + ver. {srItem.version} + + )} + {getStatusLabel(srData.status)} + + + + + + + + {Constants.getLimitedText(srData.title, 0)} + + {Constants.getLimitedText(srData.description, 0)} + + + + + +
+
+
+ +
+ ) + }) + } + + const renderNestedDocuments = (docs, section, offset, parentType, parentRelatedToType, parentGroup, parentGroupType) => { + if (!docs || docs.length === 0) return null + return docs.map((docItem, idx) => { + const docData = docItem[Constants._D] + if (!docData) return null + return ( + + handleWorkItemClick(e, parentGroup, parentGroupType)} style={{ cursor: 'pointer' }}> + +
+ + + {getWorkItemIcon(Constants._D, false)} + + + Document {docData.id} + + + + + + + + {docItem.version && ( + + ver. {docItem.version} + + )} + {getStatusLabel(docData.status)} + + + + + + + + {docData.title} + + {Constants.getLimitedText(docData.description, 0)} + + + + + +
+
+
+ +
+ ) + }) + } + + const renderSwRequirementCard = (srGroup, index) => { + const sr = srGroup[Constants._SR_] + if (!sr) return null + const isSelected = selectedWorkItem === srGroup && selectedWorkItemType === Constants._SR + const firstSnippet = srGroup.snippets && srGroup.snippets.length > 0 ? srGroup.snippets[0] : null + const fakeMappingList = firstSnippet ? [buildFakeMappingListEntry(srGroup, Constants._SR, firstSnippet)] : [] + + return ( + + handleWorkItemClick(e, srGroup, Constants._SR)} + style={{ cursor: 'pointer' }} + > + + + + {getWorkItemIcon(Constants._SR, false)} + + + Software Requirement {sr.id} + + + {firstSnippet && ( + + + + )} + + + {srGroup.version && ( + + ver. {srGroup.version} + + )} + {getStatusLabel(sr.status)} + {firstSnippet && ( + + + + )} + {getSnippetsBadge(srGroup)} + {auth.isLogged() && ( + + + + ]} + > + {messageValue && ( + + {messageValue} + + )} + + + Current Snippets ({snippets.length}) + + + {snippets.length === 0 && ( + + No snippets mapped to this work item. + + )} + + {snippets.map((snippet, idx) => ( + + + + + + + +   + +   + + + + + + {Constants.getLimitedText(snippet.section, 300)} + + + + + + + + + + + + ))} + + + + {!addingSnippet ? ( + + ) : ( + + + + Select a section from the reference document + + + + + {newSection || '(highlight text in the document below to select)'} + + + + + {newOffset} + + + + +
+ {api?.raw_specification || ''} +
+
+
+
+ + + + + + + + +
+
+ )} + + ) +} diff --git a/app/src/app/app.css b/app/src/app/app.css index fd8d6ce..1d0feef 100644 --- a/app/src/app/app.css +++ b/app/src/app/app.css @@ -15,7 +15,7 @@ body, } .code-block-bg-green { - background-color: var(--pf-v5-global--palette--green-50); + background-color: #dbeafe; } .code-block-bg-red { @@ -28,11 +28,11 @@ body, } .code-block-bg-gold { - background-color: var(--pf-v5-global--palette--gold-50); + background-color: #fef3c7; } .code-block-bg-gray { - background-color: var(--pf-v5-global--palette--black-200); + background-color: #e5e7eb; } .leaf-padding { diff --git a/docs/source/index.rst b/docs/source/index.rst index 8f25102..edefd6d 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -30,6 +30,7 @@ It comes also with a REST web api to simplify the integration in other toolchain user_management work_items work_items_import + mapping_views custom_actions diff --git a/docs/source/key_concepts.rst b/docs/source/key_concepts.rst index f887ed5..73552ee 100644 --- a/docs/source/key_concepts.rst +++ b/docs/source/key_concepts.rst @@ -65,7 +65,7 @@ Mapping Views ------------- BASIL provides different mapping views. A user can select the desired view from the Mapping page using the combo box in the top section of the page. -You will see a value for each work item types and an additional view named Raw Specification. +You will see a value for each work item types, a Dynamic View and an additional view named Raw Specification. The key concept is that selecting a particular view from that combo box you will focus on Work items with direct mapping to the Software Specification Document (or Source Code). A user can create a direct mapping of a Test Case against a section of the Specification and to be able to see it a user have to select the **Test Cases** view. A user can have the same Test Case in the Software Requirements view if the Test Case is nested under a Software Requirement. @@ -73,6 +73,8 @@ Why that is possible? Because not all the companies/users want to create all those work items. So if a user just want to create Test Cases and map them to sections of the specification document that will be possible. +See :doc:`mapping_views` for a detailed description of each view. + .. toctree:: diff --git a/docs/source/mapping_views.rst b/docs/source/mapping_views.rst new file mode 100644 index 0000000..26862b5 --- /dev/null +++ b/docs/source/mapping_views.rst @@ -0,0 +1,154 @@ +.. image:: ../../app/src/app/bgimages/basil_black.svg + +Mapping Views +================================= + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + +-------- +Overview +-------- + +When you open a Software Component, BASIL displays the Mapping page. +At the top of the page a **Mapping view** dropdown lets you choose how the reference document and its work items are presented. + +Each view is designed for a different purpose. +The type-specific views (Sw Requirements, Test Specifications, Test Cases, Justifications Only) let you focus on one work item type at a time. +The Dynamic View gives a cross-type overview of the whole specification coverage. +The Raw Specification view shows the reference document as plain text without any mapping information. + +Two switches are also available at the top of the page: + ++ **Indirect Test Specification**: when enabled, Test Specifications that are nested under a Software Requirement are shown in the view. ++ **Indirect Test Case**: when enabled, Test Cases that are nested under a Software Requirement or a Test Specification are shown in the view. + + +--------------------------------------------- +Type-Specific Views (Mapped / Unmapped Split) +--------------------------------------------- + +The views **Sw Requirements**, **Test Specifications**, **Test Cases**, and **Justifications Only** share the same layout. + +They split the reference document into two lists: + ++ **Mapped sections**: portions of the specification that have at least one work item of the selected type directly mapped to them. Each section shows the specification text and the associated work items. ++ **Unmapped sections**: portions of the specification that do not have any work item of the selected type directly mapped to them. These sections show only the specification text. + +The key concept is that selecting a particular view focuses on work items with a **direct** mapping to the reference document. +For example, a Test Case directly mapped to a section of the specification is only visible in the **Test Cases** view. +The same Test Case can also appear in the **Sw Requirements** view if it is nested under a Software Requirement, but in that case it is shown as an indirect child. + +This design allows different teams and workflows to use only the work item types they need. +If a user only wants to create Test Cases and map them to the specification, that is perfectly fine and the **Test Cases** view will show everything that matters. + +Sw Requirements +^^^^^^^^^^^^^^^ + +Shows Software Requirements directly mapped to the specification. +Each mapped section displays the related Software Requirement cards with their title, description, status, version, and coverage. +When indirect switches are enabled, nested Test Specifications and Test Cases appear indented below the parent Software Requirement. + +Test Specifications +^^^^^^^^^^^^^^^^^^^ + +Shows Test Specifications directly mapped to the specification. +Each mapped section displays the related Test Specification cards with their title, preconditions, test description, expected behavior, status, and version. +When the indirect Test Case switch is enabled, nested Test Cases appear indented below the parent Test Specification. + +Test Cases +^^^^^^^^^^ + +Shows Test Cases directly mapped to the specification. +Each mapped section displays the related Test Case cards with their title, description, status, version, and a link to the test implementation. + +Justifications Only +^^^^^^^^^^^^^^^^^^^ + +Shows Justifications directly mapped to the specification. +This view is useful to highlight which sections of the specification cannot or should not be related to other work item types and to document the reason why. + + +----------------- +Raw Specification +----------------- + +The **Raw Specification** view shows the full reference document as plain text. +No mapping information is displayed and no work items are shown. +This is useful to read the specification without any overlay or to select a section before creating a new work item. + + +------------ +Dynamic View +------------ + +The **Dynamic View** takes a different approach compared to the type-specific views. +Instead of splitting the specification by mapped and unmapped sections for a single work item type, it displays the full reference document alongside **all** directly mapped work items of every type in a unified two-column layout. + + +Why the Dynamic View? +^^^^^^^^^^^^^^^^^^^^^ + +In the type-specific views, BASIL focuses on one work item type at a time. +The user has to switch views to see how other work item types relate to the same specification. + +This works well when concentrating on a specific type, but sometimes a broader picture is needed: + ++ Which sections of the specification are covered by **any** work item, regardless of type? ++ Which sections are still completely uncovered? ++ How do all the different work item types relate to the same section of the document? + +The Dynamic View answers these questions in a single screen. + + +Layout +^^^^^^ + +The Dynamic View is a two-column table: + ++ **REFERENCE DOCUMENT** (left column): The full specification text. Sections covered by at least one work item are highlighted in green with a left border. Uncovered sections appear in a lighter color. ++ **WORK ITEMS** (right column): All directly mapped work items organized by type (Software Requirements, Test Specifications, Test Cases, Justifications, Documents). Each work item is shown as a card with its ID, version, status, coverage, and a summary of its content. + + +Selecting a work item +^^^^^^^^^^^^^^^^^^^^^ + +Clicking on any work item card in the right column selects it. +When selected: + ++ The card is visually highlighted. ++ The reference document column updates to show only the portions of the specification that are mapped to the selected work item. ++ The page scrolls to the top so that both the relevant specification text and the selected card are visible. + +To deselect, click the same card again or click the **Show full document** button that appears above the reference document. + + +Nested traceability +^^^^^^^^^^^^^^^^^^^ + +For Software Requirements that have children (nested Software Requirements, Test Specifications, or Test Cases) and for Documents that have nested Documents, the Dynamic View shows them inline under the parent work item card. +The **Indirect Test Specification** and **Indirect Test Case** switches at the top of the Mapping page also apply to the Dynamic View. +When enabled, indirect children appear indented below the parent work item card with a migration icon to distinguish them from direct mappings. + + +Snippet management +^^^^^^^^^^^^^^^^^^ + +Each work item card in the Dynamic View shows a badge indicating the number of specification snippets mapped to it. +A single work item can be mapped to multiple sections of the specification through different snippets. +Logged-in users with write permissions can click the **Manage Snippets** button on each card to open the snippets modal where they can: + ++ View all snippet mappings for that work item. ++ Add new snippet mappings to relate the work item to additional sections of the specification. ++ Edit existing snippet sections, offsets, and coverage values. ++ Remove snippet mappings that are no longer needed. + + +Context menu +^^^^^^^^^^^^ + +Each work item card in the Dynamic View includes a kebab menu (three dots icon) that provides access to the same actions available in the type-specific views: editing the work item, viewing details, checking history, usage analysis, forking, deleting, and adding comments. +For Software Requirements, the menu also allows creating nested Test Specifications and Test Cases. +For Test Specifications, the menu allows creating nested Test Cases.