This is an automated email from the ASF dual-hosted git repository. pinal pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/atlas.git
The following commit(s) were added to refs/heads/master by this push: new 9999bed01 ATLAS-4894: Atlas-UI: [UI] 'Exclude SubTypes' and 'Exclude Sub-classifications' filter should be removed from referred attribute tab of entity details page 9999bed01 is described below commit 9999bed01002007118ef1bbd7306d16fbe615856 Author: Brijesh Bhalala <brijeshbhalala2...@gmail.com> AuthorDate: Wed Sep 18 16:40:38 2024 +0530 ATLAS-4894: Atlas-UI: [UI] 'Exclude SubTypes' and 'Exclude Sub-classifications' filter should be removed from referred attribute tab of entity details page Signed-off-by: Pinal Shah <pinal.s...@freestoneinfotech.com> --- .../search/SearchResultLayoutView_tmpl.html | 2 + .../js/views/detail_page/DetailPageLayoutView.js | 2 +- .../js/views/glossary/TermPropertiestLayoutView.js | 4 +- .../views/search/RelationSearchResultLayoutView.js | 12 +-- .../js/views/search/SearchResultLayoutView.js | 3 +- .../search/SearchResultLayoutView_tmpl.html | 2 + .../js/views/detail_page/DetailPageLayoutView.js | 2 +- .../js/views/glossary/TermPropertiestLayoutView.js | 4 +- .../views/search/RelationSearchResultLayoutView.js | 12 +-- .../js/views/search/SearchResultLayoutView.js | 3 +- .../main/java/org/apache/atlas/AtlasErrorCode.java | 2 +- .../org/apache/atlas/glossary/GlossaryService.java | 2 +- .../apache/atlas/glossary/GlossaryServiceTest.java | 93 ++++++++++++++++++++-- 13 files changed, 114 insertions(+), 29 deletions(-) diff --git a/dashboardv2/public/js/templates/search/SearchResultLayoutView_tmpl.html b/dashboardv2/public/js/templates/search/SearchResultLayoutView_tmpl.html index f8cfad616..764516272 100644 --- a/dashboardv2/public/js/templates/search/SearchResultLayoutView_tmpl.html +++ b/dashboardv2/public/js/templates/search/SearchResultLayoutView_tmpl.html @@ -60,6 +60,7 @@ <input type="checkbox" data-id="checkDeletedEntity" data-value="includeDE" id="historicalentities" /> <b>Show historical entities</b></label> </div> + {{#unless isProfileDBView}} <div class="inline exclude-subclassifications" data-id="containerCheckBox" style="display: none;"> <label class="checkbox-inline btn" for="subclassifications"> <input type="checkbox" data-id="checkSubClassification" data-value="excludeSC" id="subclassifications" /> @@ -70,6 +71,7 @@ <input type="checkbox" data-id="checkSubType" data-value="excludeST" id="subtypes" /> <b>Exclude sub-types</b></label> </div> + {{/unless}} </div> </div> <div id="r_searchResultTableLayoutView"> diff --git a/dashboardv2/public/js/views/detail_page/DetailPageLayoutView.js b/dashboardv2/public/js/views/detail_page/DetailPageLayoutView.js index 3f62d970f..9c2ac29c6 100644 --- a/dashboardv2/public/js/views/detail_page/DetailPageLayoutView.js +++ b/dashboardv2/public/js/views/detail_page/DetailPageLayoutView.js @@ -532,7 +532,7 @@ define(['require', termData = ""; _.each(data, function(val) { var glossaryName = val.qualifiedName ? val.qualifiedName : val.displayText; - termData += '<span class="btn btn-action btn-sm btn-icon btn-blue" data-id="termClick" title= "' + glossaryName + '"><span>' + _.escape(glossaryName) + '</span><i class="' + (val.relationshipStatus == "ACTIVE" ? 'fa fa-close' : "") + '" data-id="deleteTerm" data-guid="' + val.guid + '" data-type="term" title="Remove Term"></i></span>'; + termData += '<span class="btn btn-action btn-sm btn-icon btn-blue" data-id="termClick" title= "' + _.escape(glossaryName) + '"><span>' + _.escape(glossaryName) + '</span><i class="' + (val.relationshipStatus == "ACTIVE" ? 'fa fa-close' : "") + '" data-id="deleteTerm" data-guid="' + val.guid + '" data-type="term" title="Remove Term"></i></span>'; }); this.ui.termList.find("span.btn").remove(); this.ui.termList.prepend(termData); diff --git a/dashboardv2/public/js/views/glossary/TermPropertiestLayoutView.js b/dashboardv2/public/js/views/glossary/TermPropertiestLayoutView.js index e2bb72d20..f816ddfc0 100644 --- a/dashboardv2/public/js/views/glossary/TermPropertiestLayoutView.js +++ b/dashboardv2/public/js/views/glossary/TermPropertiestLayoutView.js @@ -92,10 +92,10 @@ define(['require', var tableBody = '', enums = obj.enums; _.each(obj.data, function(value, key, list) { - tableBody += '<tr><td>' + key + '</td><td class="">' + that.getValue({ + tableBody += '<tr><td>' + _.escape(key) + '</td><td class="">' + _.escape(that.getValue({ "value": value, "type": enums[key] - }) + '</td></tr>'; + })) + '</td></tr>'; }); return tableBody; }; diff --git a/dashboardv2/public/js/views/search/RelationSearchResultLayoutView.js b/dashboardv2/public/js/views/search/RelationSearchResultLayoutView.js index 61176b4f6..92e270730 100644 --- a/dashboardv2/public/js/views/search/RelationSearchResultLayoutView.js +++ b/dashboardv2/public/js/views/search/RelationSearchResultLayoutView.js @@ -569,7 +569,7 @@ define(['require', uniqueAttributesValue = obj.end1.uniqueAttributes[key]; } uniqueAttributesValue = uniqueAttributesValue ? uniqueAttributesValue : obj.end1.guid; - return '<a title="' + uniqueAttributesValue + '" href="#!/detailPage/' + obj.end1.guid + '?from=relationshipSearch">' + uniqueAttributesValue + '</a>'; + return '<a title="' + _.escape(uniqueAttributesValue) + '" href="#!/detailPage/' + obj.end1.guid + '?from=relationshipSearch">' + _.escape(uniqueAttributesValue) + '</a>'; } } }) @@ -591,7 +591,7 @@ define(['require', uniqueAttributesValue = obj.end2.uniqueAttributes[key]; } uniqueAttributesValue = uniqueAttributesValue ? uniqueAttributesValue : obj.end2.guid; - return '<a title="' + uniqueAttributesValue + '" href="#!/detailPage/' + obj.end2.guid + '?from=relationshipSearch">' + uniqueAttributesValue + '</a>'; + return '<a title="' + _.escape(uniqueAttributesValue) + '" href="#!/detailPage/' + obj.end2.guid + '?from=relationshipSearch">' + _.escape(uniqueAttributesValue) + '</a>'; } } }) @@ -618,14 +618,14 @@ define(['require', var def = this.relationshipDefCollection.fullCollection.find({ name: this.value.relationshipName }); if (def) { var attrObj = def ? Utils.getNestedSuperTypeObj({ data: def.toJSON(), collection: this.relationshipDefCollection, attrMerge: true }) : []; - _.each(attrObj, function(obj, key) { - var key = obj.name, + _.each(attrObj, function(obj) { + var key = _.escape(obj.name), isRenderable = _.contains(columnToShow, key), // isSortable = obj.typeName.search(/(array|map)/i) == -1; isSortable = obj.typeName.search(/(string|date|boolean|int|number|byte|float|long|double|short)/i) == 0; // commented : as sorting is required for all the columns except non-primitive types - col[obj.name] = { - label: obj.name.capitalize(), + col[key] = { + label: key.capitalize(), cell: "Html", // headerCell: Backgrid.HeaderHTMLDecodeCell, editable: false, diff --git a/dashboardv2/public/js/views/search/SearchResultLayoutView.js b/dashboardv2/public/js/views/search/SearchResultLayoutView.js index c9e57ad73..6f990f2f7 100644 --- a/dashboardv2/public/js/views/search/SearchResultLayoutView.js +++ b/dashboardv2/public/js/views/search/SearchResultLayoutView.js @@ -82,7 +82,8 @@ define(['require', searchType: this.searchType, fromView: this.fromView, isGlossaryView: this.fromView == "glossary", - isSearchTab: Utils.getUrlState.isSearchTab() + isSearchTab: Utils.getUrlState.isSearchTab(), + isProfileDBView: this.options.profileDBView }; }, /** ui events hash */ diff --git a/dashboardv3/public/js/templates/search/SearchResultLayoutView_tmpl.html b/dashboardv3/public/js/templates/search/SearchResultLayoutView_tmpl.html index 6aa3fe185..185b1c705 100644 --- a/dashboardv3/public/js/templates/search/SearchResultLayoutView_tmpl.html +++ b/dashboardv3/public/js/templates/search/SearchResultLayoutView_tmpl.html @@ -66,6 +66,7 @@ <input type="checkbox" data-id="checkDeletedEntity" data-value="includeDE" id="historicalentities" /> <b>Show historical entities</b></label> </div> + {{#unless isProfileDBView}} <div class="inline exclude-subclassifications" data-id="containerCheckBox" style="display: none;"> <label class="checkbox-inline btn" for="subclassifications"> <input type="checkbox" data-id="checkSubClassification" data-value="excludeSC" id="subclassifications" /> @@ -76,6 +77,7 @@ <input type="checkbox" data-id="checkSubType" data-value="excludeST" id="subtypes" /> <b>Exclude sub-types</b></label> </div> + {{/unless}} </div> </div> </div> diff --git a/dashboardv3/public/js/views/detail_page/DetailPageLayoutView.js b/dashboardv3/public/js/views/detail_page/DetailPageLayoutView.js index 854908da3..b198767aa 100644 --- a/dashboardv3/public/js/views/detail_page/DetailPageLayoutView.js +++ b/dashboardv3/public/js/views/detail_page/DetailPageLayoutView.js @@ -537,7 +537,7 @@ define(['require', termData = ""; _.each(data, function(val) { var glossaryName = val.qualifiedName ? val.qualifiedName : val.displayText; - termData += '<span class="btn btn-action btn-sm btn-icon btn-blue" data-id="termClick" title= "' + glossaryName + '"><span>' + _.escape(glossaryName) + '</span><i class="' + (val.relationshipStatus == "ACTIVE" ? 'fa fa-close' : "") + '" data-id="deleteTerm" data-guid="' + val.guid + '" data-type="term" title="Remove Term"></i></span>'; + termData += '<span class="btn btn-action btn-sm btn-icon btn-blue" data-id="termClick" title= "' + _.escape(glossaryName) + '"><span>' + _.escape(glossaryName) + '</span><i class="' + (val.relationshipStatus == "ACTIVE" ? 'fa fa-close' : "") + '" data-id="deleteTerm" data-guid="' + val.guid + '" data-type="term" title="Remove Term"></i></span>'; }); this.ui.termList.find("span.btn").remove(); this.ui.termList.prepend(termData); diff --git a/dashboardv3/public/js/views/glossary/TermPropertiestLayoutView.js b/dashboardv3/public/js/views/glossary/TermPropertiestLayoutView.js index e2bb72d20..f816ddfc0 100644 --- a/dashboardv3/public/js/views/glossary/TermPropertiestLayoutView.js +++ b/dashboardv3/public/js/views/glossary/TermPropertiestLayoutView.js @@ -92,10 +92,10 @@ define(['require', var tableBody = '', enums = obj.enums; _.each(obj.data, function(value, key, list) { - tableBody += '<tr><td>' + key + '</td><td class="">' + that.getValue({ + tableBody += '<tr><td>' + _.escape(key) + '</td><td class="">' + _.escape(that.getValue({ "value": value, "type": enums[key] - }) + '</td></tr>'; + })) + '</td></tr>'; }); return tableBody; }; diff --git a/dashboardv3/public/js/views/search/RelationSearchResultLayoutView.js b/dashboardv3/public/js/views/search/RelationSearchResultLayoutView.js index f277c4e5f..465d8712c 100644 --- a/dashboardv3/public/js/views/search/RelationSearchResultLayoutView.js +++ b/dashboardv3/public/js/views/search/RelationSearchResultLayoutView.js @@ -582,7 +582,7 @@ define(['require', uniqueAttributesValue = obj.end1.uniqueAttributes[key]; } uniqueAttributesValue = uniqueAttributesValue ? uniqueAttributesValue : obj.end1.guid; - return '<a title="' + uniqueAttributesValue + '" href="#!/detailPage/' + obj.end1.guid + '?from=relationshipSearch">' + uniqueAttributesValue + '</a>'; + return '<a title="' + _.escape(uniqueAttributesValue) + '" href="#!/detailPage/' + obj.end1.guid + '?from=relationshipSearch">' + _.escape(uniqueAttributesValue) + '</a>'; } } }) @@ -604,7 +604,7 @@ define(['require', uniqueAttributesValue = obj.end2.uniqueAttributes[key]; } uniqueAttributesValue = uniqueAttributesValue ? uniqueAttributesValue : obj.end2.guid; - return '<a title="' + uniqueAttributesValue + '" href="#!/detailPage/' + obj.end2.guid + '?from=relationshipSearch">' + uniqueAttributesValue + '</a>'; + return '<a title="' + _.escape(uniqueAttributesValue) + '" href="#!/detailPage/' + obj.end2.guid + '?from=relationshipSearch">' + _.escape(uniqueAttributesValue) + '</a>'; } } }) @@ -631,14 +631,14 @@ define(['require', var def = this.relationshipDefCollection.fullCollection.find({ name: this.value.relationshipName }); if (def) { var attrObj = def ? Utils.getNestedSuperTypeObj({ data: def.toJSON(), collection: this.relationshipDefCollection, attrMerge: true }) : []; - _.each(attrObj, function(obj, key) { - var key = obj.name, + _.each(attrObj, function(obj) { + var key = _.escape(obj.name), isRenderable = _.contains(columnToShow, key), // isSortable = obj.typeName.search(/(array|map)/i) == -1; isSortable = obj.typeName.search(/(string|date|boolean|int|number|byte|float|long|double|short)/i) == 0; // commented : as sorting is required for all the columns except non-primitive types - col[obj.name] = { - label: obj.name.capitalize(), + col[key] = { + label: key.capitalize(), cell: "Html", // headerCell: Backgrid.HeaderHTMLDecodeCell, editable: false, diff --git a/dashboardv3/public/js/views/search/SearchResultLayoutView.js b/dashboardv3/public/js/views/search/SearchResultLayoutView.js index 16db56623..7abfd56a3 100644 --- a/dashboardv3/public/js/views/search/SearchResultLayoutView.js +++ b/dashboardv3/public/js/views/search/SearchResultLayoutView.js @@ -83,7 +83,8 @@ define(['require', searchType: this.searchType, fromView: this.fromView, isGlossaryView: this.fromView == "glossary", - isSearchTab: Utils.getUrlState.isSearchTab() + isSearchTab: Utils.getUrlState.isSearchTab(), + isProfileDBView: this.options.profileDBView }; }, /** ui events hash */ diff --git a/intg/src/main/java/org/apache/atlas/AtlasErrorCode.java b/intg/src/main/java/org/apache/atlas/AtlasErrorCode.java index 77a6fd8c3..61b8155b9 100644 --- a/intg/src/main/java/org/apache/atlas/AtlasErrorCode.java +++ b/intg/src/main/java/org/apache/atlas/AtlasErrorCode.java @@ -146,7 +146,7 @@ public enum AtlasErrorCode { INVALID_TERM_DISSOCIATION(400, "ATLAS-400-00-080", "Given relationshipGuid({0}) is invalid for term (guid={1}) and entity(guid={2})"), ATTRIBUTE_TYPE_INVALID(400, "ATLAS-400-00-081", "{0}.{1}: invalid attribute type. Attribute cannot be of type classification"), MISSING_CATEGORY_DISPLAY_NAME(400, "ATLAS-400-00-082", "Category name is empty/null"), - INVALID_DISPLAY_NAME(400, "ATLAS-400-00-083", "name cannot contain following special chars ('@', '.')"), + INVALID_DISPLAY_NAME(400, "ATLAS-400-00-083", "name cannot contain following special chars ('@', '.', '<', '>')"), TERM_HAS_ENTITY_ASSOCIATION(400, "ATLAS-400-00-084", "Term (guid={0}) cannot be deleted as it has been assigned to {1} entities."), INVALID_TIMEBOUNDRY_TIMEZONE(400, "ATLAS-400-00-085", "Invalid timezone {0}"), INVALID_TIMEBOUNDRY_START_TIME(400, "ATLAS-400-00-086", "Invalid startTime {0}"), diff --git a/repository/src/main/java/org/apache/atlas/glossary/GlossaryService.java b/repository/src/main/java/org/apache/atlas/glossary/GlossaryService.java index 87fc2cd49..298fda359 100644 --- a/repository/src/main/java/org/apache/atlas/glossary/GlossaryService.java +++ b/repository/src/main/java/org/apache/atlas/glossary/GlossaryService.java @@ -88,7 +88,7 @@ public class GlossaryService { private final AtlasEntityChangeNotifier entityChangeNotifier; private final AtlasGlossaryDTO glossaryDTO; - private static final char[] invalidNameChars = { '@', '.' }; + private static final char[] invalidNameChars = { '@', '.', '<', '>'}; private static final Map<String, String> glossaryGuidQualifiedNameCache = new HashMap<>(); private static final Map<String, String> categoryGuidNameCache = new HashMap<>(); diff --git a/repository/src/test/java/org/apache/atlas/glossary/GlossaryServiceTest.java b/repository/src/test/java/org/apache/atlas/glossary/GlossaryServiceTest.java index 007b2f24e..853293ec5 100644 --- a/repository/src/test/java/org/apache/atlas/glossary/GlossaryServiceTest.java +++ b/repository/src/test/java/org/apache/atlas/glossary/GlossaryServiceTest.java @@ -89,9 +89,9 @@ public class GlossaryServiceTest { @Inject private AtlasEntityStore entityStore; - private AtlasGlossary bankGlossary, creditUnionGlossary; - private AtlasGlossaryTerm checkingAccount, savingsAccount, fixedRateMortgage, adjustableRateMortgage; - private AtlasGlossaryCategory customerCategory, accountCategory, mortgageCategory; + private AtlasGlossary bankGlossary, creditUnionGlossary, debitUnionGlossary; + private AtlasGlossaryTerm checkingAccount, savingsAccount, fixedRateMortgage, adjustableRateMortgage, currentAccount; + private AtlasGlossaryCategory customerCategory, accountCategory, mortgageCategory, loanCategory; private AtlasRelatedObjectId relatedObjectId; @Inject @@ -104,9 +104,9 @@ public class GlossaryServiceTest { public static Object[][] getGlossaryTermsProvider() { return new Object[][]{ // offset, limit, expected - {0, -1, 7}, + {0, -1, 8}, {0, 2, 2}, - {2, 6, 5}, + {2, 6, 6}, }; } @@ -144,6 +144,13 @@ public class GlossaryServiceTest { creditUnionGlossary.setUsage("N/A"); creditUnionGlossary.setLanguage("en-US"); + debitUnionGlossary = new AtlasGlossary(); + debitUnionGlossary.setName("<Debit union glossary"); + debitUnionGlossary.setShortDescription("Short description"); + debitUnionGlossary.setLongDescription("Long description"); + debitUnionGlossary.setUsage("N/A"); + debitUnionGlossary.setLanguage("en-US"); + // Category accountCategory = new AtlasGlossaryCategory(); accountCategory.setName("Account categorization"); @@ -161,6 +168,11 @@ public class GlossaryServiceTest { mortgageCategory.setShortDescription("Short description"); mortgageCategory.setLongDescription("Long description"); + loanCategory = new AtlasGlossaryCategory(); + loanCategory.setName("Loan categorization>"); + loanCategory.setShortDescription("Short description"); + loanCategory.setLongDescription("Long description"); + // Terms checkingAccount = new AtlasGlossaryTerm(); checkingAccount.setName("A checking account"); @@ -196,6 +208,13 @@ public class GlossaryServiceTest { adjustableRateMortgage.setExamples(Arrays.asList("5/1", "7/1", "10/1")); adjustableRateMortgage.setUsage("N/A"); + currentAccount = new AtlasGlossaryTerm(); + currentAccount.setName("current@account"); + currentAccount.setShortDescription("Short description"); + currentAccount.setLongDescription("Long description"); + currentAccount.setAbbreviation("CURR"); + currentAccount.setExamples(Arrays.asList("Personal", "Joint")); + currentAccount.setUsage("N/A"); } @@ -224,6 +243,14 @@ public class GlossaryServiceTest { assertEquals(e.getAtlasErrorCode(), AtlasErrorCode.GLOSSARY_ALREADY_EXISTS); } + //Validate glossary creation + try { + glossaryService.createGlossary(debitUnionGlossary); + fail("Invalid glossary creation should've failed"); + } catch (AtlasBaseException e) { + assertEquals(e.getAtlasErrorCode(), AtlasErrorCode.INVALID_DISPLAY_NAME); + } + // Retrieve the glossary and see ensure no terms or categories are linked try { @@ -260,11 +287,13 @@ public class GlossaryServiceTest { fixedRateMortgage.setAnchor(glossaryId); adjustableRateMortgage.setAnchor(glossaryId); + currentAccount.setAnchor(glossaryId); // Create glossary categories accountCategory.setAnchor(glossaryId); customerCategory.setAnchor(glossaryId); mortgageCategory.setAnchor(glossaryId); + loanCategory.setAnchor(glossaryId); } @Test(groups = "Glossary.CREATE" , dependsOnMethods = "testCategoryCreation") @@ -276,6 +305,14 @@ public class GlossaryServiceTest { } catch (AtlasBaseException e) { fail("Term creation should've succeeded", e); } + + //validating term creation + try { + glossaryService.createTerm(currentAccount); + fail("Invalid term creation should've failed"); + } catch (AtlasBaseException e) { + assertEquals(e.getAtlasErrorCode(), AtlasErrorCode.INVALID_DISPLAY_NAME); + } } @Test(groups = "Glossary.CREATE" , dependsOnMethods = "testTermCreationWithoutAnyRelations") @@ -375,6 +412,15 @@ public class GlossaryServiceTest { } catch (AtlasBaseException e) { fail("Category creation should've succeeded", e); } + + // Validate category creation + try { + glossaryService.createCategory(loanCategory); + fail("Invalid category creation should've failed"); + } catch (AtlasBaseException e) { + assertEquals(e.getAtlasErrorCode(), AtlasErrorCode.INVALID_DISPLAY_NAME); + } + } @@ -425,6 +471,18 @@ public class GlossaryServiceTest { } catch (AtlasBaseException e) { fail("Glossary fetch/update should've succeeded", e); } + + //Validate glossary update + try { + debitUnionGlossary.setName("Test Glossary Update"); + debitUnionGlossary = glossaryService.createGlossary(debitUnionGlossary); + assertNotNull(debitUnionGlossary); + debitUnionGlossary.setName("<test glossary create>"); + glossaryService.updateGlossary(debitUnionGlossary); + fail("Invalid glossary update should've failed"); + } catch (AtlasBaseException e) { + assertEquals(e.getAtlasErrorCode(), AtlasErrorCode.INVALID_DISPLAY_NAME); + } } @Test(dependsOnGroups = {"Glossary.MIGRATE"}) @@ -529,6 +587,17 @@ public class GlossaryServiceTest { fail("Glossary term fetch/update should've succeeded", e); } } + //Validate term update + try { + currentAccount.setName("Test term update"); + currentAccount = glossaryService.createTerm(currentAccount); + currentAccount.setName("<Test term update>"); + glossaryService.updateTerm(currentAccount); + fail("Invalid term update should've failed"); + } catch (AtlasBaseException e) { + assertEquals(e.getAtlasErrorCode(), AtlasErrorCode.INVALID_DISPLAY_NAME); + } + } @Test(groups = "Glossary.UPDATE", dependsOnGroups = "Glossary.CREATE") @@ -576,6 +645,16 @@ public class GlossaryServiceTest { } catch (AtlasBaseException e) { fail("Customer category fetch should've succeeded"); } + //Validate category update + try { + loanCategory.setName("Test category update"); + loanCategory = glossaryService.createCategory(loanCategory); + loanCategory.setName("<Test category update>"); + glossaryService.updateCategory(loanCategory); + fail("Invalid category update should've failed"); + } catch (AtlasBaseException e) { + assertEquals(e.getAtlasErrorCode(), AtlasErrorCode.INVALID_DISPLAY_NAME); + } } @Test(groups = "Glossary.MIGRATE", dependsOnGroups = "Glossary.GET.postUpdate") @@ -921,9 +1000,9 @@ public class GlossaryServiceTest { public Object[][] getGlossaryCategoriesProvider() { return new Object[][]{ // offset, limit, expected - {0, -1, 3}, + {0, -1, 4}, {0, 2, 2}, - {2, 5, 1}, + {2, 5, 2}, }; }