diff --git a/.github/workflows/pull_request.add_in.yml b/.github/workflows/pull_request.add_in.yml index 3f31b34..c42f86f 100644 --- a/.github/workflows/pull_request.add_in.yml +++ b/.github/workflows/pull_request.add_in.yml @@ -29,4 +29,23 @@ jobs: - name: ๐Ÿ”ช Check CSharpier formatting working-directory: add-in/UGRC.UtransTools - run: dotnet csharpier check . + run: dotnet csharpier check .. + + test-review-core: + name: Test review core + runs-on: ubuntu-latest + + steps: + - name: โฌ‡๏ธ Set up code + uses: actions/checkout@v7 + + - name: ๐Ÿฅ… Setup .NET SDK + uses: actions/setup-dotnet@v6 + with: + dotnet-version: "8.0" + + - name: ๐Ÿงช Test review core with coverage + run: > + dotnet test + add-in/UGRC.UtransTools.Core.Tests/UGRC.UtransTools.Core.Tests.csproj + --collect:"XPlat Code Coverage" diff --git a/.vscode/settings.json b/.vscode/settings.json index d5124d3..b16df8c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -34,8 +34,8 @@ "recents", "ROADALIASNAME", "saltlake", - "SGID", "Sevier", + "SGID", "SRFTYP", "STREETNAME", "SUFDIR", @@ -43,6 +43,7 @@ "testpaths", "TOADDR", "Tooele", + "TRANSADMIN", "UGRC", "Uintah", "utrans", @@ -65,7 +66,27 @@ "approve": true, "matchCommandLine": true }, - "/^conda activate utrans-tools; ruff/": { + "/^conda activate utrans-tools; ruff[^;]*$/": { + "approve": true, + "matchCommandLine": true + }, + "/^dotnet build \\.\\\\add-in\\\\UGRC\\.UtransTools\\\\UGRC\\.UtransTools\\.csproj --configuration Debug$/": { + "approve": true, + "matchCommandLine": true + }, + "/^Set-Location 'C:\\\\projects\\\\utrans-tools'; dotnet test 'add-in\\\\UGRC\\.UtransTools\\.Core\\.Tests\\\\UGRC\\.UtransTools\\.Core\\.Tests\\.csproj'$/": { + "approve": true, + "matchCommandLine": true + }, + "/^dotnet test add-in\\\\UGRC\\.UtransTools\\.Core\\.Tests\\\\UGRC\\.UtransTools\\.Core\\.Tests\\.csproj$/": { + "approve": true, + "matchCommandLine": true + }, + "/^dotnet build \"add-in\\\\UGRC\\.UtransTools\\\\UGRC\\.UtransTools\\.csproj\" --configuration Debug --no-restore$/": { + "approve": true, + "matchCommandLine": true + }, + "/^dotnet build add-in\\\\UGRC\\.UtransTools\\\\UGRC\\.UtransTools\\.csproj --configuration Debug --no-restore --verbosity minimal$/": { "approve": true, "matchCommandLine": true } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..24afb8c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +## Agent skills + +### Issue tracker + +Issues and specs are tracked in this repository's GitHub Issues. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Uses the default canonical triage labels. See `docs/agents/triage-labels.md`. + +### Domain docs + +Uses a single-context layout rooted at `CONTEXT.md`. See `docs/agents/domain.md`. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..1287732 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,21 @@ +# UTRANS Change Review + +This context covers the review of changes detected between county road data and UTRANS roads. It distinguishes the detected change from the optional UTRANS road it may reference. + +## Language + +**DFC result**: +A detected feature-change record that a reviewer assesses against county road data and, when present, a linked UTRANS road. +_Avoid_: DFC record, detected change + +**Change status**: +The review outcome stored on a DFC result: `COMPLETED`, `IGNORE`, or `REVISIT`. +_Avoid_: DFC disposition + +**Linked UTRANS road**: +The UTRANS road referenced by a DFC result as its target road. A DFC result may have no linked UTRANS road. +_Avoid_: target road segment + +**Unlinked new record**: +A DFC result with change type `N` and no target UTRANS road. It can be added as a new UTRANS road or assigned a non-completed change status. +_Avoid_: driveway candidate diff --git a/UGRC.UtransTools.sln b/UGRC.UtransTools.sln index d5b79bf..573291e 100644 --- a/UGRC.UtransTools.sln +++ b/UGRC.UtransTools.sln @@ -5,16 +5,57 @@ VisualStudioVersion = 17.14.37411.7 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UGRC.UtransTools", "add-in\UGRC.UtransTools\UGRC.UtransTools.csproj", "{9112D502-5FDD-4284-BDF7-B0DA219C99EF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UGRC.UtransTools.Core", "add-in\UGRC.UtransTools.Core\UGRC.UtransTools.Core.csproj", "{8EADADCE-DF0D-46F4-A64C-24BAB8438F5F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UGRC.UtransTools.Core.Tests", "add-in\UGRC.UtransTools.Core.Tests\UGRC.UtransTools.Core.Tests.csproj", "{DA2F0C58-0D25-419B-A5CC-8498A4B47B2B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "add-in", "add-in", "{89F9893B-E750-84D5-8801-D0B0E8294416}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {9112D502-5FDD-4284-BDF7-B0DA219C99EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9112D502-5FDD-4284-BDF7-B0DA219C99EF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9112D502-5FDD-4284-BDF7-B0DA219C99EF}.Debug|x64.ActiveCfg = Debug|Any CPU + {9112D502-5FDD-4284-BDF7-B0DA219C99EF}.Debug|x64.Build.0 = Debug|Any CPU + {9112D502-5FDD-4284-BDF7-B0DA219C99EF}.Debug|x86.ActiveCfg = Debug|Any CPU + {9112D502-5FDD-4284-BDF7-B0DA219C99EF}.Debug|x86.Build.0 = Debug|Any CPU {9112D502-5FDD-4284-BDF7-B0DA219C99EF}.Release|Any CPU.ActiveCfg = Release|Any CPU {9112D502-5FDD-4284-BDF7-B0DA219C99EF}.Release|Any CPU.Build.0 = Release|Any CPU + {9112D502-5FDD-4284-BDF7-B0DA219C99EF}.Release|x64.ActiveCfg = Release|Any CPU + {9112D502-5FDD-4284-BDF7-B0DA219C99EF}.Release|x64.Build.0 = Release|Any CPU + {9112D502-5FDD-4284-BDF7-B0DA219C99EF}.Release|x86.ActiveCfg = Release|Any CPU + {9112D502-5FDD-4284-BDF7-B0DA219C99EF}.Release|x86.Build.0 = Release|Any CPU + {8EADADCE-DF0D-46F4-A64C-24BAB8438F5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8EADADCE-DF0D-46F4-A64C-24BAB8438F5F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8EADADCE-DF0D-46F4-A64C-24BAB8438F5F}.Debug|x64.ActiveCfg = Debug|Any CPU + {8EADADCE-DF0D-46F4-A64C-24BAB8438F5F}.Debug|x64.Build.0 = Debug|Any CPU + {8EADADCE-DF0D-46F4-A64C-24BAB8438F5F}.Debug|x86.ActiveCfg = Debug|Any CPU + {8EADADCE-DF0D-46F4-A64C-24BAB8438F5F}.Debug|x86.Build.0 = Debug|Any CPU + {8EADADCE-DF0D-46F4-A64C-24BAB8438F5F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8EADADCE-DF0D-46F4-A64C-24BAB8438F5F}.Release|Any CPU.Build.0 = Release|Any CPU + {8EADADCE-DF0D-46F4-A64C-24BAB8438F5F}.Release|x64.ActiveCfg = Release|Any CPU + {8EADADCE-DF0D-46F4-A64C-24BAB8438F5F}.Release|x64.Build.0 = Release|Any CPU + {8EADADCE-DF0D-46F4-A64C-24BAB8438F5F}.Release|x86.ActiveCfg = Release|Any CPU + {8EADADCE-DF0D-46F4-A64C-24BAB8438F5F}.Release|x86.Build.0 = Release|Any CPU + {DA2F0C58-0D25-419B-A5CC-8498A4B47B2B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DA2F0C58-0D25-419B-A5CC-8498A4B47B2B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA2F0C58-0D25-419B-A5CC-8498A4B47B2B}.Debug|x64.ActiveCfg = Debug|Any CPU + {DA2F0C58-0D25-419B-A5CC-8498A4B47B2B}.Debug|x64.Build.0 = Debug|Any CPU + {DA2F0C58-0D25-419B-A5CC-8498A4B47B2B}.Debug|x86.ActiveCfg = Debug|Any CPU + {DA2F0C58-0D25-419B-A5CC-8498A4B47B2B}.Debug|x86.Build.0 = Debug|Any CPU + {DA2F0C58-0D25-419B-A5CC-8498A4B47B2B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DA2F0C58-0D25-419B-A5CC-8498A4B47B2B}.Release|x64.ActiveCfg = Release|Any CPU + {DA2F0C58-0D25-419B-A5CC-8498A4B47B2B}.Release|x64.Build.0 = Release|Any CPU + {DA2F0C58-0D25-419B-A5CC-8498A4B47B2B}.Release|x86.ActiveCfg = Release|Any CPU + {DA2F0C58-0D25-419B-A5CC-8498A4B47B2B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/add-in/README.md b/add-in/README.md index 1b4b468..fbc8c65 100644 --- a/add-in/README.md +++ b/add-in/README.md @@ -2,6 +2,34 @@ ## Usage +### Map Requirements + +Before opening the editor, the active map must contain these layers with these exact names: + +- `UTRANS.TRANSADMIN.ROADS_EDIT` +- `COUNTY_STREETS` +- `DFC_RESULT` +- `SGID.LOCATION.AddressSystemQuadrants` +- `SGID.BOUNDARIES.ZipCodes` +- `SGID.BOUNDARIES.Counties` +- `SGID.BOUNDARIES.Municipalities` + +Each layer must have an available feature-class data source. The `CARTOCODE`, `ONEWAY`, `VERT_LEVEL`, `SPEED_LMT`, and `STATUS` fields in `UTRANS.TRANSADMIN.ROADS_EDIT` must exist and use coded-value domains. The editor uses those domains for its dropdown values. + +### Dropdown Values and Highlights + +The five road dropdowns (`CARTOCODE`, `ONEWAY`, `VERT_LEVEL`, `SPEED_LMT`, and `STATUS`) display the descriptions from the coded-value domain, but save the corresponding domain codes. The available choices therefore come from the current `ROADS_EDIT` schema, not from a hard-coded list. + +When a DFC record is loaded, the initial selected value for each road dropdown is calculated independently: + +1. Use the non-blank value from the matching `COUNTY_STREETS` field. +2. If the county value is blank, use the matching value from the linked `ROADS_EDIT` feature. +3. If both values are blank, use the default: `CARTOCODE` `11`, `ONEWAY` `0`, `VERT_LEVEL` `0`, `SPEED_LMT` `25`, or `STATUS` `Active`. + +If the selected source value is not in its coded-value domain, the editor replaces it with that field's default. For a multi-select **Add New** operation, there is no single source road, so the editor starts with the defaults and applies the same dropdown values to every created road. For a single new record, the county-road values are used when available. + +A yellow highlighted border around a road dropdown means its current value differs from the value initially loaded into the editor. This can mean that you changed the value, that a value was normalized to its default because it was not a valid domain code, or that a multi-select new-road value was changed from its default. The highlight is a review indicator; it does not by itself mean that the value is invalid or that it has already been saved. + ### Adding New Features Use **Add New** to copy an unlinked new record or records from `DFC_RESULT` into the UTRANS roads layer. @@ -10,9 +38,21 @@ Use **Add New** to copy an unlinked new record or records from `DFC_RESULT` into 2. Review or update the road attributes shown in the editor. When multiple records are selected, the values are used for each new road. 3. Click **Add New**. -When multiple features are selected, the form values override the county-road values for `CARTOCODE`, `ONEWAY`, `VERT_LEVEL`, `SPEED_LMT`, and `STATUS`. Review these values before clicking **Add New** because the same editor values are applied to every new road. +The form values override the county-road values for `CARTOCODE`, `ONEWAY`, `VERT_LEVEL`, `SPEED_LMT`, and `STATUS`. Review these values before clicking **Add New** because the same editor values are applied to every new road, including when only one feature is selected. + +The editor starts these fields with the following defaults when no county-road or UTRANS value is available: `CARTOCODE` `11`, `ONEWAY` `0`, `VERT_LEVEL` `0`, `SPEED_LMT` `25`, and `STATUS` `Active`. If a loaded value is not present in the corresponding coded-value domain, it is replaced with the same default. + +The add-in creates a UTRANS road using each selected county-road geometry and attributes, links the `DFC_RESULT` record to the new road, and marks the change status as `COMPLETED`. The map selections are cleared after the operation finishes. + +### Reviewing Detected Changes + +Select a `DFC_RESULT` feature to choose its Change Status. Use **IGNORE** for a change that should not update UTRANS, such as a driveway, or **REVISIT** to defer review. Click **Save** to store that change status on the selected DFC result. + +For an unlinked new record, **Add New** is available only when the record's change type is `N` and `BASE_FID` is `-1`. **Add New** creates the UTRANS road from the county-road geometry, links the DFC record, and sets its status to **COMPLETED**. A **COMPLETED** save for an unlinked record is rejected; click **Add New** first. Choosing **IGNORE** or **REVISIT** saves only the DFC status and does not write a UTRANS road. For a linked record, **COMPLETED** saves the edited UTRANS attributes and DFC status, while **IGNORE** and **REVISIT** save only the DFC status. + +### Repairing a DFC Identifier -The add-in creates a UTRANS road using each selected county-road geometry and attributes, links the `DFC_RESULT` record to the new road, and marks the DFC disposition as `COMPLETED`. The map selections are cleared after the operation finishes. +Use **Update DFC BASE_FID** when a DFC record references the wrong UTRANS segment or after splitting a line. Select exactly one feature in `UTRANS.TRANSADMIN.ROADS_EDIT` before using the button. The repair changes `DFC_RESULT.BASE_FID` to that feature and appends the previous identifier to `DFC_RESULT.PREV__NOTES`. ## Developer Set Up diff --git a/add-in/UGRC.UtransTools.Core.Tests/ReviewRulesTests.cs b/add-in/UGRC.UtransTools.Core.Tests/ReviewRulesTests.cs new file mode 100644 index 0000000..27eca3a --- /dev/null +++ b/add-in/UGRC.UtransTools.Core.Tests/ReviewRulesTests.cs @@ -0,0 +1,240 @@ +using UGRC.UtransTools.Core; +using Xunit; + +namespace UGRC.UtransTools.Core.Tests; + +public sealed class ReviewRulesTests +{ + [Theory] + [InlineData("123", true)] + [InlineData("123.5", true)] + [InlineData("12A", false)] + [InlineData("12-14", false)] + public void Validates_Address_Range_Values(string value, bool isValid) + { + Assert.Equal(isValid, ReviewRules.IsValidAddressRange(value)); + } + + [Fact] + public void Normalizes_Edited_Road_Values_For_Saving() + { + var values = ReviewRules.NormalizeEditedRoadValues( + new Dictionary { ["NAME"] = " O'Brien ", ["L_F_ADD"] = " " }, + new[] { "L_F_ADD" } + ); + + Assert.Equal("OBrien", values["NAME"]); + Assert.Equal(0d, values["L_F_ADD"]); + } + + [Fact] + public void Creates_Status_Only_Save_For_Unlinked_Record_With_Ignore_Status() + { + var plan = ReviewRules.CreateSavePlan( + ChangeStatus.Ignore, + hasLinkedUtransRoad: false, + new Dictionary { ["NAME"] = "County Road" } + ); + + Assert.False(plan.WritesUtransRoad); + Assert.Empty(plan.UtransRoadValues); + Assert.Equal(ChangeStatus.Ignore, plan.ChangeStatus); + } + + [Fact] + public void Creates_Status_Only_Save_For_Unlinked_Record_With_Revisit_Status() + { + var plan = ReviewRules.CreateSavePlan( + ChangeStatus.Revisit, + hasLinkedUtransRoad: false, + new Dictionary { ["NAME"] = "County Road" } + ); + + Assert.False(plan.WritesUtransRoad); + Assert.Empty(plan.UtransRoadValues); + Assert.Equal(ChangeStatus.Revisit, plan.ChangeStatus); + } + + [Fact] + public void Creates_Road_Save_For_Linked_Record_With_Completed_Status() + { + var plan = ReviewRules.CreateSavePlan( + ChangeStatus.Completed, + hasLinkedUtransRoad: true, + new Dictionary { ["NAME"] = "UTRANS Road" } + ); + + Assert.True(plan.WritesUtransRoad); + Assert.Equal("UTRANS Road", plan.UtransRoadValues["NAME"]); + Assert.Equal(ChangeStatus.Completed, plan.ChangeStatus); + } + + [Fact] + public void Creates_Status_Only_Save_For_Linked_Record_With_Revisit_Status() + { + var plan = ReviewRules.CreateSavePlan( + ChangeStatus.Revisit, + hasLinkedUtransRoad: true, + new Dictionary { ["NAME"] = "UTRANS Road" } + ); + + Assert.False(plan.WritesUtransRoad); + Assert.Empty(plan.UtransRoadValues); + Assert.Equal(ChangeStatus.Revisit, plan.ChangeStatus); + } + + [Fact] + public void Creates_Status_Only_Save_For_Linked_Record_With_Ignore_Status() + { + var plan = ReviewRules.CreateSavePlan( + ChangeStatus.Ignore, + hasLinkedUtransRoad: true, + new Dictionary { ["NAME"] = "UTRANS Road" } + ); + + Assert.False(plan.WritesUtransRoad); + Assert.Empty(plan.UtransRoadValues); + Assert.Equal(ChangeStatus.Ignore, plan.ChangeStatus); + } + + [Fact] + public void Rejects_Completed_Save_For_Unlinked_Record() + { + var exception = Assert.Throws(() => + ReviewRules.CreateSavePlan( + ChangeStatus.Completed, + hasLinkedUtransRoad: false, + new Dictionary() + ) + ); + + Assert.Equal( + "Click Add New to create the target UTRANS road before saving.", + exception.Message + ); + } + + [Fact] + public void Builds_Road_Payload() + { + var payload = ReviewRules.BuildRoadPayload( + new Dictionary + { + ["NAME"] = "Main", + ["POSTTYPE"] = "St", + ["POSTDIR"] = "E", + }, + new RoadReviewValues("11", "0", "0", "25", "Active") + ); + + Assert.Equal("11", payload["CARTOCODE"]); + Assert.Equal("0", payload["ONEWAY"]); + Assert.Equal("0", payload["VERT_LEVEL"]); + Assert.Equal("25", payload["SPEED_LMT"]); + Assert.Equal("Active", payload["STATUS"]); + } + + [Fact] + public void Allows_New_Road_Creation_Only_For_Unlinked_New_Records() + { + var selections = new[] { new DfcResultReview("N", -1), new DfcResultReview("N", -1) }; + + Assert.True(ReviewRules.CanCreateNewUtransRoads(selections)); + Assert.False(ReviewRules.CanCreateNewUtransRoads(new[] { new DfcResultReview("M", -1) })); + Assert.False(ReviewRules.CanCreateNewUtransRoads(new[] { new DfcResultReview("N", 42) })); + Assert.False(ReviewRules.CanCreateNewUtransRoads([])); + } + + [Theory] + [InlineData("11", "9", "11")] + [InlineData("0", "1", "0")] + [InlineData("0", "2", "0")] + [InlineData("25", "15", "25")] + [InlineData("Active", "Retired", "Active")] + public void Prefers_Primary_Road_Values_Over_Fallback_Values( + string primaryValue, + string fallbackValue, + string defaultValue + ) + { + var result = ReviewRules.ResolveRoadFieldValue(primaryValue, fallbackValue, defaultValue); + + Assert.Equal(primaryValue, result); + } + + [Theory] + [InlineData("", "15", "25", "15")] + [InlineData(" ", "", "25", "25")] + [InlineData("", null, "Active", "Active")] + public void Falls_Back_When_County_Road_Value_Is_Blank( + string countyValue, + string? utransValue, + string defaultValue, + string expectedValue + ) + { + var result = ReviewRules.ResolveRoadFieldValue(countyValue, utransValue, defaultValue); + + Assert.Equal(expectedValue, result); + } + + [Theory] + [InlineData("25", "25")] + [InlineData("0", "25")] + [InlineData("Unknown", "25")] + public void Normalizes_Coded_Values_To_Allowed_Values(string value, string expectedValue) + { + var result = ReviewRules.NormalizeCodedValue( + value, + "25", + new HashSet(new[] { "5", "25", "80" }, StringComparer.OrdinalIgnoreCase) + ); + + Assert.Equal(expectedValue, result); + } + + [Fact] + public void Transfers_Editable_County_Values_Without_Road_Overrides() + { + var payload = ReviewRules.BuildNewRoadPayload( + new Dictionary + { + ["NAME"] = "County Road", + ["CARTOCODE"] = "10", + ["SYSTEM_FIELD"] = "excluded", + }, + new[] { "NAME", "CARTOCODE", "ONEWAY" }, + new RoadReviewValues("11", "0", "0", "25", "Active"), + applyRoadValueOverrides: false + ); + + Assert.Equal("County Road", payload["NAME"]); + Assert.Equal("10", payload["CARTOCODE"]); + Assert.DoesNotContain("ONEWAY", payload.Keys); + Assert.DoesNotContain("SYSTEM_FIELD", payload.Keys); + } + + [Fact] + public void Builds_Bulk_New_Road_Payload_From_Editable_County_Values_And_Overrides() + { + var payload = ReviewRules.BuildNewRoadPayload( + new Dictionary + { + ["NAME"] = "County Road", + ["CARTOCODE"] = "10", + ["SYSTEM_FIELD"] = "excluded", + }, + new[] { "NAME", "CARTOCODE", "ONEWAY", "VERT_LEVEL", "SPEED_LMT", "STATUS" }, + new RoadReviewValues("11", "0", "0", "25", "Active"), + applyRoadValueOverrides: true + ); + + Assert.Equal("County Road", payload["NAME"]); + Assert.Equal("11", payload["CARTOCODE"]); + Assert.Equal("0", payload["ONEWAY"]); + Assert.Equal("0", payload["VERT_LEVEL"]); + Assert.Equal("25", payload["SPEED_LMT"]); + Assert.Equal("Active", payload["STATUS"]); + Assert.DoesNotContain("SYSTEM_FIELD", payload.Keys); + } +} diff --git a/add-in/UGRC.UtransTools.Core.Tests/UGRC.UtransTools.Core.Tests.csproj b/add-in/UGRC.UtransTools.Core.Tests/UGRC.UtransTools.Core.Tests.csproj new file mode 100644 index 0000000..96696be --- /dev/null +++ b/add-in/UGRC.UtransTools.Core.Tests/UGRC.UtransTools.Core.Tests.csproj @@ -0,0 +1,23 @@ + + + net8.0 + false + enable + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/add-in/UGRC.UtransTools.Core/ReviewRules.cs b/add-in/UGRC.UtransTools.Core/ReviewRules.cs new file mode 100644 index 0000000..f0a34aa --- /dev/null +++ b/add-in/UGRC.UtransTools.Core/ReviewRules.cs @@ -0,0 +1,147 @@ +using System.Collections.ObjectModel; + +namespace UGRC.UtransTools.Core; + +public enum ChangeStatus +{ + Completed, + Ignore, + Revisit, +} + +public sealed record SavePlan( + ChangeStatus ChangeStatus, + bool WritesUtransRoad, + IReadOnlyDictionary UtransRoadValues +); + +public sealed record RoadReviewValues( + string Cartocode, + string Oneway, + string VerticalLevel, + string SpeedLimit, + string Status +); + +public sealed record DfcResultReview(string ChangeType, long BaseFeatureId) +{ + public bool IsUnlinkedNewRecord => ChangeType == "N" && BaseFeatureId == -1; +} + +public static class ReviewRules +{ + public static bool IsValidAddressRange(string value) => + value.All(character => char.IsDigit(character) || character == '.'); + + public static IReadOnlyDictionary NormalizeEditedRoadValues( + IReadOnlyDictionary editedValues, + IEnumerable addressRangeFieldNames + ) + { + var addressRanges = new HashSet( + addressRangeFieldNames, + StringComparer.OrdinalIgnoreCase + ); + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (fieldName, value) in editedValues) + { + values[fieldName] = + addressRanges.Contains(fieldName) && string.IsNullOrWhiteSpace(value) + ? 0d + : value.Trim().Replace("'", string.Empty, StringComparison.Ordinal); + } + + return new ReadOnlyDictionary(values); + } + + public static bool CanCreateNewUtransRoads(IReadOnlyCollection selections) => + selections.Count > 0 && selections.All(selection => selection.IsUnlinkedNewRecord); + + public static string ResolveRoadFieldValue( + string countyValue, + string? utransValue, + string defaultValue + ) => + !string.IsNullOrWhiteSpace(countyValue) ? countyValue + : !string.IsNullOrWhiteSpace(utransValue) ? utransValue + : defaultValue; + + public static string NormalizeCodedValue( + string value, + string defaultValue, + IReadOnlySet allowedValues + ) => allowedValues.Contains(value) ? value : defaultValue; + + public static IReadOnlyDictionary BuildNewRoadPayload( + IReadOnlyDictionary countyRoadValues, + IEnumerable editableFieldNames, + RoadReviewValues reviewValues, + bool applyRoadValueOverrides + ) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var fieldName in editableFieldNames) + { + if (countyRoadValues.TryGetValue(fieldName, out var value)) + { + values[fieldName] = value; + } + } + + if (applyRoadValueOverrides) + { + values["CARTOCODE"] = reviewValues.Cartocode; + values["ONEWAY"] = reviewValues.Oneway; + values["VERT_LEVEL"] = reviewValues.VerticalLevel; + values["SPEED_LMT"] = reviewValues.SpeedLimit; + values["STATUS"] = reviewValues.Status; + } + + return new ReadOnlyDictionary(values); + } + + public static IReadOnlyDictionary BuildRoadPayload( + IReadOnlyDictionary editedValues, + RoadReviewValues reviewValues + ) + { + var values = new Dictionary(editedValues, StringComparer.OrdinalIgnoreCase) + { + ["CARTOCODE"] = reviewValues.Cartocode, + ["ONEWAY"] = reviewValues.Oneway, + ["VERT_LEVEL"] = reviewValues.VerticalLevel, + ["SPEED_LMT"] = reviewValues.SpeedLimit, + ["STATUS"] = reviewValues.Status, + }; + + return new ReadOnlyDictionary(values); + } + + public static SavePlan CreateSavePlan( + ChangeStatus changeStatus, + bool hasLinkedUtransRoad, + IReadOnlyDictionary editedRoadValues + ) + { + var writesUtransRoad = changeStatus == ChangeStatus.Completed; + if (writesUtransRoad && !hasLinkedUtransRoad) + { + throw new InvalidOperationException( + "Click Add New to create the target UTRANS road before saving." + ); + } + + return new SavePlan( + changeStatus, + writesUtransRoad, + writesUtransRoad + ? new ReadOnlyDictionary( + new Dictionary( + editedRoadValues, + StringComparer.OrdinalIgnoreCase + ) + ) + : new ReadOnlyDictionary(new Dictionary()) + ); + } +} diff --git a/add-in/UGRC.UtransTools.Core/UGRC.UtransTools.Core.csproj b/add-in/UGRC.UtransTools.Core/UGRC.UtransTools.Core.csproj new file mode 100644 index 0000000..e8cd599 --- /dev/null +++ b/add-in/UGRC.UtransTools.Core/UGRC.UtransTools.Core.csproj @@ -0,0 +1,7 @@ + + + net8.0 + enable + enable + + diff --git a/add-in/UGRC.UtransTools/Config.daml b/add-in/UGRC.UtransTools/Config.daml index f28cff5..dc0f4e4 100644 --- a/add-in/UGRC.UtransTools/Config.daml +++ b/add-in/UGRC.UtransTools/Config.daml @@ -5,7 +5,7 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://schemas.esri.com/DADF/Registry file:///C:/Program%20Files/ArcGIS/Pro/bin/ArcGIS.Desktop.Framework.xsd"> - + UGRC_UtransTools UGRC_UtransTools description Images\AddinDesktop32.png diff --git a/add-in/UGRC.UtransTools/Configuration/UtransEditorConfiguration.cs b/add-in/UGRC.UtransTools/Configuration/UtransEditorConfiguration.cs index eb6ccee..74c77f8 100644 --- a/add-in/UGRC.UtransTools/Configuration/UtransEditorConfiguration.cs +++ b/add-in/UGRC.UtransTools/Configuration/UtransEditorConfiguration.cs @@ -11,7 +11,7 @@ internal static class UtransEditorConfiguration internal const string ZipCodesAlias = "SGID.BOUNDARIES.ZipCodes"; internal const string CountiesAlias = "SGID.BOUNDARIES.Counties"; internal const string MunicipalitiesAlias = "SGID.BOUNDARIES.Municipalities"; - internal const string DfcDispositionField = "CURRENT_NOTES"; + internal const string DfcChangeStatusField = "CURRENT_NOTES"; internal const string AttributeReferenceUrl = "https://docs.google.com/document/d/1h7FTFUEXWobA8fvctgxKLaxr6LslnwVPnGVgPlrHnz0/edit"; internal const string DfcDefinitionQueryUrl = diff --git a/add-in/UGRC.UtransTools/Models/DfcDisposition.cs b/add-in/UGRC.UtransTools/Models/DfcDisposition.cs deleted file mode 100644 index 03215bf..0000000 --- a/add-in/UGRC.UtransTools/Models/DfcDisposition.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace UGRC.UtransTools.Models; - -internal enum DfcDisposition -{ - Completed, - Ignore, - Revisit, -} diff --git a/add-in/UGRC.UtransTools/Models/DfcSelectionSnapshot.cs b/add-in/UGRC.UtransTools/Models/DfcSelectionSnapshot.cs index 016260e..77ef045 100644 --- a/add-in/UGRC.UtransTools/Models/DfcSelectionSnapshot.cs +++ b/add-in/UGRC.UtransTools/Models/DfcSelectionSnapshot.cs @@ -1,3 +1,5 @@ +using UGRC.UtransTools.Core; + namespace UGRC.UtransTools.Models; internal sealed record DfcSelectionSnapshot( @@ -11,5 +13,6 @@ internal sealed record DfcSelectionSnapshot( RoadSnapshot? UtransRoad ) { - internal bool IsNotYetCopiedNewRecord => ChangeType == "N" && BaseFeatureId == -1; + internal bool IsNotYetCopiedNewRecord => + new DfcResultReview(ChangeType, BaseFeatureId).IsUnlinkedNewRecord; } diff --git a/add-in/UGRC.UtransTools/Models/EditorReviewState.cs b/add-in/UGRC.UtransTools/Models/EditorReviewState.cs index a4f93c2..edd89d7 100644 --- a/add-in/UGRC.UtransTools/Models/EditorReviewState.cs +++ b/add-in/UGRC.UtransTools/Models/EditorReviewState.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Runtime.CompilerServices; using UGRC.UtransTools.Configuration; +using UGRC.UtransTools.Core; namespace UGRC.UtransTools.Models; @@ -19,6 +20,7 @@ internal sealed class EditorReviewState : INotifyPropertyChanged private string _verticalLevel = string.Empty; private string _speedLimit = string.Empty; private string _status = string.Empty; + private string _changeStatus = string.Empty; internal EditorReviewState() { @@ -36,7 +38,7 @@ internal EditorReviewState() VerticalLevel = "0"; SpeedLimit = "25"; Status = "Active"; - DfcStatus = "COMPLETED"; + ChangeStatus = "COMPLETED"; _initialCartocode = Cartocode; _initialOneway = Oneway; _initialVerticalLevel = VerticalLevel; @@ -50,7 +52,6 @@ internal EditorReviewState(DfcSelectionSnapshot selection) { Selection = selection; var utransRoad = selection.UtransRoad; - var roadValues = utransRoad ?? selection.CountyRoad; Fields = new ObservableCollection( UtransEditorConfiguration .EditableAddressFields.Select(field => CreateField(field, true)) @@ -61,30 +62,20 @@ internal EditorReviewState(DfcSelectionSnapshot selection) ) ); - Cartocode = string.IsNullOrWhiteSpace(roadValues.GetText("CARTOCODE")) - ? "11" - : roadValues.GetText("CARTOCODE"); - Oneway = string.IsNullOrWhiteSpace(roadValues.GetText("ONEWAY")) - ? "0" - : roadValues.GetText("ONEWAY"); - VerticalLevel = string.IsNullOrWhiteSpace(roadValues.GetText("VERT_LEVEL")) - ? "0" - : roadValues.GetText("VERT_LEVEL"); - SpeedLimit = string.IsNullOrWhiteSpace(roadValues.GetText("SPEED_LMT")) - ? "25" - : roadValues.GetText("SPEED_LMT"); - Status = string.IsNullOrWhiteSpace(roadValues.GetText("STATUS")) - ? "Active" - : roadValues.GetText("STATUS"); + Cartocode = ResolveRoadFieldValue("CARTOCODE", "11"); + Oneway = ResolveRoadFieldValue("ONEWAY", "0"); + VerticalLevel = ResolveRoadFieldValue("VERT_LEVEL", "0"); + SpeedLimit = ResolveRoadFieldValue("SPEED_LMT", "25"); + Status = ResolveRoadFieldValue("STATUS", "Active"); _initialCartocode = Cartocode; _initialOneway = Oneway; _initialVerticalLevel = VerticalLevel; _initialSpeedLimit = SpeedLimit; - DfcStatus = string.IsNullOrWhiteSpace( - selection.DfcResult.GetText(UtransEditorConfiguration.DfcDispositionField) + ChangeStatus = string.IsNullOrWhiteSpace( + selection.DfcResult.GetText(UtransEditorConfiguration.DfcChangeStatusField) ) ? "COMPLETED" - : selection.DfcResult.GetText(UtransEditorConfiguration.DfcDispositionField); + : selection.DfcResult.GetText(UtransEditorConfiguration.DfcChangeStatusField); IsUdotRoad = !string.IsNullOrWhiteSpace(utransRoad?.GetText("DOT_RTNAME")); IsAgrcAdjusted = utransRoad @@ -98,6 +89,13 @@ AttributeReviewField CreateField(string fieldName, bool isAddressRange) => utransRoad?.GetText(fieldName) ?? string.Empty, isAddressRange ); + + string ResolveRoadFieldValue(string fieldName, string defaultValue) => + ReviewRules.ResolveRoadFieldValue( + selection.CountyRoad.GetText(fieldName), + utransRoad?.GetText(fieldName), + defaultValue + ); } public string Status @@ -175,26 +173,60 @@ public string SpeedLimit !string.Equals(VerticalLevel, _initialVerticalLevel, StringComparison.Ordinal); public bool IsSpeedLimitChanged => !string.Equals(SpeedLimit, _initialSpeedLimit, StringComparison.Ordinal); - public string DfcStatus { get; set; } = string.Empty; + public string ChangeStatus + { + get => _changeStatus; + set + { + if (_changeStatus == value) + { + return; + } + + _changeStatus = value; + OnPropertyChanged(); + } + } public bool IsUdotRoad { get; } public bool IsAgrcAdjusted { get; } internal IReadOnlyDictionary GetEditedValues() { - var values = new Dictionary(); - foreach (var field in Fields) - { - values[field.FieldName] = - field.IsAddressRange && string.IsNullOrWhiteSpace(field.UtransValue) - ? 0d - : field - .UtransValue.Trim() - .Replace("'", string.Empty, System.StringComparison.Ordinal); - } + return ReviewRules.NormalizeEditedRoadValues( + Fields.ToDictionary(field => field.FieldName, field => field.UtransValue), + Fields.Where(field => field.IsAddressRange).Select(field => field.FieldName) + ); + } - return values; + internal void NormalizeCodedValues( + IEnumerable cartocodeValues, + IEnumerable onewayValues, + IEnumerable verticalLevelValues, + IEnumerable speedLimitValues, + IEnumerable statusValues + ) + { + Cartocode = NormalizeCodedValue(Cartocode, "11", cartocodeValues); + Oneway = NormalizeCodedValue(Oneway, "0", onewayValues); + VerticalLevel = NormalizeCodedValue(VerticalLevel, "0", verticalLevelValues); + SpeedLimit = NormalizeCodedValue(SpeedLimit, "25", speedLimitValues); + Status = NormalizeCodedValue(Status, "Active", statusValues); } + private static string NormalizeCodedValue( + string value, + string defaultValue, + IEnumerable options + ) => + ReviewRules.NormalizeCodedValue( + value, + defaultValue, + new HashSet( + options.Select(option => option.Code), + StringComparer.OrdinalIgnoreCase + ) + ); + private void SetTrackedValue( ref string field, string value, diff --git a/add-in/UGRC.UtransTools/Services/DfcSelectionService.cs b/add-in/UGRC.UtransTools/Services/DfcSelectionService.cs index 26f5230..5b83d83 100644 --- a/add-in/UGRC.UtransTools/Services/DfcSelectionService.cs +++ b/add-in/UGRC.UtransTools/Services/DfcSelectionService.cs @@ -11,6 +11,11 @@ namespace UGRC.UtransTools.Services; internal sealed class DfcSelectionService { + internal Task HasExactlyOneUtransRoadSelectedAsync(EditorLayerContext layers) + { + return QueuedTask.Run(() => layers.UtransRoads.GetSelection().GetObjectIDs().Count == 1); + } + internal Task LoadSelectedAsync(EditorLayerContext layers) { return QueuedTask.Run(() => diff --git a/add-in/UGRC.UtransTools/Services/LayerValidationService.cs b/add-in/UGRC.UtransTools/Services/LayerValidationService.cs index 5466616..ae4a612 100644 --- a/add-in/UGRC.UtransTools/Services/LayerValidationService.cs +++ b/add-in/UGRC.UtransTools/Services/LayerValidationService.cs @@ -100,7 +100,16 @@ internal Task< StringComparer.OrdinalIgnoreCase ); - foreach (var fieldName in new[] { "CARTOCODE", "ONEWAY", "VERT_LEVEL", "STATUS" }) + foreach ( + var fieldName in new[] + { + "CARTOCODE", + "ONEWAY", + "VERT_LEVEL", + "SPEED_LMT", + "STATUS", + } + ) { var field = fields.FirstOrDefault(candidate => diff --git a/add-in/UGRC.UtransTools/Services/UtransEditService.cs b/add-in/UGRC.UtransTools/Services/UtransEditService.cs index 6006eb2..0105c15 100644 --- a/add-in/UGRC.UtransTools/Services/UtransEditService.cs +++ b/add-in/UGRC.UtransTools/Services/UtransEditService.cs @@ -7,6 +7,7 @@ using ArcGIS.Desktop.Framework.Threading.Tasks; using ArcGIS.Desktop.Mapping; using UGRC.UtransTools.Configuration; +using UGRC.UtransTools.Core; using UGRC.UtransTools.Models; namespace UGRC.UtransTools.Services; @@ -22,8 +23,7 @@ EditorReviewState roadValues return QueuedTask.Run(async () => { if ( - selections.Count == 0 - || selections.Any(selection => !selection.IsNotYetCopiedNewRecord) + !ReviewRules.CanCreateNewUtransRoads(selections.Select(ToDfcResultReview).ToArray()) ) { throw new InvalidOperationException( @@ -48,7 +48,7 @@ EditorReviewState roadValues selection.CountyRoad, utransFeatureClass, roadValues, - selections.Count > 1 + applyRoadValueOverrides: true ) ), }) @@ -70,7 +70,7 @@ EditorReviewState roadValues var values = new Dictionary { ["BASE_FID"] = newRoad.NewRoad.ObjectID, - [UtransEditorConfiguration.DfcDispositionField] = "COMPLETED", + [UtransEditorConfiguration.DfcChangeStatusField] = "COMPLETED", }; linkOperation.Modify(layers.DfcResults, newRoad.Selection.ObjectId, values); @@ -132,49 +132,40 @@ internal Task SaveAsync(EditorLayerContext layers, EditorReviewState state) { return QueuedTask.Run(async () => { - var values = GetEditedValues(state); + var values = GetRoadPayload(state); var operation = new EditOperation { Name = "Save UTRANS editor changes", SelectModifiedFeatures = false, }; - var shouldWriteRoad = string.Equals( - state.DfcStatus, - "COMPLETED", - System.StringComparison.OrdinalIgnoreCase + var plan = ReviewRules.CreateSavePlan( + ParseChangeStatus(state.ChangeStatus), + state.Selection.UtransRoad is not null, + values ); - if (shouldWriteRoad && state.Selection.UtransRoad is null) - { - throw new InvalidOperationException( - "Click Add New to create the target UTRANS road before saving." - ); - } - - if (shouldWriteRoad) - { - operation.Modify(layers.UtransRoads, state.Selection.BaseFeatureId, values); - operation.Modify( - layers.DfcResults, - state.Selection.ObjectId, - new Dictionary - { - [UtransEditorConfiguration.DfcDispositionField] = state.DfcStatus, - } - ); - } - else + if (plan.WritesUtransRoad) { operation.Modify( - layers.DfcResults, - state.Selection.ObjectId, - new Dictionary - { - [UtransEditorConfiguration.DfcDispositionField] = state.DfcStatus, - } + layers.UtransRoads, + state.Selection.BaseFeatureId, + new Dictionary( + plan.UtransRoadValues, + StringComparer.OrdinalIgnoreCase + ) ); } + operation.Modify( + layers.DfcResults, + state.Selection.ObjectId, + new Dictionary + { + [UtransEditorConfiguration.DfcChangeStatusField] = plan + .ChangeStatus.ToString() + .ToUpperInvariant(), + } + ); if (!await operation.ExecuteAsync()) { @@ -185,28 +176,8 @@ internal Task SaveAsync(EditorLayerContext layers, EditorReviewState state) }); } - private static Dictionary GetEditedValues(EditorReviewState state) - { - var values = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var pair in state.GetEditedValues()) - { - values[pair.Key] = pair.Value; - } - - values = new Dictionary(values, StringComparer.OrdinalIgnoreCase) - { - ["CARTOCODE"] = state.Cartocode, - ["ONEWAY"] = state.Oneway, - ["VERT_LEVEL"] = state.VerticalLevel, - ["SPEED_LMT"] = state.SpeedLimit, - ["FULLNAME"] = BuildFullName(values), - }; - - values["STATUS"] = state.Status; - - return values; - } + private static IReadOnlyDictionary GetRoadPayload(EditorReviewState state) => + ReviewRules.BuildRoadPayload(state.GetEditedValues(), ToRoadReviewValues(state)); private static Dictionary GetCountyRoadValues( RoadSnapshot countyRoad, @@ -215,39 +186,30 @@ internal Task SaveAsync(EditorLayerContext layers, EditorReviewState state) bool applyRoadValueOverrides ) { - var values = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var field in utransFeatureClass.GetDefinition().GetFields()) - { - if (field.IsEditable && countyRoad.Attributes.TryGetValue(field.Name, out var value)) - { - values[field.Name] = value; - } - } - - if (applyRoadValueOverrides) - { - values["CARTOCODE"] = roadValues.Cartocode; - values["ONEWAY"] = roadValues.Oneway; - values["VERT_LEVEL"] = roadValues.VerticalLevel; - values["SPEED_LMT"] = roadValues.SpeedLimit; - values["STATUS"] = roadValues.Status; - } - - return values; + var editableFieldNames = utransFeatureClass + .GetDefinition() + .GetFields() + .Where(field => field.IsEditable) + .Select(field => field.Name); + return new Dictionary( + ReviewRules.BuildNewRoadPayload( + countyRoad.Attributes, + editableFieldNames, + ToRoadReviewValues(roadValues), + applyRoadValueOverrides + ), + StringComparer.OrdinalIgnoreCase + ); } - private static string BuildFullName(IReadOnlyDictionary values) - { - var name = GetText(values, "NAME"); - var suffix = name.All(char.IsDigit) - ? GetText(values, "POSTDIR") - : GetText(values, "POSTTYPE"); - return string.IsNullOrWhiteSpace(suffix) ? name : $"{name} {suffix}".Trim(); - } + private static DfcResultReview ToDfcResultReview(DfcSelectionSnapshot selection) => + new(selection.ChangeType, selection.BaseFeatureId); + + private static RoadReviewValues ToRoadReviewValues(EditorReviewState state) => + new(state.Cartocode, state.Oneway, state.VerticalLevel, state.SpeedLimit, state.Status); - private static string GetText(IReadOnlyDictionary values, string fieldName) => - values.TryGetValue(fieldName, out var value) - ? value?.ToString()?.Replace("'", string.Empty, System.StringComparison.Ordinal) - ?? string.Empty - : string.Empty; + private static ChangeStatus ParseChangeStatus(string changeStatus) => + Enum.TryParse(changeStatus, ignoreCase: true, out var parsedStatus) + ? parsedStatus + : throw new InvalidOperationException($"Unsupported change status: {changeStatus}"); } diff --git a/add-in/UGRC.UtransTools/UGRC.UtransTools.csproj b/add-in/UGRC.UtransTools/UGRC.UtransTools.csproj index 21b65bd..d9cb465 100644 --- a/add-in/UGRC.UtransTools/UGRC.UtransTools.csproj +++ b/add-in/UGRC.UtransTools/UGRC.UtransTools.csproj @@ -20,6 +20,9 @@ + + + diff --git a/add-in/UGRC.UtransTools/UtransEditorDockpaneView.xaml b/add-in/UGRC.UtransTools/UtransEditorDockpaneView.xaml index 9869cae..49a2b12 100644 --- a/add-in/UGRC.UtransTools/UtransEditorDockpaneView.xaml +++ b/add-in/UGRC.UtransTools/UtransEditorDockpaneView.xaml @@ -457,10 +457,13 @@ - +