Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions addin/lambda-boss.Tests/LetToLambdaBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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()
{
Expand Down
36 changes: 24 additions & 12 deletions addin/lambda-boss/LetToLambdaBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@ namespace LambdaBoss;
/// <summary>
/// User's decision for a single LET binding whose RHS is a value.
/// </summary>
/// <param name="DefaultExpression">
/// 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
/// <paramref name="IsOptional" /> is true.
/// </param>
public record InputChoice(
string OriginalBindingName,
string ParamName,
bool Keep,
bool IsOptional = false);
bool IsOptional = false,
string? DefaultExpression = null);

public record LambdaGenerationRequest(
string LambdaName,
Expand Down Expand Up @@ -99,24 +106,29 @@ 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
// wrong defaults into the LAMBDA. Absolute refs resolve the same
// 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);
Expand Down
21 changes: 19 additions & 2 deletions addin/lambda-boss/UI/LetToLambdaWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -103,16 +103,33 @@
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" />
<TextBlock DockPanel.Dock="Right"
Text="{Binding RhsPreviewDisplay}"
Text="{Binding RhsPreview}"
Visibility="{Binding ShowRhsPreview, Converter={StaticResource BoolToVis}}"
Foreground="#808080"
FontFamily="Consolas"
FontSize="12"
VerticalAlignment="Center"
Margin="8,0,0,0"
TextTrimming="CharacterEllipsis"
ToolTip="{Binding RhsPreview}" />
<TextBox DockPanel.Dock="Right"
dd:DragDrop.DragSourceIgnore="True"
Visibility="{Binding IsOptional, Converter={StaticResource BoolToVis}}"
Text="{Binding DefaultExpression, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Width="170"
Padding="4,2"
FontFamily="Consolas"
FontSize="12"
Background="#2d2d2d"
Foreground="#cccccc"
CaretBrush="#cccccc"
BorderBrush="#3e3e3e"
BorderThickness="1"
VerticalAlignment="Center"
Margin="8,0,0,0"
ToolTip="Default value used when the caller omits this argument" />
<TextBox dd:DragDrop.DragSourceIgnore="True"
Text="{Binding ParamName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding Keep}"
Expand Down
45 changes: 37 additions & 8 deletions addin/lambda-boss/UI/LetToLambdaWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,31 @@ public class LetInputRow : INotifyPropertyChanged
private bool _isOptional;
private bool _keep = true;
private string _paramName = "";
private string _defaultExpression = "";

public string BindingName { get; set; } = "";
public string RhsPreview { get; set; } = "";

/// <summary>
/// Display form of <see cref="RhsPreview" /> 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 <see cref="IsOptional" /> is set. Seeded with the
/// original RHS so the dialog opens showing the existing behaviour.
/// </summary>
public string RhsPreviewDisplay => IsOptional ? $"default: {RhsPreview}" : RhsPreview;
public string DefaultExpression
{
get => _defaultExpression;
set
{
_defaultExpression = value;
OnChanged();
}
}

/// <summary>
/// The read-only RHS preview is shown only while the row is not
/// optional; when optional, the editable default box takes its place.
/// </summary>
public bool ShowRhsPreview => !IsOptional;

/// <summary>
/// Zero-based position in the original LET source order. Used to keep
Expand Down Expand Up @@ -54,7 +68,7 @@ public bool Keep
{
_isOptional = false;
OnChanged(nameof(IsOptional));
OnChanged(nameof(RhsPreviewDisplay));
OnChanged(nameof(ShowRhsPreview));
}
OnChanged();
}
Expand All @@ -68,7 +82,7 @@ public bool IsOptional
if (_isOptional == value) return;
_isOptional = value;
OnChanged();
OnChanged(nameof(RhsPreviewDisplay));
OnChanged(nameof(ShowRhsPreview));
}
}

Expand Down Expand Up @@ -114,6 +128,7 @@ public LetToLambdaWindow(ParsedLet parsed, Func<string, string?> resolveExisting
BindingName = b.Name,
ParamName = b.Name,
RhsPreview = b.RhsText,
DefaultExpression = b.RhsText,
Keep = true,
SourceIndex = i
}));
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down
Loading