refactor(ExcelIO): delete old attribute-based infrastructure

- Remove OutputColumnAttribute, OutputTableAttribute, OutputColumnCache
- Remove AttributeTableWriter and ColumnFormatter
- Remove duplicate ExcelFormats from Mapping (use Formatting version)
- Remove OutputColumn model
- Add FilterEntryMaps for criteria sheet filter models
- Update CriteriaSheetGenerator to use FluentTableWriter
- Remove attributes from filter entry models (now use fluent maps)
- Update DI to register filter entry maps and remove old services
- Update tests to use new fluent infrastructure
- Delete obsolete test files for removed infrastructure

Task 16 of fluent-excel-mapping-implementation plan.
This commit is contained in:
Joseph Doherty
2026-01-06 23:56:02 -05:00
parent e98ce636e2
commit 621dd41a97
30 changed files with 287 additions and 891 deletions
@@ -1,58 +0,0 @@
namespace JdeScoping.ExcelIO.Attributes;
/// <summary>
/// Excel output column specification.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class OutputColumnAttribute : Attribute
{
/// <summary>
/// Standard format (text).
/// </summary>
public const string StdFormat = "@";
/// <summary>
/// Standard date format.
/// </summary>
public const string DateFormat = "[$-409]MM/dd/yyyy;@";
/// <summary>
/// Standard timestamp format.
/// </summary>
public const string TimestampFormat = "[$-409]m/d/yy h:mm AM/PM;@";
/// <summary>
/// Wrapped text column default width.
/// </summary>
public const double WrappedColumnWidth = 65;
/// <summary>
/// Order to display column.
/// </summary>
public int Order { get; set; }
/// <summary>
/// Override text to display for column header.
/// </summary>
public string HeaderText { get; set; } = string.Empty;
/// <summary>
/// Column format (Excel formatting string).
/// </summary>
public string Format { get; set; } = StdFormat;
/// <summary>
/// Whether or not width should be set automatically.
/// </summary>
public bool AutoWidth { get; set; } = true;
/// <summary>
/// Manually set width (only used if AutoWidth = false).
/// </summary>
public double Width { get; set; }
/// <summary>
/// Whether or not text should be wrapped.
/// </summary>
public bool WrapText { get; set; }
}
@@ -1,23 +0,0 @@
namespace JdeScoping.ExcelIO.Attributes;
/// <summary>
/// Excel output table specification.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class OutputTableAttribute : Attribute
{
/// <summary>
/// Output tab name.
/// </summary>
public string TabName { get; set; } = string.Empty;
/// <summary>
/// Table name.
/// </summary>
public string TableName { get; set; } = string.Empty;
/// <summary>
/// Whether or not merged header should be shown.
/// </summary>
public bool ShowHeader { get; set; }
}
@@ -2,7 +2,6 @@ using JdeScoping.Core.Interfaces;
using JdeScoping.ExcelIO;
using JdeScoping.ExcelIO.Options;
using JdeScoping.ExcelIO.Generators;
using JdeScoping.ExcelIO.Helpers;
using JdeScoping.ExcelIO.Mapping;
using JdeScoping.ExcelIO.Mapping.Maps;
using JdeScoping.ExcelIO.Parsing;
@@ -42,18 +41,29 @@ public static class ExcelIODependencyInjection
// Register generators (scoped - they use options)
services.AddScoped<CriteriaSheetGenerator>();
// Register helpers (singleton - stateless)
services.AddSingleton<OutputColumnCache>();
services.AddSingleton<AttributeTableWriter>();
// Register template generator (singleton - stateless)
services.AddSingleton<DataEntryTemplateGenerator>();
// Register Excel map registry with all maps
services.AddSingleton(sp =>
{
var registry = new ExcelMapRegistry();
// Search result maps
registry.Register(new SearchResultMap());
registry.Register(new MisSearchResultMap());
registry.Register(new MisNonMatchSearchResultMap());
// Filter entry maps (for criteria sheet)
registry.Register(new TimespanFilterMap());
registry.Register(new WorkOrderFilterEntryMap());
registry.Register(new ItemNumberFilterEntryMap());
registry.Register(new ProfitCenterFilterEntryMap());
registry.Register(new WorkCenterFilterEntryMap());
registry.Register(new OperatorFilterEntryMap());
registry.Register(new ComponentLotFilterEntryMap());
registry.Register(new ItemOperationMisFilterEntryMap());
return registry;
});
@@ -1,54 +0,0 @@
using ClosedXML.Excel;
using JdeScoping.ExcelIO.Attributes;
namespace JdeScoping.ExcelIO.Formatting;
/// <summary>
/// Column width and number format utilities.
/// </summary>
public static class ColumnFormatter
{
/// <summary>
/// Applies column formatting based on attribute settings.
/// </summary>
/// <param name="column">The column to format.</param>
/// <param name="attr">The output column attribute with settings.</param>
public static void ApplyColumnFormat(IXLColumn column, OutputColumnAttribute attr)
{
// Set number format
column.Style.NumberFormat.Format = attr.Format;
// Handle wrap text
if (attr.WrapText)
{
column.Style.Alignment.WrapText = true;
}
// Handle width
if (attr.WrapText && !attr.AutoWidth)
{
// Wrapped columns with fixed width skip auto-fit
column.Width = attr.Width;
}
else if (attr.AutoWidth)
{
column.AdjustToContents();
column.Width *= ExcelFormats.DataPaddingFactor;
}
else
{
column.Width = attr.Width;
}
}
/// <summary>
/// Auto-fits a column with the specified padding factor.
/// </summary>
/// <param name="column">The column to auto-fit.</param>
/// <param name="paddingFactor">The padding factor to apply (e.g., 1.15 for 15% padding).</param>
public static void AutoFitWithPadding(IXLColumn column, double paddingFactor)
{
column.AdjustToContents();
column.Width *= paddingFactor;
}
}
@@ -1,166 +0,0 @@
using System.Reflection;
using ClosedXML.Excel;
using JdeScoping.ExcelIO.Attributes;
using JdeScoping.ExcelIO.Formatting;
using JdeScoping.ExcelIO.Helpers;
namespace JdeScoping.ExcelIO.Generators;
/// <summary>
/// Generic attribute-driven table writer for Excel worksheets.
/// </summary>
public class AttributeTableWriter
{
private readonly OutputColumnCache _cache;
/// <summary>
/// Initializes a new instance of the AttributeTableWriter class.
/// </summary>
/// <param name="cache">The output column cache.</param>
public AttributeTableWriter(OutputColumnCache cache)
{
_cache = cache;
}
/// <summary>
/// Writes a table to the worksheet using attribute-driven column definitions.
/// </summary>
/// <typeparam name="T">The type of data items.</typeparam>
/// <param name="worksheet">The worksheet to write to.</param>
/// <param name="startRow">The starting row (1-indexed).</param>
/// <param name="startCol">The starting column (1-indexed).</param>
/// <param name="data">The data to write.</param>
/// <param name="tableNameOverride">Optional table name override.</param>
/// <param name="showHeader">Optional override for showing merged header.</param>
/// <param name="headerText">Optional header text for merged header.</param>
/// <returns>The created table, or null if no data.</returns>
public IXLTable? WriteTable<T>(
IXLWorksheet worksheet,
int startRow,
int startCol,
IEnumerable<T> data,
string? tableNameOverride = null,
bool? showHeader = null,
string? headerText = null)
{
var tableAttr = typeof(T).GetCustomAttribute<OutputTableAttribute>();
var columns = _cache.GetColumns<T>();
var tableName = tableNameOverride ?? tableAttr?.TableName ?? typeof(T).Name;
var shouldShowHeader = showHeader ?? (tableAttr?.ShowHeader ?? false);
var header = headerText ?? tableAttr?.TabName ?? string.Empty;
if (columns.Count == 0)
{
return null;
}
var dataList = data.ToList();
var baseRow = startRow;
// Write merged header if requested
if (shouldShowHeader && !string.IsNullOrEmpty(header))
{
var mergedHeaderRange = worksheet.Range(baseRow, startCol, baseRow, startCol + columns.Count - 1);
HeaderFormatter.ApplyHeaderFormat(mergedHeaderRange, header, merge: true);
baseRow++;
}
// Write column headers
var col = startCol;
foreach (var column in columns)
{
var cell = worksheet.Cell(baseRow, col);
HeaderFormatter.ApplyHeaderFormat(cell, column.Attribute.HeaderText);
// Pre-set column formatting
worksheet.Column(col).Style.Alignment.WrapText = column.Attribute.WrapText;
if (!column.Attribute.AutoWidth)
{
worksheet.Column(col).Width = column.Attribute.Width;
}
col++;
}
// Write data rows
var row = baseRow + 1;
foreach (var item in dataList)
{
col = startCol;
foreach (var column in columns)
{
var value = column.Property.GetValue(item);
worksheet.Cell(row, col).Value = ConvertToXlValue(value);
col++;
}
row++;
}
// Handle empty data case - add at least one empty row for valid table
if (dataList.Count == 0)
{
row = baseRow + 1;
}
// Create table range
var dataRange = worksheet.Range(
baseRow, startCol,
baseRow + dataList.Count, startCol + columns.Count - 1);
// Create table
var table = dataRange.CreateTable(tableName);
table.Theme = XLTableTheme.TableStyleLight18;
table.ShowTotalsRow = false;
// Apply column formatting and number formats
col = startCol;
var tableStartRow = table.RangeAddress.FirstAddress.RowNumber;
var tableEndRow = table.RangeAddress.LastAddress.RowNumber;
foreach (var column in columns)
{
// Apply number format to the column data
worksheet.Range(tableStartRow, col, tableEndRow, col)
.Style.NumberFormat.Format = column.Attribute.Format;
// Apply column width
if (column.Attribute.WrapText && !column.Attribute.AutoWidth)
{
worksheet.Column(col).Width = column.Attribute.Width;
}
else if (column.Attribute.AutoWidth)
{
worksheet.Column(col).AdjustToContents();
worksheet.Column(col).Width *= ExcelFormats.DataPaddingFactor;
}
else
{
worksheet.Column(col).Width = column.Attribute.Width;
}
col++;
}
return table;
}
/// <summary>
/// Converts a value to an XLCellValue.
/// </summary>
private static XLCellValue ConvertToXlValue(object? value)
{
return value switch
{
null => Blank.Value,
string s => s,
int i => i,
long l => l,
decimal d => d,
double dbl => dbl,
float f => f,
DateTime dt => dt,
bool b => b,
_ => value.ToString() ?? string.Empty
};
}
}
@@ -12,16 +12,16 @@ namespace JdeScoping.ExcelIO.Generators;
public class CriteriaSheetGenerator
{
private readonly IOptions<ExcelExportOptions> _options;
private readonly AttributeTableWriter _tableWriter;
private readonly FluentTableWriter _tableWriter;
/// <summary>
/// Initializes a new instance of the CriteriaSheetGenerator class.
/// </summary>
/// <param name="options">Excel export options.</param>
/// <param name="tableWriter">Attribute table writer.</param>
/// <param name="tableWriter">Fluent table writer.</param>
public CriteriaSheetGenerator(
IOptions<ExcelExportOptions> options,
AttributeTableWriter tableWriter)
FluentTableWriter tableWriter)
{
_options = options;
_tableWriter = tableWriter;
@@ -1,60 +0,0 @@
using System.Collections.Concurrent;
using System.Reflection;
using JdeScoping.ExcelIO.Attributes;
using JdeScoping.ExcelIO.Models;
namespace JdeScoping.ExcelIO.Helpers;
/// <summary>
/// Cached reflection for column metadata.
/// </summary>
public class OutputColumnCache
{
private readonly ConcurrentDictionary<Type, IReadOnlyList<OutputColumn>> _cache = new();
/// <summary>
/// Gets the output columns for a given type.
/// </summary>
/// <typeparam name="T">The type to get columns for.</typeparam>
/// <returns>Ordered list of output columns.</returns>
public IReadOnlyList<OutputColumn> GetColumns<T>()
{
return GetColumns(typeof(T));
}
/// <summary>
/// Gets the output columns for a given type.
/// </summary>
/// <param name="type">The type to get columns for.</param>
/// <returns>Ordered list of output columns.</returns>
public IReadOnlyList<OutputColumn> GetColumns(Type type)
{
return _cache.GetOrAdd(type, BuildColumns);
}
private static IReadOnlyList<OutputColumn> BuildColumns(Type type)
{
var columns = new List<OutputColumn>();
foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
var attr = property.GetCustomAttribute<OutputColumnAttribute>();
if (attr != null)
{
columns.Add(new OutputColumn
{
Name = property.Name,
Property = property,
Attribute = attr
});
}
}
// Sort by Order ascending, then by Name alphabetically for tie-breaking
return columns
.OrderBy(c => c.Attribute.Order)
.ThenBy(c => c.Name)
.ToList()
.AsReadOnly();
}
}
@@ -1,19 +0,0 @@
namespace JdeScoping.ExcelIO.Mapping;
/// <summary>
/// Standard Excel format strings for column configuration.
/// </summary>
public static class ExcelFormats
{
/// <summary>Text format (default).</summary>
public const string Text = "@";
/// <summary>Date format: MM/dd/yyyy</summary>
public const string Date = "[$-409]MM/dd/yyyy;@";
/// <summary>Timestamp format: m/d/yy h:mm AM/PM</summary>
public const string Timestamp = "[$-409]m/d/yy h:mm AM/PM;@";
/// <summary>Default width for wrapped text columns.</summary>
public const double WrappedColumnWidth = 65;
}
@@ -0,0 +1,119 @@
using JdeScoping.ExcelIO.Formatting;
using JdeScoping.ExcelIO.Models.Reporting;
namespace JdeScoping.ExcelIO.Mapping.Maps;
/// <summary>
/// Excel column mapping for TimespanFilter.
/// </summary>
public sealed class TimespanFilterMap : ExcelClassMap<TimespanFilter>
{
public TimespanFilterMap()
{
Table("Timespan_Filter", "Timespan Filter");
Map(x => x.MinimumDt).Order(10).Header("Minimum Date").Format(ExcelFormats.DateFormat);
Map(x => x.MaximumDt).Order(20).Header("Maximum Date").Format(ExcelFormats.DateFormat);
}
}
/// <summary>
/// Excel column mapping for WorkOrderFilterEntry.
/// </summary>
public sealed class WorkOrderFilterEntryMap : ExcelClassMap<WorkOrderFilterEntry>
{
public WorkOrderFilterEntryMap()
{
Table("Work_Order_Filter", "Work Order Filter");
Map(x => x.WorkOrderNumber).Order(10).Header("Work Order Number");
Map(x => x.ItemNumber).Order(20).Header("Item Number");
}
}
/// <summary>
/// Excel column mapping for ItemNumberFilterEntry.
/// </summary>
public sealed class ItemNumberFilterEntryMap : ExcelClassMap<ItemNumberFilterEntry>
{
public ItemNumberFilterEntryMap()
{
Table("Item_Number_Filter", "Item Number Filter");
Map(x => x.ItemNumber).Order(10).Header("Item Number");
Map(x => x.ItemDescription).Order(20).Header("Item Description");
}
}
/// <summary>
/// Excel column mapping for ProfitCenterFilterEntry.
/// </summary>
public sealed class ProfitCenterFilterEntryMap : ExcelClassMap<ProfitCenterFilterEntry>
{
public ProfitCenterFilterEntryMap()
{
Table("Profit_Center_Filter", "Profit Center Filter");
Map(x => x.Code).Order(10).Header("Profit Center");
Map(x => x.Description).Order(20).Header("Description");
}
}
/// <summary>
/// Excel column mapping for WorkCenterFilterEntry.
/// </summary>
public sealed class WorkCenterFilterEntryMap : ExcelClassMap<WorkCenterFilterEntry>
{
public WorkCenterFilterEntryMap()
{
Table("Work_Center_Filter", "Work Center Filter");
Map(x => x.Code).Order(10).Header("Work Center");
Map(x => x.Description).Order(20).Header("Description");
}
}
/// <summary>
/// Excel column mapping for OperatorFilterEntry.
/// </summary>
public sealed class OperatorFilterEntryMap : ExcelClassMap<OperatorFilterEntry>
{
public OperatorFilterEntryMap()
{
Table("Operator_Filter", "Operator Filter");
// Note: AddressNumber is not exported to Excel (no OutputColumn attribute in original)
Map(x => x.UserId).Order(10).Header("Username");
Map(x => x.FullName).Order(20).Header("Name");
}
}
/// <summary>
/// Excel column mapping for ComponentLotFilterEntry.
/// </summary>
public sealed class ComponentLotFilterEntryMap : ExcelClassMap<ComponentLotFilterEntry>
{
public ComponentLotFilterEntryMap()
{
Table("Component_Lot_Filter", "Component Lot Filter");
Map(x => x.LotNumber).Order(10).Header("Lot Number");
Map(x => x.ItemNumber).Order(20).Header("Item Number");
}
}
/// <summary>
/// Excel column mapping for ItemOperationMisFilterEntry.
/// </summary>
public sealed class ItemOperationMisFilterEntryMap : ExcelClassMap<ItemOperationMisFilterEntry>
{
public ItemOperationMisFilterEntryMap()
{
Table("Item_Operation_MIS_Filter", "Item/Operation/MIS Filter");
Map(x => x.ItemNumber).Order(10).Header("Item Number");
Map(x => x.OperationNumber).Order(20).Header("Operation Number");
Map(x => x.MisNumber).Order(30).Header("MIS Number");
Map(x => x.MisRevision).Order(40).Header("MIS Revision");
}
}
@@ -1,4 +1,5 @@
using JdeScoping.Core.Models.SearchResults;
using JdeScoping.ExcelIO.Formatting;
namespace JdeScoping.ExcelIO.Mapping.Maps;
@@ -13,10 +14,10 @@ public sealed class MisNonMatchSearchResultMap : ExcelClassMap<MisNonMatchSearch
Map(x => x.WorkCenterCode).Order(10).Header("Work Center Code");
Map(x => x.WorkOrderNumber).Order(20).Header("Work Order Number");
Map(x => x.WorkOrderStartDate).Order(30).Header("Work Order Start Date").Format(ExcelFormats.Date);
Map(x => x.WorkOrderStartDate).Order(30).Header("Work Order Start Date").Format(ExcelFormats.DateFormat);
Map(x => x.JobStepNumber).Order(40).Header("Job Step Number");
Map(x => x.JobStepDescription).Order(50).Header("Function Operation Description");
Map(x => x.JobStepEndDate).Order(60).Header("Job Step End Date").Format(ExcelFormats.Date);
Map(x => x.JobStepEndDate).Order(60).Header("Job Step End Date").Format(ExcelFormats.DateFormat);
Map(x => x.FunctionCode).Order(70).Header("Function Code");
Map(x => x.WasJobStepAdded).Order(75).Header("Was Job Step Added?");
Map(x => x.MatchedJobStepNumber).Order(76).Header("Matched Job Step Number");
@@ -1,4 +1,5 @@
using JdeScoping.Core.Models.SearchResults;
using JdeScoping.ExcelIO.Formatting;
namespace JdeScoping.ExcelIO.Mapping.Maps;
@@ -17,7 +18,7 @@ public sealed class MisSearchResultMap : ExcelClassMap<MisSearchResult>
Map(x => x.RevId).Order(40).Header("MIS Revision");
Map(x => x.ItemDescription).Order(50).Header("Item Description");
Map(x => x.Status).Order(60).Header("MIS Release Status");
Map(x => x.ReleaseDate).Order(70).Header("MIS Release Date").Format(ExcelFormats.Timestamp);
Map(x => x.ReleaseDate).Order(70).Header("MIS Release Date").Format(ExcelFormats.TimestampFormat);
Map(x => x.BranchCode).Order(80).Header("Branch Code");
Map(x => x.JobStepSequenceNumber).Order(90).Header("Job Step Sequence Number");
Map(x => x.MatchedSequenceNumber).Order(100).Header("Matched Sequence Number");
@@ -1,4 +1,5 @@
using JdeScoping.Core.Models.SearchResults;
using JdeScoping.ExcelIO.Formatting;
namespace JdeScoping.ExcelIO.Mapping.Maps;
@@ -25,10 +26,10 @@ public sealed class SearchResultMap : ExcelClassMap<SearchResult>
Map(x => x.StepNumber).Order(110).Header("Operation Step");
Map(x => x.StepDescription).Order(120).Header("Operation Step Description");
Map(x => x.FunctionOperationDescription).Order(130).Header("Function Operation Description");
Map(x => x.StepUpdateDt).Order(140).Header("Operation Step Update Timestamp").Format(ExcelFormats.Timestamp);
Map(x => x.StepUpdateDt).Order(140).Header("Operation Step Update Timestamp").Format(ExcelFormats.TimestampFormat);
Map(x => x.StatusCode).Order(150).Header("Status Code");
Map(x => x.StatusDescription).Order(160).Header("Status Description");
Map(x => x.StatusUpdateDt).Order(170).Header("Status Update Timestamp").Format(ExcelFormats.Date);
Map(x => x.StatusUpdateDt).Order(170).Header("Status Update Timestamp").Format(ExcelFormats.DateFormat);
Map(x => x.InclusionReason).Order(180).Header("Inclusion Reason");
}
}
@@ -1,25 +0,0 @@
using System.Reflection;
using JdeScoping.ExcelIO.Attributes;
namespace JdeScoping.ExcelIO.Models;
/// <summary>
/// Column metadata model for Excel output.
/// </summary>
public class OutputColumn
{
/// <summary>
/// Property name.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Property info for reflection.
/// </summary>
public PropertyInfo Property { get; init; } = null!;
/// <summary>
/// Output column attribute.
/// </summary>
public OutputColumnAttribute Attribute { get; init; } = null!;
}
@@ -1,22 +1,17 @@
using JdeScoping.ExcelIO.Attributes;
namespace JdeScoping.ExcelIO.Models.Reporting;
/// <summary>
/// Component lot search filter entry.
/// </summary>
[OutputTable(TabName = "Component Lot Filter", ShowHeader = true, TableName = "Component_Lot_Filter")]
public class ComponentLotFilterEntry
{
/// <summary>
/// Component lot number.
/// </summary>
[OutputColumn(Order = 10, HeaderText = "Lot Number")]
public string LotNumber { get; set; } = string.Empty;
/// <summary>
/// Component lot item number.
/// </summary>
[OutputColumn(Order = 20, HeaderText = "Item Number")]
public string ItemNumber { get; set; } = string.Empty;
}
@@ -1,22 +1,17 @@
using JdeScoping.ExcelIO.Attributes;
namespace JdeScoping.ExcelIO.Models.Reporting;
/// <summary>
/// Item number search filter entry.
/// </summary>
[OutputTable(TabName = "Item Number Filter", ShowHeader = true, TableName = "Item_Number_Filter")]
public class ItemNumberFilterEntry
{
/// <summary>
/// Item number.
/// </summary>
[OutputColumn(Order = 10, HeaderText = "Item Number")]
public string ItemNumber { get; set; } = string.Empty;
/// <summary>
/// Item description.
/// </summary>
[OutputColumn(Order = 20, HeaderText = "Item Description")]
public string ItemDescription { get; set; } = string.Empty;
}
@@ -1,34 +1,27 @@
using JdeScoping.ExcelIO.Attributes;
namespace JdeScoping.ExcelIO.Models.Reporting;
/// <summary>
/// Item/Operation/MIS search filter entry.
/// </summary>
[OutputTable(TabName = "Item/Operation/MIS Filter", ShowHeader = true, TableName = "Item_Operation_MIS_Filter")]
public class ItemOperationMisFilterEntry
{
/// <summary>
/// Part's item number.
/// </summary>
[OutputColumn(Order = 10, HeaderText = "Item Number")]
public string ItemNumber { get; set; } = string.Empty;
/// <summary>
/// Operation's job step number.
/// </summary>
[OutputColumn(Order = 20, HeaderText = "Operation Number")]
public string OperationNumber { get; set; } = string.Empty;
/// <summary>
/// MIS number.
/// </summary>
[OutputColumn(Order = 30, HeaderText = "MIS Number")]
public string MisNumber { get; set; } = string.Empty;
/// <summary>
/// MIS revision.
/// </summary>
[OutputColumn(Order = 40, HeaderText = "MIS Revision")]
public string MisRevision { get; set; } = string.Empty;
}
@@ -1,11 +1,8 @@
using JdeScoping.ExcelIO.Attributes;
namespace JdeScoping.ExcelIO.Models.Reporting;
/// <summary>
/// Operator search filter entry.
/// </summary>
[OutputTable(TabName = "Operator Filter", ShowHeader = true, TableName = "Operator_Filter")]
public class OperatorFilterEntry
{
/// <summary>
@@ -16,12 +13,10 @@ public class OperatorFilterEntry
/// <summary>
/// Operator login user ID.
/// </summary>
[OutputColumn(Order = 10, HeaderText = "Username")]
public string UserId { get; set; } = string.Empty;
/// <summary>
/// Operator full name (FIRST + LAST).
/// </summary>
[OutputColumn(Order = 20, HeaderText = "Name")]
public string FullName { get; set; } = string.Empty;
}
@@ -1,22 +1,17 @@
using JdeScoping.ExcelIO.Attributes;
namespace JdeScoping.ExcelIO.Models.Reporting;
/// <summary>
/// Profit center search filter entry.
/// </summary>
[OutputTable(TabName = "Profit Center Filter", ShowHeader = true, TableName = "Profit_Center_Filter")]
public class ProfitCenterFilterEntry
{
/// <summary>
/// Profit center code.
/// </summary>
[OutputColumn(Order = 10, HeaderText = "Profit Center")]
public string Code { get; set; } = string.Empty;
/// <summary>
/// Profit center description.
/// </summary>
[OutputColumn(Order = 20, HeaderText = "Description")]
public string Description { get; set; } = string.Empty;
}
@@ -1,22 +1,17 @@
using JdeScoping.ExcelIO.Attributes;
namespace JdeScoping.ExcelIO.Models.Reporting;
/// <summary>
/// Timespan filter entry for criteria sheet.
/// </summary>
[OutputTable(TabName = "Timespan Filter", ShowHeader = true, TableName = "Timespan_Filter")]
public class TimespanFilter
{
/// <summary>
/// Minimum date/time.
/// </summary>
[OutputColumn(Order = 10, HeaderText = "Minimum Date", Format = OutputColumnAttribute.DateFormat)]
public DateTime? MinimumDt { get; set; }
/// <summary>
/// Maximum date/time.
/// </summary>
[OutputColumn(Order = 20, HeaderText = "Maximum Date", Format = OutputColumnAttribute.DateFormat)]
public DateTime? MaximumDt { get; set; }
}
@@ -1,22 +1,17 @@
using JdeScoping.ExcelIO.Attributes;
namespace JdeScoping.ExcelIO.Models.Reporting;
/// <summary>
/// Work center search filter entry.
/// </summary>
[OutputTable(TabName = "Work Center Filter", ShowHeader = true, TableName = "Work_Center_Filter")]
public class WorkCenterFilterEntry
{
/// <summary>
/// Work center code.
/// </summary>
[OutputColumn(Order = 10, HeaderText = "Work Center")]
public string Code { get; set; } = string.Empty;
/// <summary>
/// Work center description.
/// </summary>
[OutputColumn(Order = 20, HeaderText = "Description")]
public string Description { get; set; } = string.Empty;
}
@@ -1,22 +1,17 @@
using JdeScoping.ExcelIO.Attributes;
namespace JdeScoping.ExcelIO.Models.Reporting;
/// <summary>
/// Work order search filter entry.
/// </summary>
[OutputTable(TabName = "Work Order Filter", ShowHeader = true, TableName = "Work_Order_Filter")]
public class WorkOrderFilterEntry
{
/// <summary>
/// Work order number.
/// </summary>
[OutputColumn(Order = 10, HeaderText = "Work Order Number")]
public long WorkOrderNumber { get; set; }
/// <summary>
/// Work order item number.
/// </summary>
[OutputColumn(Order = 20, HeaderText = "Item Number")]
public string ItemNumber { get; set; } = string.Empty;
}
@@ -1,187 +0,0 @@
using ClosedXML.Excel;
using JdeScoping.ExcelIO.Attributes;
using JdeScoping.ExcelIO.Generators;
using JdeScoping.ExcelIO.Helpers;
using Shouldly;
using Xunit;
namespace JdeScoping.ExcelIO.Tests;
public class AttributeTableWriterTests
{
private readonly OutputColumnCache _cache = new();
private readonly AttributeTableWriter _writer;
public AttributeTableWriterTests()
{
_writer = new AttributeTableWriter(_cache);
}
[OutputTable(TabName = "Test Items", TableName = "Test_Items", ShowHeader = false)]
private class TestItem
{
[OutputColumn(Order = 10, HeaderText = "ID")]
public int Id { get; set; }
[OutputColumn(Order = 20, HeaderText = "Name")]
public string Name { get; set; } = string.Empty;
[OutputColumn(Order = 30, HeaderText = "Value")]
public decimal Value { get; set; }
}
[OutputTable(TabName = "Wrapped Table", TableName = "Wrapped_Table")]
private class WrappedItem
{
[OutputColumn(Order = 10, HeaderText = "Description", WrapText = true, AutoWidth = false, Width = 65)]
public string Description { get; set; } = string.Empty;
}
private class NoAttributeItem
{
public string Data { get; set; } = string.Empty;
}
[Fact]
public void WriteTable_CreatesTableWithCorrectColumns()
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Test");
var data = new List<TestItem>
{
new() { Id = 1, Name = "Item 1", Value = 10.5m },
new() { Id = 2, Name = "Item 2", Value = 20.5m }
};
var table = _writer.WriteTable(worksheet, 1, 1, data);
table.ShouldNotBeNull();
table.ColumnCount().ShouldBe(3);
table.RowCount().ShouldBe(3); // Header + 2 data rows
}
[Fact]
public void WriteTable_UsesLight18TableStyle()
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Test");
var data = new List<TestItem> { new() { Id = 1, Name = "Test", Value = 100m } };
var table = _writer.WriteTable(worksheet, 1, 1, data);
table.ShouldNotBeNull();
table.Theme.ShouldBe(XLTableTheme.TableStyleLight18);
}
[Fact]
public void WriteTable_SetsColumnHeaders()
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Test");
var data = new List<TestItem> { new() { Id = 1, Name = "Test", Value = 100m } };
_writer.WriteTable(worksheet, 1, 1, data);
worksheet.Cell(1, 1).Value.GetText().ShouldBe("ID");
worksheet.Cell(1, 2).Value.GetText().ShouldBe("Name");
worksheet.Cell(1, 3).Value.GetText().ShouldBe("Value");
}
[Fact]
public void WriteTable_WritesDataRows()
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Test");
var data = new List<TestItem>
{
new() { Id = 1, Name = "Item 1", Value = 10.5m },
new() { Id = 2, Name = "Item 2", Value = 20.5m }
};
_writer.WriteTable(worksheet, 1, 1, data);
worksheet.Cell(2, 1).Value.GetNumber().ShouldBe(1);
worksheet.Cell(2, 2).Value.GetText().ShouldBe("Item 1");
worksheet.Cell(2, 3).Value.GetNumber().ShouldBe(10.5);
worksheet.Cell(3, 1).Value.GetNumber().ShouldBe(2);
}
[Fact]
public void WriteTable_WithShowHeader_CreatesMergedHeader()
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Test");
var data = new List<TestItem> { new() { Id = 1, Name = "Test", Value = 100m } };
_writer.WriteTable(worksheet, 1, 1, data, showHeader: true, headerText: "Test Header");
// First row should be merged header
var headerRange = worksheet.Range(1, 1, 1, 3);
headerRange.IsMerged().ShouldBeTrue();
worksheet.Cell(1, 1).Value.GetText().ShouldBe("Test Header");
// Column headers should be on row 2
worksheet.Cell(2, 1).Value.GetText().ShouldBe("ID");
}
[Fact]
public void WriteTable_EmptyData_CreatesTableWithHeaderOnly()
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Test");
var data = new List<TestItem>();
var table = _writer.WriteTable(worksheet, 1, 1, data);
table.ShouldNotBeNull();
// Table should exist with headers
table.ColumnCount().ShouldBe(3);
}
[Fact]
public void WriteTable_NoAttributes_ReturnsNull()
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Test");
var data = new List<NoAttributeItem> { new() { Data = "Test" } };
var table = _writer.WriteTable(worksheet, 1, 1, data);
table.ShouldBeNull();
}
[Fact]
public void WriteTable_WrappedColumn_SetsFixedWidth()
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Test");
var data = new List<WrappedItem> { new() { Description = "Long description text" } };
_writer.WriteTable(worksheet, 1, 1, data);
worksheet.Column(1).Width.ShouldBe(65);
worksheet.Column(1).Style.Alignment.WrapText.ShouldBeTrue();
}
[Fact]
public void WriteTable_TableNameOverride_UsesProvidedName()
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Test");
var data = new List<TestItem> { new() { Id = 1, Name = "Test", Value = 100m } };
var table = _writer.WriteTable(worksheet, 1, 1, data, tableNameOverride: "Custom_Table");
table.ShouldNotBeNull();
table.Name.ShouldBe("Custom_Table");
}
}
@@ -1,116 +0,0 @@
using ClosedXML.Excel;
using JdeScoping.ExcelIO.Attributes;
using JdeScoping.ExcelIO.Formatting;
using Shouldly;
using Xunit;
namespace JdeScoping.ExcelIO.Tests;
public class ColumnFormatterTests
{
[Fact]
public void ApplyColumnFormat_AutoWidth_AdjustsToContents()
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Test");
worksheet.Cell(1, 1).Value = "Some Text Value";
var attr = new OutputColumnAttribute
{
AutoWidth = true,
Format = OutputColumnAttribute.StdFormat
};
ColumnFormatter.ApplyColumnFormat(worksheet.Column(1), attr);
// Width should be greater than default after adjustment
worksheet.Column(1).Width.ShouldBeGreaterThan(0);
}
[Fact]
public void ApplyColumnFormat_FixedWidth_SetsExactWidth()
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Test");
var attr = new OutputColumnAttribute
{
AutoWidth = false,
Width = 50.0,
Format = OutputColumnAttribute.StdFormat
};
ColumnFormatter.ApplyColumnFormat(worksheet.Column(1), attr);
worksheet.Column(1).Width.ShouldBe(50.0);
}
[Fact]
public void ApplyColumnFormat_WrapText_EnablesWrapping()
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Test");
var attr = new OutputColumnAttribute
{
WrapText = true,
AutoWidth = false,
Width = 65.0,
Format = OutputColumnAttribute.StdFormat
};
ColumnFormatter.ApplyColumnFormat(worksheet.Column(1), attr);
worksheet.Column(1).Style.Alignment.WrapText.ShouldBeTrue();
worksheet.Column(1).Width.ShouldBe(65.0);
}
[Fact]
public void ApplyColumnFormat_DateFormat_AppliesCorrectFormat()
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Test");
var attr = new OutputColumnAttribute
{
AutoWidth = false,
Width = 20.0,
Format = OutputColumnAttribute.DateFormat
};
ColumnFormatter.ApplyColumnFormat(worksheet.Column(1), attr);
worksheet.Column(1).Style.NumberFormat.Format.ShouldBe(OutputColumnAttribute.DateFormat);
}
[Fact]
public void ApplyColumnFormat_TimestampFormat_AppliesCorrectFormat()
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Test");
var attr = new OutputColumnAttribute
{
AutoWidth = false,
Width = 25.0,
Format = OutputColumnAttribute.TimestampFormat
};
ColumnFormatter.ApplyColumnFormat(worksheet.Column(1), attr);
worksheet.Column(1).Style.NumberFormat.Format.ShouldBe(OutputColumnAttribute.TimestampFormat);
}
[Fact]
public void AutoFitWithPadding_AppliesPaddingFactor()
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Test");
worksheet.Cell(1, 1).Value = "Some Text";
ColumnFormatter.AutoFitWithPadding(worksheet.Column(1), 1.30);
// Width should be greater than 0 and include padding
worksheet.Column(1).Width.ShouldBeGreaterThan(0);
}
}
@@ -1,7 +1,8 @@
using ClosedXML.Excel;
using JdeScoping.ExcelIO.Options;
using JdeScoping.ExcelIO.Generators;
using JdeScoping.ExcelIO.Helpers;
using JdeScoping.ExcelIO.Mapping;
using JdeScoping.ExcelIO.Mapping.Maps;
using JdeScoping.ExcelIO.Models.Reporting;
using Microsoft.Extensions.Options;
using Shouldly;
@@ -21,11 +22,25 @@ public class CriteriaSheetGeneratorTests
CriteriaSheetPassword = "TestPassword"
});
var cache = new OutputColumnCache();
var tableWriter = new AttributeTableWriter(cache);
var registry = CreateTestRegistry();
var tableWriter = new FluentTableWriter(registry);
_generator = new CriteriaSheetGenerator(_options, tableWriter);
}
private static ExcelMapRegistry CreateTestRegistry()
{
var registry = new ExcelMapRegistry();
registry.Register(new TimespanFilterMap());
registry.Register(new WorkOrderFilterEntryMap());
registry.Register(new ItemNumberFilterEntryMap());
registry.Register(new ProfitCenterFilterEntryMap());
registry.Register(new WorkCenterFilterEntryMap());
registry.Register(new OperatorFilterEntryMap());
registry.Register(new ComponentLotFilterEntryMap());
registry.Register(new ItemOperationMisFilterEntryMap());
return registry;
}
[Fact]
public void Generate_CreatesSearchCriteriaSheet()
{
@@ -1,7 +1,8 @@
using ClosedXML.Excel;
using JdeScoping.ExcelIO.Options;
using JdeScoping.ExcelIO.Generators;
using JdeScoping.ExcelIO.Helpers;
using JdeScoping.ExcelIO.Mapping;
using JdeScoping.ExcelIO.Mapping.Maps;
using JdeScoping.ExcelIO.Models.Reporting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -9,6 +10,10 @@ using NSubstitute;
using Shouldly;
using Xunit;
using SearchResult = JdeScoping.Core.Models.SearchResults.SearchResult;
using MisSearchResult = JdeScoping.Core.Models.SearchResults.MisSearchResult;
using MisNonMatchSearchResult = JdeScoping.Core.Models.SearchResults.MisNonMatchSearchResult;
namespace JdeScoping.ExcelIO.Tests;
/// <summary>
@@ -29,11 +34,33 @@ public class ExcelExportIntegrationTests
DataSheetPassword = "TestDataPass"
});
var cache = new OutputColumnCache();
var tableWriter = new AttributeTableWriter(cache);
var registry = CreateTestRegistry();
var tableWriter = new FluentTableWriter(registry);
var criteriaGenerator = new CriteriaSheetGenerator(_options, tableWriter);
_service = new ExcelExportService(_logger, _options, criteriaGenerator, tableWriter);
_service = new ExcelExportService(_logger, _options, criteriaGenerator, tableWriter, registry);
}
private static ExcelMapRegistry CreateTestRegistry()
{
var registry = new ExcelMapRegistry();
// Search result maps
registry.Register(new SearchResultMap());
registry.Register(new MisSearchResultMap());
registry.Register(new MisNonMatchSearchResultMap());
// Filter entry maps
registry.Register(new TimespanFilterMap());
registry.Register(new WorkOrderFilterEntryMap());
registry.Register(new ItemNumberFilterEntryMap());
registry.Register(new ProfitCenterFilterEntryMap());
registry.Register(new WorkCenterFilterEntryMap());
registry.Register(new OperatorFilterEntryMap());
registry.Register(new ComponentLotFilterEntryMap());
registry.Register(new ItemOperationMisFilterEntryMap());
return registry;
}
#region Sheet Count Tests
@@ -387,10 +414,13 @@ public class ExcelExportIntegrationTests
public async Task GenerateAsync_SearchResults_ContainsCorrectData()
{
var search = CreateMinimalSearchModel();
var searchResult = CreateSampleSearchResult();
searchResult.WorkOrderNumber = 99999;
searchResult.ItemNumber = "TEST-ITEM-001";
searchResult.LotNumber = "LOT-999";
var searchResult = new SearchResult
{
WorkOrderNumber = 99999,
ItemNumber = "TEST-ITEM-001",
LotNumber = "LOT-999",
Flagged = true
};
search.Results.Add(searchResult);
var result = await _service.GenerateAsync(search);
@@ -408,9 +438,16 @@ public class ExcelExportIntegrationTests
[Fact]
public async Task GenerateAsync_MisInfo_ContainsCorrectData()
{
var search = CreateSearchModelWithMisData();
search.MisResults![0].ItemNumber = "MIS-ITEM-001";
search.MisResults[0].MisNumber = "MIS-12345";
var search = CreateMinimalSearchModel();
search.ExtractMisData = true;
search.MisResults = [
new MisSearchResult
{
ItemNumber = "MIS-ITEM-001",
MisNumber = "MIS-12345"
}
];
search.MisNonMatchResults = [];
var result = await _service.GenerateAsync(search);
@@ -426,9 +463,16 @@ public class ExcelExportIntegrationTests
[Fact]
public async Task GenerateAsync_Investigation_ContainsCorrectData()
{
var search = CreateSearchModelWithMisData();
search.MisNonMatchResults![0].WorkOrderNumber = 77777;
search.MisNonMatchResults[0].ItemNumber = "INV-ITEM-001";
var search = CreateMinimalSearchModel();
search.ExtractMisData = true;
search.MisResults = [];
search.MisNonMatchResults = [
new MisNonMatchSearchResult
{
WorkOrderNumber = 77777,
ItemNumber = "INV-ITEM-001"
}
];
var result = await _service.GenerateAsync(search);
@@ -1,7 +1,8 @@
using ClosedXML.Excel;
using JdeScoping.ExcelIO.Options;
using JdeScoping.ExcelIO.Generators;
using JdeScoping.ExcelIO.Helpers;
using JdeScoping.ExcelIO.Mapping;
using JdeScoping.ExcelIO.Mapping.Maps;
using JdeScoping.ExcelIO.Models.Reporting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -9,6 +10,10 @@ using NSubstitute;
using Shouldly;
using Xunit;
using SearchResult = JdeScoping.Core.Models.SearchResults.SearchResult;
using MisSearchResult = JdeScoping.Core.Models.SearchResults.MisSearchResult;
using MisNonMatchSearchResult = JdeScoping.Core.Models.SearchResults.MisNonMatchSearchResult;
namespace JdeScoping.ExcelIO.Tests;
public class ExcelExportServiceTests
@@ -26,11 +31,33 @@ public class ExcelExportServiceTests
DataSheetPassword = "TestDataPass"
});
var cache = new OutputColumnCache();
var tableWriter = new AttributeTableWriter(cache);
var registry = CreateTestRegistry();
var tableWriter = new FluentTableWriter(registry);
var criteriaGenerator = new CriteriaSheetGenerator(_options, tableWriter);
_service = new ExcelExportService(_logger, _options, criteriaGenerator, tableWriter);
_service = new ExcelExportService(_logger, _options, criteriaGenerator, tableWriter, registry);
}
private static ExcelMapRegistry CreateTestRegistry()
{
var registry = new ExcelMapRegistry();
// Search result maps
registry.Register(new SearchResultMap());
registry.Register(new MisSearchResultMap());
registry.Register(new MisNonMatchSearchResultMap());
// Filter entry maps
registry.Register(new TimespanFilterMap());
registry.Register(new WorkOrderFilterEntryMap());
registry.Register(new ItemNumberFilterEntryMap());
registry.Register(new ProfitCenterFilterEntryMap());
registry.Register(new WorkCenterFilterEntryMap());
registry.Register(new OperatorFilterEntryMap());
registry.Register(new ComponentLotFilterEntryMap());
registry.Register(new ItemOperationMisFilterEntryMap());
return registry;
}
[Fact]
@@ -1,4 +1,4 @@
using JdeScoping.ExcelIO.Models.Reporting;
using JdeScoping.Core.Models.SearchResults;
using Shouldly;
using Xunit;
@@ -1,8 +1,8 @@
using ClosedXML.Excel;
using JdeScoping.ExcelIO.Options;
using JdeScoping.ExcelIO.Formatting;
using JdeScoping.ExcelIO.Generators;
using JdeScoping.ExcelIO.Helpers;
using JdeScoping.ExcelIO.Mapping;
using JdeScoping.ExcelIO.Mapping.Maps;
using JdeScoping.ExcelIO.Models.Reporting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -10,6 +10,11 @@ using NSubstitute;
using Shouldly;
using Xunit;
using ExcelFormats = JdeScoping.ExcelIO.Formatting.ExcelFormats;
using SearchResult = JdeScoping.Core.Models.SearchResults.SearchResult;
using MisSearchResult = JdeScoping.Core.Models.SearchResults.MisSearchResult;
using MisNonMatchSearchResult = JdeScoping.Core.Models.SearchResults.MisNonMatchSearchResult;
namespace JdeScoping.ExcelIO.Tests;
/// <summary>
@@ -30,11 +35,33 @@ public class LegacyComparisonTests
DataSheetPassword = "JDESCOPINGTOOL"
});
var cache = new OutputColumnCache();
var tableWriter = new AttributeTableWriter(cache);
var registry = CreateTestRegistry();
var tableWriter = new FluentTableWriter(registry);
var criteriaGenerator = new CriteriaSheetGenerator(options, tableWriter);
_service = new ExcelExportService(logger, options, criteriaGenerator, tableWriter);
_service = new ExcelExportService(logger, options, criteriaGenerator, tableWriter, registry);
}
private static ExcelMapRegistry CreateTestRegistry()
{
var registry = new ExcelMapRegistry();
// Search result maps
registry.Register(new SearchResultMap());
registry.Register(new MisSearchResultMap());
registry.Register(new MisNonMatchSearchResultMap());
// Filter entry maps
registry.Register(new TimespanFilterMap());
registry.Register(new WorkOrderFilterEntryMap());
registry.Register(new ItemNumberFilterEntryMap());
registry.Register(new ProfitCenterFilterEntryMap());
registry.Register(new WorkCenterFilterEntryMap());
registry.Register(new OperatorFilterEntryMap());
registry.Register(new ComponentLotFilterEntryMap());
registry.Register(new ItemOperationMisFilterEntryMap());
return registry;
}
#region Search Results Column Order Tests
@@ -1,3 +1,4 @@
using JdeScoping.ExcelIO.Formatting;
using JdeScoping.ExcelIO.Mapping;
using Shouldly;
using Xunit;
@@ -23,7 +24,7 @@ public class ExcelClassMapTests
Map(x => x.Id).Order(10).Header("ID Number");
Map(x => x.Name).Order(20).Header("Full Name");
Map(x => x.CreatedAt).Order(30).Header("Created").Format(ExcelFormats.Timestamp);
Map(x => x.CreatedAt).Order(30).Header("Created").Format(ExcelFormats.TimestampFormat);
}
}
@@ -71,7 +72,7 @@ public class ExcelClassMapTests
var map = new TestModelMap();
var columns = map.Columns;
columns[2].Format.ShouldBe(ExcelFormats.Timestamp);
columns[2].Format.ShouldBe(ExcelFormats.TimestampFormat);
}
[Fact]
@@ -1,100 +0,0 @@
using JdeScoping.ExcelIO.Attributes;
using JdeScoping.ExcelIO.Helpers;
using Shouldly;
using Xunit;
namespace JdeScoping.ExcelIO.Tests;
public class OutputColumnCacheTests
{
private readonly OutputColumnCache _cache = new();
[OutputTable(TabName = "Test Table", TableName = "Test_Table")]
private class TestModel
{
[OutputColumn(Order = 30, HeaderText = "Column C")]
public string ColumnC { get; set; } = string.Empty;
[OutputColumn(Order = 10, HeaderText = "Column A")]
public string ColumnA { get; set; } = string.Empty;
[OutputColumn(Order = 20, HeaderText = "Column B")]
public string ColumnB { get; set; } = string.Empty;
public string NonOutputColumn { get; set; } = string.Empty;
}
private class TieBreakModel
{
[OutputColumn(Order = 10, HeaderText = "Zebra")]
public string Zebra { get; set; } = string.Empty;
[OutputColumn(Order = 10, HeaderText = "Apple")]
public string Apple { get; set; } = string.Empty;
[OutputColumn(Order = 10, HeaderText = "Mango")]
public string Mango { get; set; } = string.Empty;
}
private class EmptyModel
{
public string NoAttributes { get; set; } = string.Empty;
}
[Fact]
public void GetColumns_ReturnsColumnsOrderedByOrderProperty()
{
var columns = _cache.GetColumns<TestModel>();
columns.Count.ShouldBe(3);
columns[0].Attribute.HeaderText.ShouldBe("Column A");
columns[1].Attribute.HeaderText.ShouldBe("Column B");
columns[2].Attribute.HeaderText.ShouldBe("Column C");
}
[Fact]
public void GetColumns_TieBreaksAlphabeticallyByPropertyName()
{
var columns = _cache.GetColumns<TieBreakModel>();
columns.Count.ShouldBe(3);
// All have Order=10, so should be sorted by property name
columns[0].Name.ShouldBe("Apple");
columns[1].Name.ShouldBe("Mango");
columns[2].Name.ShouldBe("Zebra");
}
[Fact]
public void GetColumns_ExcludesPropertiesWithoutAttribute()
{
var columns = _cache.GetColumns<TestModel>();
columns.Count.ShouldBe(3);
columns.ShouldNotContain(c => c.Name == "NonOutputColumn");
}
[Fact]
public void GetColumns_ReturnsEmptyForEmptyModel()
{
var columns = _cache.GetColumns<EmptyModel>();
columns.Count.ShouldBe(0);
}
[Fact]
public void GetColumns_CachesResults()
{
var columns1 = _cache.GetColumns<TestModel>();
var columns2 = _cache.GetColumns<TestModel>();
ReferenceEquals(columns1, columns2).ShouldBeTrue();
}
[Fact]
public void GetColumns_ByType_ReturnsCorrectColumns()
{
var columns = _cache.GetColumns(typeof(TestModel));
columns.Count.ShouldBe(3);
}
}