|
@@ -0,0 +1,334 @@
|
|
|
|
|
+using Xunit;
|
|
|
|
|
+
|
|
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Tests.S6.Manufacturing;
|
|
|
|
|
+
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// 前端源码文本契约的公共视图:**去注释**后再断言。
|
|
|
|
|
+/// 缘由:这些页面的注释里会成段引用节点码(N1_SUBMIT)、字段名(flowStatus / canSubmitReview)
|
|
|
|
|
+/// 来解释"为什么不这么做",裸文本 DoesNotContain 会把说明文字当成违规实现命中。
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+internal static class VueSource
|
|
|
|
|
+{
|
|
|
|
|
+ /// <summary>剥掉 /* */ 块注释、<!-- --> HTML 注释、以及整行 // 与 * 续行注释。</summary>
|
|
|
|
|
+ public static string CodeOnly(string src)
|
|
|
|
|
+ {
|
|
|
|
|
+ var s = System.Text.RegularExpressions.Regex.Replace(src, @"/\*.*?\*/", "", System.Text.RegularExpressions.RegexOptions.Singleline);
|
|
|
|
|
+ s = System.Text.RegularExpressions.Regex.Replace(s, @"<!--.*?-->", "", System.Text.RegularExpressions.RegexOptions.Singleline);
|
|
|
|
|
+ var kept = s.Split('\n').Where(l =>
|
|
|
|
|
+ {
|
|
|
|
|
+ var t = l.TrimStart();
|
|
|
|
|
+ return !t.StartsWith("//", StringComparison.Ordinal) && !t.StartsWith("* ", StringComparison.Ordinal) && t != "*";
|
|
|
|
|
+ });
|
|
|
|
|
+ return string.Join("\n", kept);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// S6-IPQC-EDIT-1 源码契约测试:IPQC 检查单详情页「编辑权限」归后端 Authority。
|
|
|
|
|
+///
|
|
|
|
|
+/// 缺口:`ReturnToPrev` 后实例仍是 Running(只回退 CurrentNodeId),而详情页把编辑权限写成
|
|
|
|
|
+/// 本地表达式 `flowStatus !== 'NotStarted' → 只读`,于是主管退回回到 N1 后录入区仍锁死 ——
|
|
|
|
|
+/// 后端已允许重新提交(canSubmitReview=true),前端却不允许改数据,形成"能提交但改不动"。
|
|
|
|
|
+///
|
|
|
|
|
+/// 修法:后端 `S6ReviewStateOutput.CanEdit`(复用与 canSubmitReview 同一份 N1 待办归属判定,
|
|
|
|
|
+/// 但不叠加 completed),前端纯消费、状态缺失时 fail-closed。
|
|
|
|
|
+///
|
|
|
|
|
+/// 关键不变量:
|
|
|
|
|
+/// 1. canEdit ≠ canSubmitReview(后者多一个 completed 条件)——不得互相顶替;
|
|
|
|
|
+/// 2. 授权不能只看节点,必须节点 + Pending + AssigneeId==当前用户;
|
|
|
|
|
+/// 3. 首次录入(NotStarted)不得被本次修复关掉;
|
|
|
|
|
+/// 4. 前端不得保留任何本地流程推断。
|
|
|
|
|
+///
|
|
|
|
|
+/// 沿用本仓既有做法——源码契约断言而非行为级集成测试(service 依赖 ISqlSugarClient /
|
|
|
|
|
+/// FlowEngineService,裸 xUnit 进程无法实例化)。真实状态矩阵由本批 Runtime R1–R12 覆盖。
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public class S6ProcessInspectionEditAuthorityContractTests
|
|
|
|
|
+{
|
|
|
|
|
+ private static string Svc() => File.ReadAllText(FindFile(
|
|
|
|
|
+ "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "S6ProcessInspectionReviewService.cs"));
|
|
|
|
|
+
|
|
|
|
|
+ private static string Dto() => File.ReadAllText(FindFile(
|
|
|
|
|
+ "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "Dto", "S6ProcessInspectionReviewDto.cs"));
|
|
|
|
|
+
|
|
|
|
|
+ private static string DetailPage() => File.ReadAllText(FindFile(
|
|
|
|
|
+ "Web", "src", "views", "aidop", "s6", "process-quality", "ipqcBillDetail.vue"));
|
|
|
|
|
+
|
|
|
|
|
+ private static string ItemPanel() => File.ReadAllText(FindFile(
|
|
|
|
|
+ "Web", "src", "views", "aidop", "s6", "process-quality", "components", "IpqcBillItemPanel.vue"));
|
|
|
|
|
+
|
|
|
|
|
+ private static string ApiTypes() => File.ReadAllText(FindFile(
|
|
|
|
|
+ "Web", "src", "views", "aidop", "s6", "api", "productionInstruction.ts"));
|
|
|
|
|
+
|
|
|
|
|
+ // ── 后端必须成为唯一 authority ──
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>capability 必须落在只读状态 DTO 上(与其余 4 个按钮门控同一处)。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Dto_ExposesCanEditCapability()
|
|
|
|
|
+ {
|
|
|
|
|
+ Assert.Contains("public bool CanEdit { get; set; }", Dto());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>编辑权限必须在 BuildStateAsync 里算,不得散落到 Controller / 前端。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void State_ComputesCanEditInBuildState()
|
|
|
|
|
+ {
|
|
|
|
|
+ var body = BuildStateBody();
|
|
|
|
|
+ Assert.Contains("output.CanEdit", body);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── §30 状态矩阵 ──
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>NotStarted:首次录入必须可编辑,且**不受 completed 约束**(否则录完即锁死)。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Matrix_NotStarted_IsEditable()
|
|
|
|
|
+ {
|
|
|
|
|
+ var branch = Slice(BuildStateBody(), "output.FlowStatus = \"NotStarted\";", "return output;");
|
|
|
|
|
+ Assert.Contains("output.CanEdit = true;", branch);
|
|
|
|
|
+ Assert.DoesNotContain("output.CanEdit = completed", branch);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>退回到 N1 且本人持有该待办:可编辑,且同样不叠加 completed。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Matrix_ReturnedToN1_IsEditable()
|
|
|
|
|
+ {
|
|
|
|
|
+ var branch = ReturnedBranch();
|
|
|
|
|
+ Assert.Contains("output.CanEdit = true;", branch);
|
|
|
|
|
+ Assert.DoesNotContain("output.CanEdit = completed", branch);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>终态(Approved / Rejected / Cancelled / Terminated):显式不可编辑。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Matrix_Terminal_IsReadOnly()
|
|
|
|
|
+ {
|
|
|
|
|
+ var branch = Slice(BuildStateBody(),
|
|
|
|
|
+ "if (inst.Status != FlowInstanceStatusEnum.Running)", "output.CurrentNodeCode = inst.CurrentNodeId;");
|
|
|
|
|
+ Assert.Contains("output.CanEdit = false;", branch);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// N2 主管审核 / N3 质量处置 / 本人无待办三条路径必须靠 bool 默认 false 兜底 ——
|
|
|
|
|
+ /// 即这些分支里不得出现任何把 CanEdit 打开的赋值。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Theory]
|
|
|
|
|
+ [InlineData("if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeSupervisor)", "else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeDisposition)")]
|
|
|
|
|
+ [InlineData("else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeDisposition)", "else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeSubmit)")]
|
|
|
|
|
+ [InlineData("output.Message = \"流程进行中(当前用户无待办)\";", "return output;")]
|
|
|
|
|
+ public void Matrix_NonEditableBranches_NeverOpenCanEdit(string from, string to)
|
|
|
|
|
+ {
|
|
|
|
|
+ var branch = Slice(BuildStateBody(), from, to);
|
|
|
|
|
+ Assert.DoesNotContain("output.CanEdit = true", branch);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>CanEdit 在整个 service 里只有三处赋值(NotStarted / 终态 / 退回 N1),防止后续被到处打开。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void CanEdit_HasExactlyThreeAssignments()
|
|
|
|
|
+ {
|
|
|
|
|
+ Assert.Equal(3, CountOf(Svc(), "output.CanEdit"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── §8 授权不能只看节点 ──
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>退回态的可编辑判定必须建立在「本人持有的 Pending 待办」之上,不是只看 CurrentNodeId。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void EditAuthority_RequiresOwnedPendingTaskNotJustNode()
|
|
|
|
|
+ {
|
|
|
|
|
+ var body = BuildStateBody();
|
|
|
|
|
+ // 分支入口本身就由 myTask 决定
|
|
|
|
|
+ Assert.Contains("var myTask = await GetMyPendingTaskAsync(inst.Id);", body);
|
|
|
|
|
+ Assert.Contains("if (myTask != null)", body);
|
|
|
|
|
+ Assert.Contains("else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeSubmit)", body);
|
|
|
|
|
+ // 不得出现「只按实例当前节点」开放编辑的写法
|
|
|
|
|
+ Assert.DoesNotContain("inst.CurrentNodeId == S6ProcessInspectionFlowConst.NodeSubmit)\n output.CanEdit", body);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>待办归属必须按当前用户 + Pending 过滤(与重提复用同一 helper)。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void PendingTaskHelper_ScopedToCurrentUserAndPending()
|
|
|
|
|
+ {
|
|
|
|
|
+ var helper = Slice(Svc(), "private async Task<ApprovalFlowTask?> GetMyPendingTaskAsync", "private async Task<(ApprovalFlowInstance");
|
|
|
|
|
+ Assert.Contains("_userManager.UserId", helper);
|
|
|
|
|
+ Assert.Contains("x.AssigneeId == userId", helper);
|
|
|
|
|
+ Assert.Contains("FlowTaskStatusEnum.Pending", helper);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>租户收口:state 入口先走 LoadBillAsync,且 BuildStateAsync 的单据查询带 tenant_id。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void State_IsTenantScoped()
|
|
|
|
|
+ {
|
|
|
|
|
+ var s = Svc();
|
|
|
|
|
+ Assert.Contains("await LoadBillAsync(billId);", s);
|
|
|
|
|
+ Assert.Contains("var tid = ResolveTenantOrThrow();", BuildStateBody());
|
|
|
|
|
+ Assert.Contains("WHERE b.id=@id AND b.tenant_id=@tid", BuildStateBody());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── §10 两个 capability 语义不等价 ──
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// canEdit 与 canSubmitReview 不得等价:退回分支里 canSubmitReview 仍带 completed 门槛,
|
|
|
|
|
+ /// canEdit 不带 —— 这正是"退回后开始改结果(ResultStatus 回到 IN_PROGRESS)仍要能改"的依据。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void CanEdit_IsNotAliasOfCanSubmitReview()
|
|
|
|
|
+ {
|
|
|
|
|
+ var branch = ReturnedBranch();
|
|
|
|
|
+ Assert.Contains("output.CanEdit = true;", branch);
|
|
|
|
|
+ Assert.Contains("output.CanSubmitReview = completed;", branch);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>后端不得把 canEdit 直接写成 canSubmitReview 的别名。</summary>
|
|
|
|
|
+ [Theory]
|
|
|
|
|
+ [InlineData("output.CanEdit = output.CanSubmitReview")]
|
|
|
|
|
+ [InlineData("output.CanEdit = completed")]
|
|
|
|
|
+ public void CanEdit_NotDerivedFromSubmitCapability(string forbidden)
|
|
|
|
|
+ {
|
|
|
|
|
+ Assert.DoesNotContain(forbidden, Svc());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── §31 Reject→N1 契约冻结(上游语义不得被改坏)──
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>退回必须保持实例 Running + 回到 N1,否则 canEdit 的退回分支永远进不到。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void SupervisorReturn_KeepsInstanceRunningAtN1()
|
|
|
|
|
+ {
|
|
|
|
|
+ var engine = File.ReadAllText(FindFile(
|
|
|
|
|
+ "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
|
|
|
|
|
+ var body = Slice(engine, "public async Task ReturnToPrev(long taskId, string? comment)", "/// <summary>\n /// 加签");
|
|
|
|
|
+ Assert.Contains("instance.CurrentNodeId = prevNodeId;", body);
|
|
|
|
|
+ Assert.Contains("CreateTasksForNode(instance, flowData, prevNodeId)", body);
|
|
|
|
|
+ Assert.DoesNotContain("instance.Status = FlowInstanceStatusEnum.Rejected", body);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>退回后展示状态仍须是业务中文(后端拥有语义,前端不拼)。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void ReturnedBranch_KeepsBackendOwnedDisplayStatus()
|
|
|
|
|
+ {
|
|
|
|
|
+ Assert.Contains("output.DisplayStatus = \"已退回待重新提交\";", ReturnedBranch());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── 前端:纯消费 + fail-closed ──
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>详情页必须直接消费后端字段。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Frontend_ConsumesBackendCanEdit()
|
|
|
|
|
+ {
|
|
|
|
|
+ Assert.Contains("review.value?.canEdit === true", DetailPage());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>状态缺失(加载中 / 接口失败)时必须 fail-closed —— 旧实现在 review 为空时是放开的。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Frontend_FailsClosedWhenStateMissing()
|
|
|
|
|
+ {
|
|
|
|
|
+ var s = DetailPage();
|
|
|
|
|
+ Assert.DoesNotContain("!(fs && fs !== 'NotStarted')", s);
|
|
|
|
|
+ Assert.DoesNotContain("review.value?.canEdit !== false", s);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// 前端不得保留任何本地流程推断作为编辑权限来源。
|
|
|
|
|
+ /// 断言只作用于 canEdit 那一行本身 —— 页面别处按 capability 显示按钮(hasAnyAction 聚合
|
|
|
|
|
+ /// canSubmitReview 等)是正当用法,不能一并禁掉。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Theory]
|
|
|
|
|
+ [InlineData("flowStatus")]
|
|
|
|
|
+ [InlineData("currentNodeCode")]
|
|
|
|
|
+ [InlineData("canSubmitReview")]
|
|
|
|
|
+ [InlineData("N1_SUBMIT")]
|
|
|
|
|
+ public void Frontend_CanEditExpressionHasNoLocalFlowInference(string forbidden)
|
|
|
|
|
+ {
|
|
|
|
|
+ var line = VueSource.CodeOnly(DetailPage())
|
|
|
|
|
+ .Split('\n').Single(l => l.Contains("const canEdit", StringComparison.Ordinal));
|
|
|
|
|
+ Assert.DoesNotContain(forbidden, line);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>整页不得残留旧的本地推断表达式。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void Frontend_OldLocalExpressionIsGone()
|
|
|
|
|
+ {
|
|
|
|
|
+ Assert.DoesNotContain("!(fs && fs !== 'NotStarted')", VueSource.CodeOnly(DetailPage()));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>TS 类型须同步补齐,否则 vue-tsc 下 canEdit 会是隐式 any/报错。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void FrontendTypes_DeclareCanEdit()
|
|
|
|
|
+ {
|
|
|
|
|
+ Assert.Contains("canEdit?: boolean;", ApiTypes());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>
|
|
|
|
|
+ /// §12:录入区的输入框与保存/完成页脚必须由**同一个** canEdit 控制 ——
|
|
|
|
|
+ /// 不允许出现"按钮 disabled 但 input 还能改"。
|
|
|
|
|
+ /// </summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void ItemPanel_SingleEditSwitchGovernsInputsAndFooter()
|
|
|
|
|
+ {
|
|
|
|
|
+ var s = VueSource.CodeOnly(ItemPanel());
|
|
|
|
|
+ Assert.Contains(":disabled=\"!canEdit\"", s);
|
|
|
|
|
+ Assert.Contains("v-if=\"canEdit\" class=\"panel-foot\"", s);
|
|
|
|
|
+ // 组件内不得自己看流程(去注释后断言:注释里成段解释"不按 flowStatus 推断",不算实现)
|
|
|
|
|
+ Assert.DoesNotContain("flowStatus", s);
|
|
|
|
|
+ Assert.DoesNotContain("currentNodeCode", s);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>子组件 canEdit 默认必须是 false(fail-closed)。</summary>
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void ItemPanel_DefaultsToReadOnly()
|
|
|
|
|
+ {
|
|
|
|
|
+ Assert.Contains("canEdit: false", ItemPanel());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── 范围守卫:本轮不得动审批流框架 ──
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>编辑权限不得改成前端可传入 —— state 是只读输出,不接受回传判定。</summary>
|
|
|
|
|
+ [Theory]
|
|
|
|
|
+ [InlineData("CanEdit { get; set; }", "S6ReviewStateOutput")]
|
|
|
|
|
+ public void CanEdit_LivesOnlyOnReadOnlyStateOutput(string decl, string owner)
|
|
|
|
|
+ {
|
|
|
|
|
+ var dto = Dto();
|
|
|
|
|
+ var cls = Slice(dto, $"public class {owner}", "public class S6DispositionView");
|
|
|
|
|
+ Assert.Contains(decl, cls);
|
|
|
|
|
+ // 入参 DTO 一律不得带 CanEdit
|
|
|
|
|
+ foreach (var input in new[] { "S6SubmitReviewInput", "S6ReviewApproveInput", "S6ReviewReturnInput", "S6QeDispositionInput" })
|
|
|
|
|
+ {
|
|
|
|
|
+ var start = dto.IndexOf($"public class {input}", StringComparison.Ordinal);
|
|
|
|
|
+ Assert.True(start >= 0, $"未找到入参类 {input}");
|
|
|
|
|
+ var end = dto.IndexOf("public class ", start + input.Length + 14, StringComparison.Ordinal);
|
|
|
|
|
+ var slice = end > start ? dto[start..end] : dto[start..];
|
|
|
|
|
+ Assert.DoesNotContain("CanEdit", slice);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static string BuildStateBody() => Slice(Svc(),
|
|
|
|
|
+ "private async Task<S6ReviewStateOutput> BuildStateAsync(long billId)",
|
|
|
|
|
+ "// ── Phase 1E 展示辅助");
|
|
|
|
|
+
|
|
|
|
|
+ private static string ReturnedBranch() => Slice(BuildStateBody(),
|
|
|
|
|
+ "else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeSubmit)",
|
|
|
|
|
+ " else\n {");
|
|
|
|
|
+
|
|
|
|
|
+ private static int CountOf(string src, string needle)
|
|
|
|
|
+ {
|
|
|
|
|
+ int n = 0, i = 0;
|
|
|
|
|
+ while ((i = src.IndexOf(needle, i, StringComparison.Ordinal)) >= 0) { n++; i += needle.Length; }
|
|
|
|
|
+ return n;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static string Slice(string src, string from, string to)
|
|
|
|
|
+ {
|
|
|
|
|
+ var a = src.IndexOf(from, StringComparison.Ordinal);
|
|
|
|
|
+ Assert.True(a >= 0, $"未找到起点:{from}");
|
|
|
|
|
+ var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal);
|
|
|
|
|
+ return b > a ? src[a..b] : src[a..];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static string FindFile(params string[] parts)
|
|
|
|
|
+ {
|
|
|
|
|
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
|
|
|
|
+ while (dir != null)
|
|
|
|
|
+ {
|
|
|
|
|
+ var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
|
|
|
|
|
+ if (File.Exists(candidate)) return candidate;
|
|
|
|
|
+ dir = dir.Parent;
|
|
|
|
|
+ }
|
|
|
|
|
+ throw new FileNotFoundException(string.Join("/", parts));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|