diff --git a/addin/lambda-boss.Tests/LetToLambdaBuilderTests.cs b/addin/lambda-boss.Tests/LetToLambdaBuilderTests.cs index 07410b4..1caaf8e 100644 --- a/addin/lambda-boss.Tests/LetToLambdaBuilderTests.cs +++ b/addin/lambda-boss.Tests/LetToLambdaBuilderTests.cs @@ -22,6 +22,16 @@ private static string BuildWithOptional(string formula, string lambdaName, return LetToLambdaBuilder.Build(new LambdaGenerationRequest(lambdaName, parsed, inputs)); } + private static string BuildWithDefault(string formula, string lambdaName, + params (string name, string paramName, bool keep, bool isOptional, string? defaultExpr)[] choices) + { + var parsed = LetParser.Parse(formula); + var inputs = choices + .Select(c => new InputChoice(c.name, c.paramName, c.keep, c.isOptional, c.defaultExpr)) + .ToList(); + return LetToLambdaBuilder.Build(new LambdaGenerationRequest(lambdaName, parsed, inputs)); + } + private static string Lines(params string[] lines) => string.Join("\n", lines); [Fact] @@ -369,6 +379,78 @@ public void OptionalWithReorderAndRename_WorksTogether() ")"), result); } + [Fact] + public void OptionalWithCustomDefault_UsesTypedDefault() + { + // The original RHS is A1, but the user overrides the default to 0. + var result = BuildWithDefault("=LET(x, 10, y, A1, x + y)", "Adder", + ("x", "x", true, false, null), ("y", "offset", true, true, "0")); + + Assert.Equal(Lines( + "=LAMBDA(", + " x,", + " [offset],", + " LET(", + " offset, IF(ISOMITTED(offset), 0, offset),", + " x + offset", + " )", + ")"), result); + } + + [Fact] + public void OptionalWithCustomDefault_CellRefAbsolutized() + { + // A cell ref typed as a custom default is absolutized like any other. + var result = BuildWithDefault("=LET(x, 10, y, A1, x + y)", "Adder", + ("x", "x", true, false, null), ("y", "offset", true, true, "B2")); + + Assert.Equal(Lines( + "=LAMBDA(", + " x,", + " [offset],", + " LET(", + " offset, IF(ISOMITTED(offset), $B$2, offset),", + " x + offset", + " )", + ")"), result); + } + + [Fact] + public void OptionalWithBlankCustomDefault_FallsBackToOriginalRhs() + { + // A null/blank custom default preserves the pre-edit behaviour. + var result = BuildWithDefault("=LET(x, 10, y, A1, x + y)", "Adder", + ("x", "x", true, false, null), ("y", "offset", true, true, " ")); + + Assert.Equal(Lines( + "=LAMBDA(", + " x,", + " [offset],", + " LET(", + " offset, IF(ISOMITTED(offset), $A$1, offset),", + " x + offset", + " )", + ")"), result); + } + + [Fact] + public void OptionalWithCustomDefault_AppliesRenames() + { + // The typed default references binding x, which is renamed to base. + var result = BuildWithDefault("=LET(x, 5, y, 0, x + y)", "Calc", + ("x", "base", true, false, null), ("y", "offset", true, true, "x + 1")); + + Assert.Equal(Lines( + "=LAMBDA(", + " base,", + " [offset],", + " LET(", + " offset, IF(ISOMITTED(offset), base + 1, offset),", + " base + offset", + " )", + ")"), result); + } + [Fact] public void OptionalOnUncheckedRow_Throws() { diff --git a/addin/lambda-boss/LetToLambdaBuilder.cs b/addin/lambda-boss/LetToLambdaBuilder.cs index 9f02dcc..5bd8abe 100644 --- a/addin/lambda-boss/LetToLambdaBuilder.cs +++ b/addin/lambda-boss/LetToLambdaBuilder.cs @@ -6,11 +6,18 @@ namespace LambdaBoss; /// /// User's decision for a single LET binding whose RHS is a value. /// +/// +/// Custom default for an optional param, as typed by the user in the +/// dialog. When null or blank the builder falls back to the binding's +/// original RHS, preserving the pre-edit behaviour. Only consulted when +/// is true. +/// public record InputChoice( string OriginalBindingName, string ParamName, bool Keep, - bool IsOptional = false); + bool IsOptional = false, + string? DefaultExpression = null); public record LambdaGenerationRequest( string LambdaName, @@ -99,13 +106,10 @@ public static string Build(LambdaGenerationRequest request) .ToList(); // Optional bindings wrap each optional kept param with an - // IF(ISOMITTED(...)) defaulting to the original RHS (with renames). - // They appear before internal bindings so internal bindings can - // reference the defaulted value. - // Optional bindings wrap each optional kept param with an - // IF(ISOMITTED(...)) defaulting to the original RHS (with renames). - // They appear before internal bindings so internal bindings can - // reference the defaulted value. Cell references in the default are + // IF(ISOMITTED(...)) defaulting to the user's custom default (or the + // original RHS when none was supplied), with renames applied. They + // appear before internal bindings so internal bindings can reference + // the defaulted value. Cell references in the default are // forced absolute: when Excel stores a LAMBDA as a workbook Name, // relative refs shift by the offset between the active cell at // registration time and the calling cell, which in practice baked @@ -113,10 +117,18 @@ public static string Build(LambdaGenerationRequest request) // regardless of where the LAMBDA is invoked. var optionalBindings = kept .Where(k => k.Choice.IsOptional) - .Select(k => new LetBinding( - k.Choice.ParamName, - $"IF(ISOMITTED({k.Choice.ParamName}), {AbsolutizeCellRefs(ApplyRenames(k.Binding.RhsText, renames))}, {k.Choice.ParamName})", - IsCalculation: false)) + .Select(k => + { + // A blank custom default falls back to the original RHS so the + // builder behaves exactly as before when no edit was made. + var defaultExpr = string.IsNullOrWhiteSpace(k.Choice.DefaultExpression) + ? k.Binding.RhsText + : k.Choice.DefaultExpression!; + return new LetBinding( + k.Choice.ParamName, + $"IF(ISOMITTED({k.Choice.ParamName}), {AbsolutizeCellRefs(ApplyRenames(defaultExpr, renames))}, {k.Choice.ParamName})", + IsCalculation: false); + }) .ToList(); var body = ApplyRenames(parsed.Body, renames); diff --git a/addin/lambda-boss/UI/LetToLambdaWindow.xaml b/addin/lambda-boss/UI/LetToLambdaWindow.xaml index dc28aa7..e4a3d7b 100644 --- a/addin/lambda-boss/UI/LetToLambdaWindow.xaml +++ b/addin/lambda-boss/UI/LetToLambdaWindow.xaml @@ -103,9 +103,10 @@ Foreground="#cccccc" VerticalAlignment="Center" Margin="0,0,8,0" - ToolTip="Caller may omit this argument; defaults to the original RHS expression" /> + ToolTip="Caller may omit this argument; edit the default it falls back to" /> + - /// Display form of used by the row template. - /// When the row is marked optional, the RHS becomes the default - /// expression in the generated LAMBDA, so we prefix "default:" to - /// make that role explicit. + /// The default expression for an optional param, editable in the row + /// template when is set. Seeded with the + /// original RHS so the dialog opens showing the existing behaviour. /// - public string RhsPreviewDisplay => IsOptional ? $"default: {RhsPreview}" : RhsPreview; + public string DefaultExpression + { + get => _defaultExpression; + set + { + _defaultExpression = value; + OnChanged(); + } + } + + /// + /// The read-only RHS preview is shown only while the row is not + /// optional; when optional, the editable default box takes its place. + /// + public bool ShowRhsPreview => !IsOptional; /// /// Zero-based position in the original LET source order. Used to keep @@ -54,7 +68,7 @@ public bool Keep { _isOptional = false; OnChanged(nameof(IsOptional)); - OnChanged(nameof(RhsPreviewDisplay)); + OnChanged(nameof(ShowRhsPreview)); } OnChanged(); } @@ -68,7 +82,7 @@ public bool IsOptional if (_isOptional == value) return; _isOptional = value; OnChanged(); - OnChanged(nameof(RhsPreviewDisplay)); + OnChanged(nameof(ShowRhsPreview)); } } @@ -114,6 +128,7 @@ public LetToLambdaWindow(ParsedLet parsed, Func resolveExisting BindingName = b.Name, ParamName = b.Name, RhsPreview = b.RhsText, + DefaultExpression = b.RhsText, Keep = true, SourceIndex = i })); @@ -314,6 +329,15 @@ private void UpdateSaveEnabled() return; } + var blankDefault = keptRows + .FirstOrDefault(r => r.IsOptional && string.IsNullOrWhiteSpace(r.DefaultExpression)); + if (blankDefault != null) + { + StatusText.Text = $"Optional input '{blankDefault.ParamName}' needs a default value."; + SaveButton.IsEnabled = false; + return; + } + var retainedBindingNames = _parsed.Bindings .Where(b => b.IsCalculation || _rows.Any(r => r.BindingName == b.Name && !r.Keep)) .Select(b => b.Name) @@ -355,7 +379,12 @@ private void HideNameError() private void SaveButton_Click(object sender, RoutedEventArgs e) { var inputs = _rows - .Select(r => new InputChoice(r.BindingName, r.ParamName.Trim(), r.Keep, r.IsOptional)) + .Select(r => new InputChoice( + r.BindingName, + r.ParamName.Trim(), + r.Keep, + r.IsOptional, + r.IsOptional ? r.DefaultExpression.Trim() : null)) .ToList(); Result = new LambdaGenerationRequest(LambdaNameBox.Text.Trim(), _parsed, inputs);