diff --git a/applications/Unity.AutoUI/cypress/e2e/lists.cy.ts b/applications/Unity.AutoUI/cypress/e2e/lists.cy.ts index 3a73fef29b..35be3f99c9 100644 --- a/applications/Unity.AutoUI/cypress/e2e/lists.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/lists.cy.ts @@ -10,31 +10,34 @@ describe('Grant Manager Login and List Navigation', () => { const appsPage = ApplicationsPageInstance() function setDashboardIntakeToTestIfAvailable() { - const btnSel = 'button[data-id="dashboardIntakeId"]' - const listboxSel = '#bs-select-1[role="listbox"]' - const searchSel = 'input[type="search"][aria-controls="bs-select-1"]' - - cy.get(btnSel, { timeout: 30000 }) + // The INTAKES filter is a Select2 (bootstrap-5 theme) multi-select, not + // bootstrap-select — the toggle is the [role="combobox"] wrapping the + // rendered-choices @@ -230,5 +245,13 @@ @await Component.InvokeAsync(typeof(Unity.AI.Web.Views.Settings.AISettingGroup.AISettingViewComponent)) } + + + @if (Model.ShowProgramDetails) + { +
+ @await Component.InvokeAsync(typeof(Unity.GrantManager.Web.Views.Settings.ProgramDetails.ProgramDetailsViewComponent)) +
+ } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml index 80c2d7a984..952ad6afb9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml @@ -1,8 +1,12 @@ @page +@using Microsoft.Extensions.Localization +@using Unity.GrantManager.Localization @model Unity.GrantManager.Web.Pages.ErrorModel +@inject IStringLocalizer L @{ var code = Model.HttpStatusCode; + var isWrongTenant = code == 409; string title; string message; @@ -17,6 +21,10 @@ title = "Access Denied"; message = "You do not have permission to view this page."; break; + case 409: + title = L["WrongTenantError:Title"].Value; + message = string.Empty; + break; case 500: title = "Something Went Wrong"; message = "An unexpected error occurred. Please try again, or contact support if the problem persists."; @@ -31,6 +39,17 @@

@title

-

@message

+ @if (isWrongTenant) + { +

+ @L["WrongTenantError:ApplicationTenant", Model.ApplicationTenantName]
+ @L["WrongTenantError:CurrentTenant", Model.CurrentTenantName] +

+

@L["WrongTenantError:Instructions"]

+ } + else + { +

@message

+ }
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml.cs index 246f000a8d..aefc0dae49 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml.cs @@ -5,9 +5,13 @@ namespace Unity.GrantManager.Web.Pages; public class ErrorModel : PageModel { public int HttpStatusCode { get; private set; } + public string? ApplicationTenantName { get; private set; } + public string? CurrentTenantName { get; private set; } - public void OnGet(int httpStatusCode = 0) + public void OnGet(int httpStatusCode = 0, string? applicationTenantName = null, string? currentTenantName = null) { HttpStatusCode = httpStatusCode;//HTTP Status Code + ApplicationTenantName = applicationTenantName; + CurrentTenantName = currentTenantName; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index a6c5e0b226..40884fc686 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -52,8 +52,9 @@ } @section styles { - - + + + } @section scripts { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs index f01f08326a..223565f1e8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs @@ -21,6 +21,7 @@ using Unity.Modules.Shared.Specializations; using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; using Volo.Abp.Features; +using Volo.Abp.TenantManagement; using Volo.Abp.Users; namespace Unity.GrantManager.Web.Pages.GrantApplications @@ -33,6 +34,7 @@ public class DetailsModel : AbpPageModel private readonly IApplicationFormVersionAppService _applicationFormVersionAppService; private readonly IScoresheetRepository _scoresheetRepository; private readonly IFeatureChecker _featureChecker; + private readonly ITenantRepository _tenantRepository; protected readonly IZoneManagementAppService _zoneManagementAppService; [BindProperty(SupportsGet = true)] @@ -48,6 +50,9 @@ public class DetailsModel : AbpPageModel [BindProperty(SupportsGet = true)] public Guid ApplicationId { get; set; } + [BindProperty(SupportsGet = true)] + public Guid? TenantId { get; set; } + [BindProperty(SupportsGet = true)] public Guid ApplicationFormVersionId { get; set; } @@ -94,6 +99,7 @@ public DetailsModel( IApplicationFormVersionAppService applicationFormVersionAppService, IScoresheetRepository scoresheetRepository, IFeatureChecker featureChecker, + ITenantRepository tenantRepository, ICurrentUser currentUser, IConfiguration configuration, IZoneManagementAppService zoneManagementAppService) @@ -103,6 +109,7 @@ public DetailsModel( _featureChecker = featureChecker; _applicationFormVersionAppService = applicationFormVersionAppService; _scoresheetRepository = scoresheetRepository; + _tenantRepository = tenantRepository; _zoneManagementAppService = zoneManagementAppService; CurrentUserId = currentUser.Id; @@ -113,8 +120,19 @@ public DetailsModel( TotalEmailAttachmentMaxFileSize = configuration["S3:EmailAttachmentsTotalMaxFileSize"] ?? "25"; } - public async Task OnGetAsync() + public async Task OnGetAsync() { + if (TenantId.HasValue && TenantId.Value != CurrentTenant.Id) + { + var applicationTenant = await _tenantRepository.FindAsync(TenantId.Value); + return RedirectToPage("/Error", new + { + httpStatusCode = 409, + applicationTenantName = applicationTenant?.Name ?? TenantId.Value.ToString(), + currentTenantName = CurrentTenant.Name ?? "Host" + }); + } + if (await _featureChecker.IsEnabledAsync(SpecializationConsts.Onboarding)) { ViewData["ActiveNavHref"] = "/TenantManagement/Onboarding"; @@ -152,6 +170,8 @@ public async Task OnGetAsync() ArgumentNullException.ThrowIfNull(applicationForm); ApplicationScoresheetSchemaJson = await GetApplicationScoresheetSchemaJsonAsync(applicationForm); ApplicationFormSubmissionData = applicationFormSubmission.Submission; + + return Page(); } public async Task OnPostAsync() diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css index 666206fd20..7590f16596 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css @@ -66,41 +66,6 @@ justify-content: center; } -.ai-button-content { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.5rem; -} - -.ai-generate-btn { - height: 2.25rem; -} - -.ai-generate-btn:disabled, -.ai-generate-btn.disabled { - cursor: not-allowed; - opacity: 0.55; - pointer-events: none; -} - -.ai-generate-btn:disabled:hover, -.ai-generate-btn.disabled:hover, -.ai-generate-btn[data-ai-shared-generating='1'], -.ai-generate-btn[data-ai-cooldown-active='1'], -.ai-generate-btn[data-ai-cooldown-checking='1'] { - pointer-events: none; -} - -.ai-generate-btn.btn-sm { - height: 2rem; -} - -.ai-generate-btn .spinner-border-sm { - width: 0.875rem; - height: 0.875rem; -} - .left-card { border-right: 1px solid #dddddd; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-button.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-button.css new file mode 100644 index 0000000000..7a01423328 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-button.css @@ -0,0 +1,32 @@ +.ai-button-content { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.ai-generate-btn { + height: 2.25rem; +} + +.ai-generate-btn:disabled, +.ai-generate-btn.disabled, +.ai-generate-btn[data-ai-shared-generating='1'], +.ai-generate-btn[data-ai-cooldown-active='1'], +.ai-generate-btn[data-ai-cooldown-checking='1'] { + cursor: not-allowed; + opacity: 1; + background-color: #e9ecef !important; + border-color: #adb5bd !important; + color: #495057 !important; + pointer-events: none; +} + +.ai-generate-btn.btn-sm { + height: 2rem; +} + +.ai-generate-btn .spinner-border-sm { + width: 0.875rem; + height: 0.875rem; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js index e91f359097..7bd57e4276 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-rate-limit.js @@ -3,7 +3,7 @@ * across every .ai-generate-btn so all AI surfaces behave consistently. */ (function () { - const BUTTON_SELECTOR = '.ai-generate-btn'; + const BUTTON_SELECTOR = '.ai-generate-btn:not([data-ai-review-action="true"])'; const ATTR_LABEL = 'data-original-label'; const ATTR_COOLDOWN = 'data-ai-cooldown-active'; const ATTR_CHECKING = 'data-ai-cooldown-checking'; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml index 2e7f8e8796..f3cbf078b3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml @@ -31,7 +31,7 @@
@if (Model.IsAIScoringEnabled && Model.IsAiAssessment) { -
- - @if (await PermissionChecker.IsGrantedAsync(AIPermissions.FormWorksheet.Generate)) - { - - - } - @if (await PermissionChecker.IsGrantedAsync(AIPermissions.FormScoresheet.Generate)) + @if (await PermissionChecker.IsGrantedAsync(AIPermissions.FormScoresheet.View)) { } +
- - diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.css index e5511e4b19..7a9c3bad91 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.css @@ -112,27 +112,27 @@ height: calc(100vh - 250px); } -.ai-worksheet-review { +.ai-suggestion-review { border: 0; border-radius: 10px; overflow: hidden; } -.ai-worksheet-review__panel { +.ai-suggestion-review__panel { display: flex; flex-direction: column; gap: 12px; padding: 20px; } -.ai-worksheet-review__panel > .modal-header, -.ai-worksheet-review__panel > .modal-body, -.ai-worksheet-review__panel > .modal-footer { +.ai-suggestion-review__panel > .modal-header, +.ai-suggestion-review__panel > .modal-body, +.ai-suggestion-review__panel > .modal-footer { margin: 0; padding: 0; } -.ai-worksheet-review__body { +.ai-suggestion-review__body { display: flex; flex: 0 1 auto; flex-direction: column; @@ -140,17 +140,17 @@ max-height: min(65vh, 42rem); } -.ai-worksheet-review__list { +.ai-suggestion-review__list { flex: 0 1 auto; min-height: 0; overflow-y: auto; } -.ai-worksheet-review__header { +.ai-suggestion-review__header { border-bottom: 0; } -.ai-worksheet-review .modal-title { +.ai-suggestion-review .modal-title { color: #1f2933; font-size: 1.25rem; font-weight: 600; @@ -158,30 +158,30 @@ margin: 0; } -.ai-worksheet-review__title-group .form-label { +.ai-suggestion-review__title-group .form-label { color: #334e68; font-size: 0.875rem; font-weight: 600; margin-bottom: 0.25rem; } -.ai-worksheet-review__title-group .form-control { +.ai-suggestion-review__title-group .form-control { border-color: #b8c7d6; font-size: 0.9375rem; } -.ai-worksheet-review__title-group .form-control:focus { +.ai-suggestion-review__title-group .form-control:focus { border-color: var(--bc-colors-blue-primary, #255a90); box-shadow: 0 0 0 0.2rem rgb(37 90 144 / 15%); } -.ai-worksheet-review__table-header, -.ai-worksheet-review__field { +.ai-suggestion-review__table-header, +.ai-suggestion-review__field { display: grid; grid-template-columns: minmax(0, 1fr) 2rem minmax(0, 1fr) auto; } -.ai-worksheet-review__table-header { +.ai-suggestion-review__table-header { align-items: center; background: #f4f7fa; border-bottom: 1px solid #d8e1eb; @@ -197,17 +197,58 @@ text-transform: uppercase; } -.ai-worksheet-review__field { +.ai-suggestion-review__field { align-items: center; min-height: 3.5rem; padding-inline: 0.75rem; } -.ai-worksheet-review__field + .ai-worksheet-review__field { +.ai-suggestion-review__panel[data-hide-target-column="true"] .ai-suggestion-review__table-header, +.ai-suggestion-review__panel[data-hide-target-column="true"] .ai-suggestion-review__field { + grid-template-columns: minmax(0, 1fr) 2rem auto; +} + +.ai-suggestion-review__panel[data-hide-target-column="true"] .ai-suggestion-review__switch, +.ai-suggestion-review__panel[data-hide-target-column="true"] .ai-suggestion-review__section-header .form-switch { + align-items: center; + display: flex; + justify-content: flex-end; + min-height: 2rem; + min-width: 2.5rem; +} + +.ai-suggestion-review__panel[data-hide-target-column="true"] .ai-suggestion-review__switch .form-check-input, +.ai-suggestion-review__panel[data-hide-target-column="true"] .ai-suggestion-review__section-header .form-check-input { + transform: scale(0.9); +} + +.ai-suggestion-review__panel[data-hide-target-column="true"] .ai-suggestion-review__select-all .form-check-input { + transform: scale(0.9); +} + +.ai-suggestion-review__section-header { + align-items: center; + background: #eef3f7; + border-top: 1px solid #d8e1eb; + color: #334e68; + display: flex; + font-weight: 700; + justify-content: space-between; + padding: 0.65rem 0.75rem; +} + +.ai-suggestion-review__source { + align-items: center; + display: flex; + gap: 0.5rem; + overflow-wrap: anywhere; +} + +.ai-suggestion-review__field + .ai-suggestion-review__field { border-top: 1px solid #edf1f5; } -.ai-worksheet-review__field-name { +.ai-suggestion-review__field-name { color: #1f2933; font-size: 0.9375rem; font-weight: 600; @@ -215,46 +256,130 @@ overflow-wrap: anywhere; } -.ai-worksheet-review__arrow { +.ai-suggestion-review__arrow { color: #52789d; justify-self: center; } -.ai-worksheet-review__switch { +.ai-suggestion-review__switch { justify-self: end; } -.ai-worksheet-review__field:hover { +.ai-suggestion-review__field:hover { background: #f9fbfd; } -.ai-worksheet-review__select-all { +.ai-suggestion-review__select-all { align-items: center; display: flex; justify-self: end; } -.ai-worksheet-review__empty { +.ai-suggestion-review__empty { color: #486581; text-align: center; } -.ai-worksheet-review__footer { +.ai-suggestion-review[data-empty-confirmation] .ai-suggestion-review__header { + justify-content: center; + padding-top: 0.5rem; + text-align: center; +} + +.ai-suggestion-review[data-empty-confirmation] .ai-suggestion-review__body { + min-height: 12rem; +} + +.ai-suggestion-review[data-empty-confirmation] .ai-suggestion-review__list { + align-items: center; + display: flex; + justify-content: center; + min-height: 10rem; +} + +.ai-suggestion-review[data-empty-confirmation] .ai-suggestion-review__empty { + align-items: center; + display: flex; + flex-direction: column; + font-size: 1rem; + font-weight: 500; + line-height: 1.5; + max-width: 28rem; + padding: 1.5rem 1rem; + text-align: center; +} + +.ai-suggestion-review__empty-icon { + align-items: center; + background: #e8f1f8; + border-radius: 50%; + color: #255a90; + display: inline-flex; + font-size: 1.35rem; + height: 3.5rem; + justify-content: center; + margin-bottom: 0.9rem; + position: relative; + width: 3.5rem; +} + +.ai-suggestion-review__empty-check { + align-items: center; + background: #2f855a; + border: 2px solid #fff; + border-radius: 50%; + bottom: -0.15rem; + color: #fff; + display: inline-flex; + font-size: 0.55rem; + height: 1.25rem; + justify-content: center; + position: absolute; + right: -0.15rem; + width: 1.25rem; +} + +.ai-suggestion-review__empty-title { + color: #1f2933; + font-size: 1.08rem; + font-weight: 600; + line-height: 1.35; + margin-bottom: 0.4rem; +} + +.ai-suggestion-review__empty-text { + color: #627d98; + font-size: 0.92rem; + font-weight: 400; + line-height: 1.55; +} + +.ai-suggestion-review[data-empty-confirmation] .ai-suggestion-review__footer { + justify-content: center; +} + +.ai-suggestion-review[data-empty-confirmation] .ai-suggestion-review__footer .btn-primary { + font-size: 0.9rem; + min-width: 0; + padding: 0.5rem 1.25rem; +} + +.ai-suggestion-review__footer { border-top: 0; gap: 0.5rem; justify-content: flex-start; } -.ai-worksheet-review__footer > * { +.ai-suggestion-review__footer > * { margin: 0; } -.ai-worksheet-review__footer .btn { +.ai-suggestion-review__footer .btn { font-size: inherit; font-weight: 400; } -.ai-worksheet-review__discard { +.ai-suggestion-review__discard { --bs-btn-color: #b42318; --bs-btn-border-color: #b42318; --bs-btn-hover-bg: #b42318; @@ -263,33 +388,33 @@ } @media (max-width: 767.98px) { - .ai-worksheet-review__body { + .ai-suggestion-review__body { max-height: 70vh; } - .ai-worksheet-review__table-header { + .ai-suggestion-review__table-header { display: flex; justify-content: flex-end; } - .ai-worksheet-review__table-header > span { + .ai-suggestion-review__table-header > span { display: none; } - .ai-worksheet-review__field { + .ai-suggestion-review__field { grid-template-columns: minmax(0, 1fr) 1.5rem auto; row-gap: 0.5rem; } - .ai-worksheet-review__field-name:first-child { + .ai-suggestion-review__field-name:first-child { grid-column: 1 / 2; } - .ai-worksheet-review__field-name:nth-child(3) { + .ai-suggestion-review__field-name:nth-child(3) { grid-column: 1 / 2; } - .ai-worksheet-review__field-name::before { + .ai-suggestion-review__field-name::before { color: #596777; content: attr(data-field-role); display: block; @@ -300,12 +425,12 @@ text-transform: uppercase; } - .ai-worksheet-review__arrow { + .ai-suggestion-review__arrow { grid-column: 2 / 3; grid-row: 1 / 3; } - .ai-worksheet-review__switch { + .ai-suggestion-review__switch { grid-column: 3 / 4; grid-row: 1 / 3; } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/DataSeed/AIModelDataSeederTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/DataSeed/AIModelDataSeederTests.cs index 9018a0a016..c4a9ed510b 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/DataSeed/AIModelDataSeederTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/DataSeed/AIModelDataSeederTests.cs @@ -30,6 +30,7 @@ public async Task Should_Seed_All_Configured_Models() .Returns(callInfo => { var model = callInfo.Arg(); + ArgumentNullException.ThrowIfNull(model); insertedModels.Add(model); return Task.FromResult(model); }); @@ -70,9 +71,10 @@ public async Task Should_Update_Existing_Model_When_Provider_Is_Stale() cancellationToken: Arg.Any()) .Returns(callInfo => { - var predicate = callInfo - .Arg>>() - .Compile(); + var predicateExpression = callInfo + .Arg>>(); + ArgumentNullException.ThrowIfNull(predicateExpression); + var predicate = predicateExpression.Compile(); return Task.FromResult(new[] { existingModel }.Where(predicate).ToList()); }); @@ -85,7 +87,7 @@ public async Task Should_Update_Existing_Model_When_Provider_Is_Stale() DeserializeSettings(existingModel.SettingsJson).MaxOutputTokenCountSupported.ShouldBeFalse(); await modelRepository.Received(1).UpdateAsync(existingModel, autoSave: true); await modelRepository.DidNotReceive().InsertAsync( - Arg.Is(model => model.Name == existingModel.Name), + Arg.Is(model => model != null && model.Name == existingModel.Name), Arg.Any(), Arg.Any()); } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/DataSeed/AIPromptDataSeederTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/DataSeed/AIPromptDataSeederTests.cs index a391d2cf08..9594190aa4 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/DataSeed/AIPromptDataSeederTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/DataSeed/AIPromptDataSeederTests.cs @@ -27,7 +27,7 @@ public async Task Should_Seed_The_Complete_BuiltIn_Prompt_Matrix() .Returns((AIPrompt?)null); promptRepository .InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(callInfo => + .ReturnsForAnyArgs(callInfo => { var prompt = callInfo.Arg(); ArgumentNullException.ThrowIfNull(prompt); @@ -53,8 +53,55 @@ prompt.TenantId is null && prompt.IsActive && !string.IsNullOrWhiteSpace(prompt.SystemPrompt) && !string.IsNullOrWhiteSpace(prompt.UserPrompt)).ShouldBeTrue(); + + var worksheetPrompt = insertedPrompts.Single(prompt => prompt.Name == AIPromptTypes.FormWorksheet); + worksheetPrompt.UserPrompt.ShouldContain("all applicable field suggestions"); + worksheetPrompt.UserPrompt.ShouldContain("empty fields array"); } + + [Fact] + public async Task Should_Update_Existing_Prompt_To_Current_Definition() + { + var existingPrompt = new AIPrompt(Guid.NewGuid(), AIPromptTypes.FormWorksheet, 2, "old system prompt", "old user prompt") + { + MetadataJson = "old metadata", + IsActive = false + }; + var promptName = existingPrompt.Name; + var promptVersion = existingPrompt.VersionNumber; + var promptRepository = Substitute.For>(); + promptRepository + .FirstOrDefaultAsync(Arg.Any>>()) + .ReturnsForAnyArgs(callInfo => + { + var predicate = callInfo.Arg>>().Compile(); + var probe = new AIPrompt(Guid.NewGuid(), promptName, promptVersion, string.Empty, string.Empty); + return Task.FromResult(predicate(probe) ? existingPrompt : null!); + }); + promptRepository + .FirstOrDefaultAsync(Arg.Any>>(), Arg.Any()) + .ReturnsForAnyArgs(callInfo => + { + var predicate = callInfo.Arg>>().Compile(); + var probe = new AIPrompt(Guid.NewGuid(), promptName, promptVersion, string.Empty, string.Empty); + return Task.FromResult(predicate(probe) ? existingPrompt : null!); + }); + promptRepository + .UpdateAsync(Arg.Any(), true, Arg.Any()) + .ReturnsForAnyArgs(callInfo => Task.FromResult(callInfo.Arg())); + + var currentTenant = Substitute.For(); + currentTenant.Change(null).Returns(Substitute.For()); + var seeder = new AIPromptDataSeeder(promptRepository, currentTenant); + await seeder.SeedAsync(new DataSeedContext()); + + existingPrompt.SystemPrompt.ShouldNotBe("old system prompt"); + existingPrompt.UserPrompt.ShouldContain("WORKSHEET"); + existingPrompt.MetadataJson.ShouldContain("DATA"); + existingPrompt.IsActive.ShouldBeTrue(); + await promptRepository.Received().UpdateAsync(existingPrompt, true, Arg.Any()); + } private static void AssertVersions( IEnumerable prompts, string promptName, diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/GenerateFormWorksheetJobTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/GenerateFormWorksheetJobTests.cs index 96a8975302..557345369f 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/GenerateFormWorksheetJobTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/GenerateFormWorksheetJobTests.cs @@ -2,6 +2,7 @@ using System.Linq; using Shouldly; using Unity.Flex.Worksheets; +using Unity.Flex.Domain.Worksheets; using Unity.GrantManager.GrantApplications.Automation.Operations.FormWorksheet; using Xunit; @@ -66,4 +67,16 @@ public void BuildWorksheet_Should_Create_One_SuggestedFields_Section_With_Defaul field.Order.ShouldBe(1u); field.Definition.ShouldContain("maxLength"); } + + [Fact] + public void EnsureCanonicalSuggestionWorksheetState_Should_Reject_Published_Worksheet() + { + var worksheet = new Worksheet(Guid.NewGuid(), "ai-form-worksheet", "AI Worksheet"); + worksheet.SetPublished(true); + + var exception = Should.Throw(() => + FormWorksheetOperationExecutor.EnsureCanonicalSuggestionWorksheetState(worksheet)); + + exception.Message.ShouldContain("canonical AI suggestion worksheet is published"); + } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicantProfile/AppServices/ApplicantContactAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicantProfile/AppServices/ApplicantContactAppServiceTests.cs index cb89efdba1..fb4f54f65e 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicantProfile/AppServices/ApplicantContactAppServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicantProfile/AppServices/ApplicantContactAppServiceTests.cs @@ -90,7 +90,8 @@ await _contactManager.Received(1).UpdateAsync( applicantId, contactId, Arg.Is(ci => - ci.Name == input.Name + ci != null + && ci.Name == input.Name && ci.Title == input.Title && ci.Email == input.Email && ci.HomePhoneNumber == null diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceMappingReviewTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceMappingReviewTests.cs new file mode 100644 index 0000000000..3587b58ea6 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceMappingReviewTests.cs @@ -0,0 +1,307 @@ +using System; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using NSubstitute; +using Microsoft.Extensions.Localization; +using Unity.AI.Localization; +using Shouldly; +using Unity.AI.Generation; +using Unity.AI.Operations; +using Unity.Flex.Domain.Worksheets; +using Unity.Flex.Domain.WorksheetLinks; +using Unity.Flex.Worksheets; +using Unity.Flex.Domain.Scoresheets; +using Unity.GrantManager.ApplicationForms.Mapping; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Forms; +using Unity.GrantManager.Intakes; +using Unity.GrantManager.Integrations.Chefs; +using Unity.GrantManager.Reporting.FieldGenerators; +using Unity.GrantManager.GrantApplications.Automation.Operations.FormMapping; +using Unity.Modules.Shared.Correlation; +using Volo.Abp; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Uow; +using Xunit; +using Xunit.Abstractions; + +namespace Unity.GrantManager.ApplicationForms; + +public class ApplicationFormVersionAppServiceMappingReviewTests(ITestOutputHelper outputHelper) + : GrantManagerApplicationTestBase(outputHelper) +{ + [Fact] + public async Task AcceptMappingSuggestionsAsync_Should_Apply_Selected_Suggestions_And_Keep_Remaining() + { + var formVersionId = Guid.NewGuid(); + var selectedSuggestionId = Guid.NewGuid(); + var remainingSuggestionId = Guid.NewGuid(); + var formVersion = new ApplicationFormVersion { SubmissionHeaderMapping = "{}" }; + var repository = Substitute.For>(); + repository.GetAsync(formVersionId).Returns(formVersion); + var review = CreateReview(formVersionId, [ + new FormMappingSuggestionDto + { + Id = selectedSuggestionId, + SourceField = "ChefsProjectName", + TargetField = "ProjectName" + }, + new FormMappingSuggestionDto + { + Id = remainingSuggestionId, + SourceField = "ChefsAmount", + TargetField = "RequestedAmount" + } + ]); + var reviewRepository = Substitute.For(); + reviewRepository.FindLatestByOperationAndFormVersionAsync( + AIGenerationOperations.FormMapping, + formVersionId) + .Returns(review); + var service = CreateService(repository, reviewRepository); + + var result = await service.AcceptMappingSuggestionsAsync(formVersionId, new AcceptMappingSuggestionsDto + { + SuggestionIds = [selectedSuggestionId] + }); + + result.SubmissionHeaderMapping.ShouldContain("ProjectName"); + formVersion.SubmissionHeaderMapping.ShouldContain("ProjectName"); + formVersion.SubmissionHeaderMapping.ShouldContain("ChefsProjectName"); + var payload = JsonSerializer.Deserialize(review.ReviewData)!; + payload.PendingSuggestions.Select(suggestion => suggestion.Id).ShouldBe([remainingSuggestionId]); + await repository.Received(1).UpdateAsync(formVersion, true); + await reviewRepository.Received(1).UpdateAsync(review, true); + } + + [Fact] + public async Task AcceptMappingSuggestionsAsync_Should_Leave_State_Unchanged_When_A_Suggestion_Is_Stale() + { + var formVersionId = Guid.NewGuid(); + var repository = Substitute.For>(); + var review = CreateReview(formVersionId, [new FormMappingSuggestionDto { Id = Guid.NewGuid() }]); + var reviewRepository = Substitute.For(); + reviewRepository.FindLatestByOperationAndFormVersionAsync( + AIGenerationOperations.FormMapping, + formVersionId) + .Returns(review); + var service = CreateService(repository, reviewRepository); + + await Should.ThrowAsync(async () => + { + await service.AcceptMappingSuggestionsAsync( + formVersionId, + new AcceptMappingSuggestionsDto { SuggestionIds = [Guid.NewGuid()] }); + }); + + await repository.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + await reviewRepository.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task AcceptMappingSuggestionsAsync_Should_Replace_Source_And_Move_Conflicting_Target_In_Final_Review() + { + var formVersionId = Guid.NewGuid(); + var suggestionId = Guid.NewGuid(); + var formVersion = new ApplicationFormVersion + { + SubmissionHeaderMapping = "{\"TargetA\":\"SourceA\",\"TargetB\":\"SourceB\"}" + }; + var repository = Substitute.For>(); + repository.GetAsync(formVersionId).Returns(formVersion); + var review = new GenerationReview(Guid.NewGuid(), AIGenerationOperations.FormMapping, formVersionId, sequence: 2); + review.SetReviewData(JsonSerializer.Serialize(new FormMappingReviewPayload + { + PendingSuggestions = + [ + new FormMappingSuggestionDto + { + Id = suggestionId, + SourceField = "SourceA", + TargetField = "TargetB" + } + ] + })); + var reviewRepository = Substitute.For(); + reviewRepository.FindLatestByOperationAndFormVersionAsync(AIGenerationOperations.FormMapping, formVersionId) + .Returns(review); + var service = CreateService(repository, reviewRepository); + + var result = await service.AcceptMappingSuggestionsAsync(formVersionId, new AcceptMappingSuggestionsDto + { + SuggestionIds = [suggestionId] + }); + + result.SubmissionHeaderMapping.ShouldBe("{\"TargetB\":\"SourceA\"}"); + } + + [Fact] + public void ClassifyFinalSuggestions_Should_Remove_Unchanged_And_Describe_Changes() + { + var suggestions = FormMappingOperationExecutor.ClassifyFinalSuggestions( + "{\"TargetA\":\"SourceA\",\"TargetB\":\"SourceB\"}", + [ + new FormMappingSuggestionDto { SourceField = "SourceA", TargetField = "TargetA" }, + new FormMappingSuggestionDto { SourceField = "SourceA", TargetField = "TargetB" }, + new FormMappingSuggestionDto { SourceField = "SourceC", TargetField = "TargetC" } + ], + out var unchangedCount); + + unchangedCount.ShouldBe(1); + suggestions.Count.ShouldBe(2); + suggestions[0].ChangeType.ShouldBe("Changed"); + suggestions[0].PreviousTargetField.ShouldBe("TargetA"); + suggestions[0].ConflictSourceField.ShouldBe("SourceB"); + suggestions[1].ChangeType.ShouldBe("New"); + } + + [Fact] + public async Task FinalizeMappingReviewAsync_Should_Allow_Final_Mapping_When_One_Of_Multiple_Drafts_Is_Published_And_Assigned() + { + var formVersionId = Guid.NewGuid(); + var formVersion = new ApplicationFormVersion { ApplicationFormId = Guid.NewGuid() }; + var assignedDraft = new Worksheet(Guid.NewGuid(), "ai-assigned", "Assigned AI worksheet"); + assignedDraft.SetPublished(true); + var unassignedDraft = new Worksheet(Guid.NewGuid(), "ai-unassigned", "Unassigned AI worksheet"); + var mappingReview = new GenerationReview(Guid.NewGuid(), AIGenerationOperations.FormMapping, formVersionId); + mappingReview.Complete(); + var worksheetReview = new GenerationReview(Guid.NewGuid(), AIGenerationOperations.FormWorksheet, formVersionId); + worksheetReview.Complete(); + worksheetReview.SetReviewData(JsonSerializer.Serialize(new FormWorksheetReviewPayload + { + DraftWorksheetIds = [assignedDraft.Id, unassignedDraft.Id] + })); + + var repository = Substitute.For>(); + repository.GetAsync(formVersionId).Returns(formVersion); + var generationService = Substitute.For(); + var reviewRepository = Substitute.For(); + reviewRepository.FindLatestByOperationAndFormVersionAsync(AIGenerationOperations.FormMapping, formVersionId) + .Returns(mappingReview); + reviewRepository.FindLatestByOperationAndFormVersionAsync(AIGenerationOperations.FormWorksheet, formVersionId) + .Returns(worksheetReview); + var worksheetRepository = Substitute.For(); + worksheetRepository.FindAsync(assignedDraft.Id).Returns(assignedDraft); + worksheetRepository.FindAsync(unassignedDraft.Id).Returns(unassignedDraft); + var worksheetLinkRepository = Substitute.For(); + worksheetLinkRepository.GetListByCorrelationAsync(formVersionId, CorrelationConsts.FormVersion) + .Returns([ + new WorksheetLink(Guid.NewGuid(), assignedDraft.Id, formVersionId, CorrelationConsts.FormVersion, string.Empty) + ]); + var service = CreateService(repository, reviewRepository, generationService, worksheetRepository, worksheetLinkRepository); + + await service.FinalizeMappingReviewAsync(formVersionId); + + mappingReview.Status.ShouldBe(GenerationReviewStatus.Completed); + await generationService.Received(1).SubmitAsync( + AIGenerationOperations.FormMapping, + Arg.Is(submission => + submission != null && + submission.ApplicationFormVersionId == formVersionId && + submission.ApplicationId == formVersion.ApplicationFormId)); + } + + [Fact] + public async Task GetMappingReviewAsync_Should_Complete_When_No_Worksheet_Suggestions_Were_Generated() + { + var formVersionId = Guid.NewGuid(); + var mappingReview = new GenerationReview(Guid.NewGuid(), AIGenerationOperations.FormMapping, formVersionId); + mappingReview.Complete(); + var worksheetReview = new GenerationReview(Guid.NewGuid(), AIGenerationOperations.FormWorksheet, formVersionId); + worksheetReview.SetReviewData(JsonSerializer.Serialize(new FormWorksheetReviewPayload + { + NoSuggestionsGenerated = true + })); + worksheetReview.Complete(); + + var repository = Substitute.For>(); + var reviewRepository = Substitute.For(); + reviewRepository.FindLatestByOperationAndFormVersionAsync(AIGenerationOperations.FormMapping, formVersionId) + .Returns(mappingReview); + reviewRepository.FindLatestByOperationAndFormVersionAsync(AIGenerationOperations.FormWorksheet, formVersionId) + .Returns(worksheetReview); + var service = CreateService(repository, reviewRepository); + + var result = await service.GetMappingReviewAsync(formVersionId); + + result.State.ShouldBe(FormGenerationWorkflowState.Completed.ToString()); + result.Action.ShouldBe(FormGenerationWorkflowAction.GenerateMapping.ToString()); + result.CanGenerateFinalMapping.ShouldBeFalse(); + } + + [Fact] + public async Task FinalizeMappingReviewAsync_Should_Reject_When_No_Worksheet_Suggestions_Were_Generated() + { + var formVersionId = Guid.NewGuid(); + var formVersion = new ApplicationFormVersion { ApplicationFormId = Guid.NewGuid() }; + var mappingReview = new GenerationReview(Guid.NewGuid(), AIGenerationOperations.FormMapping, formVersionId); + mappingReview.Complete(); + var worksheetReview = new GenerationReview(Guid.NewGuid(), AIGenerationOperations.FormWorksheet, formVersionId); + worksheetReview.SetReviewData(JsonSerializer.Serialize(new FormWorksheetReviewPayload + { + NoSuggestionsGenerated = true + })); + worksheetReview.Complete(); + + var repository = Substitute.For>(); + repository.GetAsync(formVersionId).Returns(formVersion); + var generationService = Substitute.For(); + var reviewRepository = Substitute.For(); + reviewRepository.FindLatestByOperationAndFormVersionAsync(AIGenerationOperations.FormMapping, formVersionId) + .Returns(mappingReview); + reviewRepository.FindLatestByOperationAndFormVersionAsync(AIGenerationOperations.FormWorksheet, formVersionId) + .Returns(worksheetReview); + var service = CreateService(repository, reviewRepository, generationService); + + await Should.ThrowAsync(() => service.FinalizeMappingReviewAsync(formVersionId)); + + await generationService.DidNotReceive().SubmitAsync( + Arg.Any(), Arg.Any()); + } + + private static GenerationReview CreateReview( + Guid formVersionId, + System.Collections.Generic.List suggestions) + { + var review = new GenerationReview( + Guid.NewGuid(), + AIGenerationOperations.FormMapping, + formVersionId); + review.SetReviewData(JsonSerializer.Serialize(new FormMappingReviewPayload + { + PendingSuggestions = suggestions + })); + return review; + } + + private ApplicationFormVersionAppService CreateService( + IRepository repository, + IGenerationReviewRepository reviewRepository, + IAIGenerationAppService? generationService = null, + IWorksheetRepository? worksheetRepository = null, + IWorksheetLinkRepository? worksheetLinkRepository = null) + { + var service = new ApplicationFormVersionAppService( + repository, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For>(), + generationService ?? Substitute.For(), + worksheetRepository ?? Substitute.For(), + Substitute.For>(), + reviewRepository, + worksheetLinkRepository ?? Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For()); + service.LazyServiceProvider = GetRequiredService(); + return service; + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs index d37a007bc9..120c098fff 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs @@ -1,4 +1,4 @@ -using NSubstitute; +using NSubstitute; using Shouldly; using System; using System.Collections.Generic; @@ -8,12 +8,15 @@ using System.Threading.Tasks; using Unity.AI; using Unity.AI.Features; +using Unity.AI.Localization; +using Microsoft.Extensions.Localization; using Unity.AI.Generation; using Unity.AI.Operations; using Unity.AI.Requests; using Unity.AI.Responses; using Unity.Flex.Domain.Worksheets; using Unity.Flex.Worksheets; +using Unity.Flex.Domain.Scoresheets; using Unity.GrantManager.ApplicationForms; using Unity.GrantManager.ApplicationForms.Mapping; using Unity.GrantManager.Applications; @@ -61,6 +64,23 @@ await generationService.Received(1).SubmitAsync( && request.ApplicationFormVersionId == formVersionId)); } + [Fact] + public async Task GenerateMappingAsync_Should_Reject_When_Review_Is_Active() + { + var formVersionId = Guid.NewGuid(); + var repository = Substitute.For>(); + repository.GetAsync(formVersionId).Returns(new ApplicationFormVersion { ApplicationFormId = Guid.NewGuid() }); + var generationService = Substitute.For(); + var reviewRepository = Substitute.For(); + reviewRepository.FindLatestByOperationAndFormVersionAsync(AIGenerationOperations.FormMapping, formVersionId) + .Returns(new GenerationReview(Guid.NewGuid(), AIGenerationOperations.FormMapping, formVersionId)); + var service = CreateService(repository, generationService, generationReviewRepository: reviewRepository); + service.LazyServiceProvider = GetRequiredService(); + + await Should.ThrowAsync(() => service.GenerateMappingAsync(formVersionId)); + await generationService.DidNotReceive().SubmitAsync(Arg.Any(), Arg.Any()); + } + [Fact] public void FormMappingPromptData_Should_UseEmptyObject_When_NoExistingMappingIsAvailable() { @@ -69,6 +89,38 @@ public void FormMappingPromptData_Should_UseEmptyObject_When_NoExistingMappingIs promptData.GetProperty("existingMapping").GetRawText().ShouldBe("{}"); } + [Fact] + public void FormMappingPromptData_Should_Include_Linked_Worksheet_Fields() + { + var promptData = FormMappingPromptDataBuilder.Build(new ApplicationFormMappingReadModelDto + { + Worksheets = + [ + new WorksheetMappingFieldsDto + { + WorksheetName = "Project details", + Fields = + [ + new MappingFieldDto + { + Name = "custom_project_name", + Label = "Project name", + Type = "String", + IsCustom = true + } + ] + } + ] + }); + + promptData.GetProperty("unityData") + .GetProperty("customFields")[0] + .GetProperty("Fields")[0] + .GetProperty("Name") + .GetString() + .ShouldBe("custom_project_name"); + } + [Fact] public async Task GetPendingAiWorksheetAsync_Should_Return_Unpublished_Worksheet_Fields() { @@ -83,12 +135,14 @@ public async Task GetPendingAiWorksheetAsync_Should_Return_Unpublished_Worksheet formVersionRepository.GetAsync(formVersionId).Returns(formVersion); var worksheetRepository = Substitute.For(); worksheetRepository.GetByNameAsync(Arg.Any(), true).Returns(worksheet); + var reviewRepository = CreateActiveWorksheetReviewRepository(formVersionId); var service = CreateService( Substitute.For>(), Substitute.For(), formVersionRepository, - worksheetRepository); + worksheetRepository, + generationReviewRepository: reviewRepository); service.LazyServiceProvider = GetRequiredService(); var result = await service.GetPendingAiWorksheetAsync(formVersionId); @@ -138,6 +192,7 @@ public async Task CreateAiWorksheetDraftAsync_Should_Create_Unlinked_Unpublished var worksheetRepository = Substitute.For(); worksheetRepository.GetByNameAsync(Arg.Any(), true).Returns(worksheet); var customFieldRepository = Substitute.For>(); + var reviewRepository = CreateActiveWorksheetReviewRepository(formVersionId); Worksheet? createdDraft = null; worksheetRepository.InsertAsync(Arg.Do(worksheet => createdDraft = worksheet), true) .Returns(Task.FromResult(null!)); @@ -147,7 +202,8 @@ public async Task CreateAiWorksheetDraftAsync_Should_Create_Unlinked_Unpublished Substitute.For(), formVersionRepository, worksheetRepository, - customFieldRepository); + customFieldRepository, + generationReviewRepository: reviewRepository); service.LazyServiceProvider = GetRequiredService(); var selectedFieldId = worksheet.Sections.Single().Fields.First().Id; @@ -189,6 +245,7 @@ public async Task CreateAiWorksheetDraftAsync_Should_Number_Internal_Name_And_De var worksheetRepository = Substitute.For(); worksheetRepository.GetByNameAsync(Arg.Any(), true).Returns(worksheet); var customFieldRepository = Substitute.For>(); + var reviewRepository = CreateActiveWorksheetReviewRepository(formVersionId); Worksheet? createdDraft = null; worksheetRepository.GetByNameAsync("ai-risk-review", false).Returns(new Worksheet(Guid.NewGuid(), "ai-risk-review", "Existing")); worksheetRepository.GetByNameAsync("ai-risk-review-2", false).Returns((Worksheet?)null); @@ -200,7 +257,8 @@ public async Task CreateAiWorksheetDraftAsync_Should_Number_Internal_Name_And_De Substitute.For(), formVersionRepository, worksheetRepository, - customFieldRepository); + customFieldRepository, + generationReviewRepository: reviewRepository); service.LazyServiceProvider = GetRequiredService(); await service.CreateAiWorksheetDraftAsync(formVersionId, new CreateAiWorksheetDraftDto @@ -216,6 +274,60 @@ public async Task CreateAiWorksheetDraftAsync_Should_Number_Internal_Name_And_De await worksheetRepository.Received(1).DeleteAsync(worksheet, true); } + [Fact] + public async Task DiscardAiScoresheetSuggestionsAsync_Should_Preserve_Scoresheet_With_Instances() + { + var formVersionId = Guid.NewGuid(); + var formId = Guid.NewGuid(); + var formVersionRepository = Substitute.For(); + formVersionRepository.GetAsync(formVersionId).Returns(new ApplicationFormVersion { ApplicationFormId = formId }); + var scoresheet = new Scoresheet(Guid.NewGuid(), "AI Scoresheet", $"ai-form-{formId}-version-{formVersionId}-scoresheet"); + scoresheet.Instances.Add(new Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance(Guid.NewGuid(), scoresheet.Id, Guid.NewGuid(), "FormVersion")); + var scoresheetRepository = Substitute.For(); + scoresheetRepository.GetByNameAsync(Arg.Any(), true).Returns(scoresheet); + var reviewRepository = Substitute.For(); + var scoresheetInstanceRepository = Substitute.For(); + scoresheetInstanceRepository.AnyByScoresheetAsync(scoresheet.Id).Returns(true); + var review = new GenerationReview(Guid.NewGuid(), AIGenerationOperations.FormScoresheet, formVersionId); + reviewRepository.FindLatestByOperationAndFormVersionAsync(AIGenerationOperations.FormScoresheet, formVersionId).Returns(review); + + var service = CreateService(Substitute.For>(), Substitute.For(), formVersionRepository, scoresheetRepository: scoresheetRepository, generationReviewRepository: reviewRepository, scoresheetInstanceRepository: scoresheetInstanceRepository); + service.LazyServiceProvider = GetRequiredService(); + + await Should.ThrowAsync(() => service.DiscardAiScoresheetSuggestionsAsync(formVersionId)); + await scoresheetRepository.DidNotReceive().DeleteAsync(Arg.Any(), Arg.Any()); + } + [Theory] + [InlineData(GenerationReviewStatus.Completed)] + [InlineData(GenerationReviewStatus.Discarded)] + public async Task GetPendingAiWorksheetAsync_Should_Return_Null_When_Review_Is_Not_Active(GenerationReviewStatus status) + { + var formVersionId = Guid.NewGuid(); + var formId = Guid.NewGuid(); + var formVersion = new ApplicationFormVersion { ApplicationFormId = formId }; + var worksheet = BuildAiWorksheet(formId, formVersionId, published: false); + var formVersionRepository = Substitute.For(); + formVersionRepository.GetAsync(formVersionId).Returns(formVersion); + var worksheetRepository = Substitute.For(); + worksheetRepository.GetByNameAsync(Arg.Any(), true).Returns(worksheet); + var reviewRepository = CreateActiveWorksheetReviewRepository(formVersionId); + var review = await reviewRepository.FindLatestByOperationAndFormVersionAsync( + AIGenerationOperations.FormWorksheet, formVersionId); + review!.SetStatus(status); + + var service = CreateService( + Substitute.For>(), + Substitute.For(), + formVersionRepository, + worksheetRepository, + generationReviewRepository: reviewRepository); + service.LazyServiceProvider = GetRequiredService(); + + var result = await service.GetPendingAiWorksheetAsync(formVersionId); + + result.ShouldBeNull(); + } + private static Worksheet BuildAiWorksheet(Guid formId, Guid formVersionId, bool published, int fieldCount = 1) { var worksheet = new Worksheet( @@ -309,7 +421,10 @@ private static ApplicationFormVersionAppService CreateService( IAIGenerationAppService aiGenerationAppService, IApplicationFormVersionRepository? formVersionRepository = null, IWorksheetRepository? worksheetRepository = null, - IRepository? customFieldRepository = null) + IRepository? customFieldRepository = null, + IGenerationReviewRepository? generationReviewRepository = null, + IScoresheetRepository? scoresheetRepository = null, + Unity.Flex.Domain.ScoresheetInstances.IScoresheetInstanceRepository? scoresheetInstanceRepository = null) { var featureChecker = Substitute.For(); var service = new ApplicationFormVersionAppService( @@ -321,9 +436,29 @@ private static ApplicationFormVersionAppService CreateService( Substitute.For(), Substitute.For(), featureChecker, + Substitute.For>(), aiGenerationAppService, worksheetRepository ?? Substitute.For(), - customFieldRepository ?? Substitute.For>()); + customFieldRepository ?? Substitute.For>(), + generationReviewRepository ?? Substitute.For(), + Substitute.For(), + scoresheetRepository ?? Substitute.For(), + Substitute.For(), + scoresheetInstanceRepository ?? Substitute.For()); return service; } + + private static IGenerationReviewRepository CreateActiveWorksheetReviewRepository(Guid formVersionId) + { + var repository = Substitute.For(); + var review = new GenerationReview( + Guid.NewGuid(), + AIGenerationOperations.FormWorksheet, + formVersionId); + repository.FindLatestByOperationAndFormVersionAsync( + AIGenerationOperations.FormWorksheet, + formVersionId) + .Returns(review); + return repository; + } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactAppServiceTests.cs index e375badf33..42c3501e75 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactAppServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactAppServiceTests.cs @@ -300,7 +300,8 @@ await _contactManager.Received(1).CreateAsync( input.RelatedEntityType, input.RelatedEntityId, Arg.Is(ci => - ci.Name == input.Name + ci != null + && ci.Name == input.Name && ci.Title == input.Title && ci.Email == input.Email && ci.HomePhoneNumber == input.HomePhoneNumber @@ -391,7 +392,8 @@ await _contactManager.Received(1).UpdateAsync( entityId, contactId, Arg.Is(ci => - ci.Name == input.Name + ci != null + && ci.Name == input.Name && ci.Title == input.Title && ci.Email == input.Email && ci.HomePhoneNumber == input.HomePhoneNumber diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/Generation/AIGenerationQueueTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/Generation/AIGenerationQueueTests.cs index 5a4775bc3e..b4fb8e5f13 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/Generation/AIGenerationQueueTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/Generation/AIGenerationQueueTests.cs @@ -106,7 +106,9 @@ private static IBackgroundJobManager CreateBackgroundJobManager(List()) .Returns(callInfo => { - jobs.Add(callInfo.Arg()); + var job = callInfo.Arg(); + ArgumentNullException.ThrowIfNull(job); + jobs.Add(job); return Task.FromResult(string.Empty); }); return backgroundJobManager; @@ -141,11 +143,21 @@ private static ApplicationGenerationQueue CreateQueue( asyncQueryableExecuter.FirstOrDefaultAsync( Arg.Any>(), Arg.Any()) - .Returns(callInfo => Task.FromResult(callInfo.Arg>().FirstOrDefault())); + .Returns(callInfo => + { + var operations = callInfo.Arg>(); + ArgumentNullException.ThrowIfNull(operations); + return Task.FromResult(operations.FirstOrDefault()); + }); asyncQueryableExecuter.FirstOrDefaultAsync( Arg.Any>(), Arg.Any()) - .Returns(callInfo => Task.FromResult(callInfo.Arg>().FirstOrDefault())); + .Returns(callInfo => + { + var requests = callInfo.Arg>(); + ArgumentNullException.ThrowIfNull(requests); + return Task.FromResult(requests.FirstOrDefault()); + }); return new ApplicationGenerationQueue( backgroundJobManager, @@ -221,4 +233,4 @@ public ValueTask DisposeAsync() return ValueTask.CompletedTask; } } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Operations/AIExecutionModeResolverTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Operations/AIExecutionModeResolverTests.cs index 9396267b19..b49e567b32 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Operations/AIExecutionModeResolverTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Operations/AIExecutionModeResolverTests.cs @@ -24,7 +24,9 @@ public async Task ResolveMode_Uses_Persisted_Operation_Mode() cancellationToken: Arg.Any()) .Returns(callInfo => { - var predicate = callInfo.Arg>>(); + var predicateExpression = callInfo.Arg>>(); + ArgumentNullException.ThrowIfNull(predicateExpression); + var predicate = predicateExpression.Compile(); return Task.FromResult(new[] { new AIOperation(Guid.NewGuid(), AIPromptTypes.ApplicationScoring, Guid.NewGuid()) @@ -32,7 +34,7 @@ public async Task ResolveMode_Uses_Persisted_Operation_Mode() ExecutionMode = AIExecutionMode.Batch, IsActive = true } - }.Where(predicate.Compile()).ToList()); + }.Where(predicate).ToList()); }); var resolver = new AIExecutionModeResolver(repository); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Operations/ApplicationScoringServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Operations/ApplicationScoringServiceTests.cs index a5911fb7e7..a3e154d85e 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Operations/ApplicationScoringServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Operations/ApplicationScoringServiceTests.cs @@ -35,7 +35,9 @@ public async Task RegenerateAsync_Sequential_Mode_Uses_Per_Section_Requests() cancellationToken: Arg.Any()) .Returns(callInfo => { - var predicate = callInfo.Arg>>(); + var predicateExpression = callInfo.Arg>>(); + ArgumentNullException.ThrowIfNull(predicateExpression); + var predicate = predicateExpression.Compile(); return Task.FromResult(new[] { new AIOperation(Guid.NewGuid(), AIPromptTypes.ApplicationScoring, Guid.NewGuid()) @@ -43,7 +45,7 @@ public async Task RegenerateAsync_Sequential_Mode_Uses_Per_Section_Requests() ExecutionMode = AIExecutionMode.Sequential, IsActive = true } - }.Where(predicate.Compile()).ToList()); + }.Where(predicate).ToList()); }); var service = new ApplicationScoringService( diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Runtime/Execution/AIProviderPayloadValidatorTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Runtime/Execution/AIProviderPayloadValidatorTests.cs index fbb0b5a392..b63570adb1 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Runtime/Execution/AIProviderPayloadValidatorTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Runtime/Execution/AIProviderPayloadValidatorTests.cs @@ -52,7 +52,7 @@ public void ValidateApplicationScoringJson_Should_Return_InvalidOutput_When_Answ result.IsValid.ShouldBeFalse(); result.FailureCategory.ShouldBe(AIFailureCategory.InvalidOutput); - result.Reason.ShouldContain("q1"); + result.Reason!.ShouldContain("q1"); } [Fact] @@ -96,7 +96,7 @@ public void ValidateApplicationAnalysisJson_Should_Return_InvalidOutput_When_Dec result.IsValid.ShouldBeFalse(); result.FailureCategory.ShouldBe(AIFailureCategory.InvalidOutput); - result.Reason.ShouldContain("Expected 'PROCEED' or 'HOLD'"); + result.Reason!.ShouldContain("Expected 'PROCEED' or 'HOLD'"); } [Fact] @@ -223,6 +223,29 @@ public void ValidateFormScoresheetJson_Should_Allow_Empty_Optional_Reporting_Fie result.IsValid.ShouldBeTrue(); } + [Fact] + public void ValidateFormScoresheetJson_Should_Allow_Mixed_Property_Casing() + { + var response = ValidFormScoresheetJson + .Replace("\"Title\"", "\"title\"", StringComparison.Ordinal) + .Replace("\"Name\"", "\"name\"", StringComparison.Ordinal) + .Replace("\"Version\"", "\"version\"", StringComparison.Ordinal) + .Replace("\"Order\"", "\"order\"", StringComparison.Ordinal) + .Replace("\"Published\"", "\"published\"", StringComparison.Ordinal) + .Replace("\"Sections\"", "\"sections\"", StringComparison.Ordinal) + .Replace("\"Fields\"", "\"fields\"", StringComparison.Ordinal) + .Replace("\"ReportColumns\"", "\"reportColumns\"", StringComparison.Ordinal) + .Replace("\"ReportKeys\"", "\"reportKeys\"", StringComparison.Ordinal) + .Replace("\"ReportViewName\"", "\"reportViewName\"", StringComparison.Ordinal) + .Replace("\"Description\"", "\"description\"", StringComparison.Ordinal) + .Replace("\"Type\"", "\"type\"", StringComparison.Ordinal) + .Replace("\"Definition\"", "\"definition\"", StringComparison.Ordinal); + + var result = AIProviderPayloadValidator.ValidateFormScoresheetJson(response); + + result.IsValid.ShouldBeTrue(); + } + [Theory] [InlineData("\"Title\": \"Generated scoresheet\"", "\"Title\": \"\"")] [InlineData("\"Version\": 1", "\"Version\": \"1\"")] @@ -247,7 +270,7 @@ public void ValidateFormScoresheetJson_Should_Return_InvalidOutput_For_Duplicate var result = AIProviderPayloadValidator.ValidateFormScoresheetJson(response); result.IsValid.ShouldBeFalse(); - result.Reason.ShouldContain("duplicate field names"); + result.Reason!.ShouldContain("duplicate field names"); } [Fact] @@ -261,7 +284,7 @@ public void ValidateFormScoresheetJson_Should_Return_InvalidOutput_For_Duplicate var result = AIProviderPayloadValidator.ValidateFormScoresheetJson(response); result.IsValid.ShouldBeFalse(); - result.Reason.ShouldContain("duplicate section names"); + result.Reason!.ShouldContain("duplicate section names"); } private const string ValidFormScoresheetJson = """