From 2412b5f1006f99099e59cac9d8b47d5bb84f74d9 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 20:07:52 -0500 Subject: [PATCH 01/13] feat: add FmTable/FmField model with full XML round-trip for schema editing --- src/SharpFM/Schema/Model/FieldEnums.cs | 48 +++ src/SharpFM/Schema/Model/FmField.cs | 296 ++++++++++++++++++ src/SharpFM/Schema/Model/FmTable.cs | 100 ++++++ tests/SharpFM.Tests/Schema/FmFieldTests.cs | 282 +++++++++++++++++ tests/SharpFM.Tests/Schema/FmTableTests.cs | 98 ++++++ .../Schema/TableRoundTripTests.cs | 160 ++++++++++ 6 files changed, 984 insertions(+) create mode 100644 src/SharpFM/Schema/Model/FieldEnums.cs create mode 100644 src/SharpFM/Schema/Model/FmField.cs create mode 100644 src/SharpFM/Schema/Model/FmTable.cs create mode 100644 tests/SharpFM.Tests/Schema/FmFieldTests.cs create mode 100644 tests/SharpFM.Tests/Schema/FmTableTests.cs create mode 100644 tests/SharpFM.Tests/Schema/TableRoundTripTests.cs diff --git a/src/SharpFM/Schema/Model/FieldEnums.cs b/src/SharpFM/Schema/Model/FieldEnums.cs new file mode 100644 index 0000000..480c3e7 --- /dev/null +++ b/src/SharpFM/Schema/Model/FieldEnums.cs @@ -0,0 +1,48 @@ +namespace SharpFM.Schema.Model; + +public enum FieldDataType +{ + Text, + Number, + Date, + Time, + TimeStamp, + Binary +} + +public enum FieldKind +{ + Normal, + Calculated, + Summary +} + +public enum AutoEnterType +{ + Serial, + UUID, + CreationDate, + ModificationDate, + CreationTime, + ModificationTime, + ConstantData, + Calculation, + Lookup +} + +public enum SummaryOperation +{ + Sum, + Average, + Count, + Minimum, + Maximum, + StdDeviation +} + +public enum FieldIndexing +{ + None, + Minimal, + All +} diff --git a/src/SharpFM/Schema/Model/FmField.cs b/src/SharpFM/Schema/Model/FmField.cs new file mode 100644 index 0000000..7b36e64 --- /dev/null +++ b/src/SharpFM/Schema/Model/FmField.cs @@ -0,0 +1,296 @@ +using System; +using System.Xml.Linq; + +namespace SharpFM.Schema.Model; + +public class FmField +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public FieldDataType DataType { get; set; } = FieldDataType.Text; + public FieldKind Kind { get; set; } = FieldKind.Normal; + public int Repetitions { get; set; } = 1; + public string? Comment { get; set; } + + // Validation + public bool NotEmpty { get; set; } + public bool Unique { get; set; } + public bool Existing { get; set; } + public string? MaxDataLength { get; set; } + public string? ValidationCalculation { get; set; } + public string? ErrorMessage { get; set; } + public string? RangeMin { get; set; } + public string? RangeMax { get; set; } + + // Auto-enter + public AutoEnterType? AutoEnter { get; set; } + public bool AllowEditing { get; set; } + public string? AutoEnterValue { get; set; } + + // Calculated fields + public string? Calculation { get; set; } + public bool AlwaysEvaluate { get; set; } + public string? CalculationContext { get; set; } + + // Summary fields + public SummaryOperation? SummaryOp { get; set; } + public string? SummaryTargetField { get; set; } + + // Storage + public bool IsGlobal { get; set; } + public FieldIndexing Indexing { get; set; } = FieldIndexing.None; + + public static FmField FromXml(XElement el) + { + var field = new FmField + { + Id = int.TryParse(el.Attribute("id")?.Value, out var id) ? id : 0, + Name = el.Attribute("name")?.Value ?? "", + DataType = ParseDataType(el.Attribute("dataType")?.Value), + Kind = ParseFieldKind(el.Attribute("fieldType")?.Value), + Repetitions = int.TryParse(el.Attribute("maxRepetition")?.Value, out var rep) ? rep : 1, + Comment = el.Element("Comment")?.Value, + }; + + // Calculation + var calcEl = el.Element("Calculation"); + if (calcEl != null) + { + field.Calculation = calcEl.Value; + field.AlwaysEvaluate = calcEl.Attribute("alwaysEvaluate")?.Value == "True"; + field.CalculationContext = calcEl.Attribute("table")?.Value; + } + + // Summary + var summaryEl = el.Element("SummaryField"); + if (summaryEl != null) + { + field.SummaryOp = ParseSummaryOp(summaryEl.Attribute("operation")?.Value); + field.SummaryTargetField = summaryEl.Element("SummaryField")?.Attribute("name")?.Value; + } + + // Auto-enter + var autoEl = el.Element("AutoEnter"); + if (autoEl != null) + { + field.AutoEnter = ParseAutoEnterType(autoEl.Attribute("value")?.Value); + field.AllowEditing = autoEl.Attribute("allowEditing")?.Value == "True"; + + var serialEl = autoEl.Element("Serial"); + if (serialEl != null) + field.AutoEnterValue = serialEl.Attribute("nextSerialNumber")?.Value; + + var constEl = autoEl.Element("ConstantData"); + if (constEl != null) + field.AutoEnterValue = constEl.Value; + + var autoCalcEl = autoEl.Element("Calculation"); + if (autoCalcEl != null) + field.AutoEnterValue = autoCalcEl.Value; + } + + // Validation + var valEl = el.Element("Validation"); + if (valEl != null) + { + field.NotEmpty = valEl.Element("NotEmpty")?.Attribute("value")?.Value == "True"; + field.Unique = valEl.Element("Unique")?.Attribute("value")?.Value == "True"; + field.Existing = valEl.Element("Existing")?.Attribute("value")?.Value == "True"; + + var maxLen = valEl.Element("MaxDataLength"); + if (maxLen != null) + field.MaxDataLength = maxLen.Attribute("length")?.Value; + + var rangeEl = valEl.Element("Range"); + if (rangeEl != null) + { + field.RangeMin = rangeEl.Element("MinimumValue")?.Value; + field.RangeMax = rangeEl.Element("MaximumValue")?.Value; + } + + field.ValidationCalculation = valEl.Element("StrictValidation")?.Value; + field.ErrorMessage = valEl.Element("ErrorMessage")?.Value; + } + + // Storage + var storageEl = el.Element("Storage"); + if (storageEl != null) + { + field.IsGlobal = storageEl.Attribute("global")?.Value == "True"; + field.Indexing = ParseIndexing(storageEl.Attribute("indexed")?.Value); + } + + return field; + } + + public XElement ToXml() + { + var el = new XElement("Field", + new XAttribute("id", Id), + new XAttribute("name", Name), + new XAttribute("dataType", DataType.ToString()), + new XAttribute("fieldType", Kind.ToString())); + + if (Repetitions > 1) + el.Add(new XAttribute("maxRepetition", Repetitions)); + + if (!string.IsNullOrEmpty(Comment)) + el.Add(new XElement("Comment", Comment)); + + // Calculation + if (!string.IsNullOrEmpty(Calculation)) + { + var calcEl = XElement.Parse($""); + if (AlwaysEvaluate) + calcEl.Add(new XAttribute("alwaysEvaluate", "True")); + if (!string.IsNullOrEmpty(CalculationContext)) + calcEl.Add(new XAttribute("table", CalculationContext)); + el.Add(calcEl); + } + + // Summary + if (SummaryOp.HasValue) + { + var summaryEl = new XElement("SummaryField", + new XAttribute("operation", SummaryOp.Value.ToString())); + if (!string.IsNullOrEmpty(SummaryTargetField)) + summaryEl.Add(new XElement("SummaryField", new XAttribute("name", SummaryTargetField))); + el.Add(summaryEl); + } + + // Auto-enter + if (AutoEnter.HasValue) + { + var autoEl = new XElement("AutoEnter", + new XAttribute("value", AutoEnterTypeToString(AutoEnter.Value))); + if (AllowEditing) + autoEl.Add(new XAttribute("allowEditing", "True")); + + switch (AutoEnter.Value) + { + case AutoEnterType.Serial: + autoEl.Add(new XElement("Serial", + new XAttribute("nextSerialNumber", AutoEnterValue ?? "1"))); + break; + case AutoEnterType.ConstantData: + autoEl.Add(XElement.Parse($"")); + break; + case AutoEnterType.Calculation: + autoEl.Add(XElement.Parse($"")); + break; + } + + el.Add(autoEl); + } + + // Validation + if (NotEmpty || Unique || Existing || MaxDataLength != null || + ValidationCalculation != null || ErrorMessage != null || + RangeMin != null || RangeMax != null) + { + var valEl = new XElement("Validation"); + if (NotEmpty) valEl.Add(new XElement("NotEmpty", new XAttribute("value", "True"))); + if (Unique) valEl.Add(new XElement("Unique", new XAttribute("value", "True"))); + if (Existing) valEl.Add(new XElement("Existing", new XAttribute("value", "True"))); + + if (MaxDataLength != null) + valEl.Add(new XElement("MaxDataLength", new XAttribute("length", MaxDataLength))); + + if (RangeMin != null || RangeMax != null) + { + var rangeEl = new XElement("Range"); + if (RangeMin != null) + rangeEl.Add(XElement.Parse($"")); + if (RangeMax != null) + rangeEl.Add(XElement.Parse($"")); + valEl.Add(rangeEl); + } + + if (ValidationCalculation != null) + valEl.Add(XElement.Parse($"")); + if (ErrorMessage != null) + valEl.Add(XElement.Parse($"")); + + el.Add(valEl); + } + + // Storage + if (IsGlobal || Indexing != FieldIndexing.None) + { + var storageEl = new XElement("Storage"); + if (IsGlobal) storageEl.Add(new XAttribute("global", "True")); + if (Indexing != FieldIndexing.None) + storageEl.Add(new XAttribute("indexed", Indexing.ToString())); + el.Add(storageEl); + } + + return el; + } + + // --- Parsing helpers --- + + private static FieldDataType ParseDataType(string? value) => value switch + { + "Text" => FieldDataType.Text, + "Number" => FieldDataType.Number, + "Date" => FieldDataType.Date, + "Time" => FieldDataType.Time, + "TimeStamp" => FieldDataType.TimeStamp, + "Binary" => FieldDataType.Binary, + _ => FieldDataType.Text + }; + + private static FieldKind ParseFieldKind(string? value) => value switch + { + "Normal" => FieldKind.Normal, + "Calculated" => FieldKind.Calculated, + "Summary" => FieldKind.Summary, + _ => FieldKind.Normal + }; + + private static SummaryOperation? ParseSummaryOp(string? value) => value switch + { + "Sum" => SummaryOperation.Sum, + "Average" => SummaryOperation.Average, + "Count" => SummaryOperation.Count, + "Minimum" => SummaryOperation.Minimum, + "Maximum" => SummaryOperation.Maximum, + "StdDeviation" => SummaryOperation.StdDeviation, + _ => null + }; + + private static AutoEnterType? ParseAutoEnterType(string? value) => value switch + { + "SerialNumber" or "Serial" => AutoEnterType.Serial, + "UUID" => AutoEnterType.UUID, + "CreationDate" => AutoEnterType.CreationDate, + "ModificationDate" => AutoEnterType.ModificationDate, + "CreationTime" => AutoEnterType.CreationTime, + "ModificationTime" => AutoEnterType.ModificationTime, + "ConstantData" => AutoEnterType.ConstantData, + "Calculation" => AutoEnterType.Calculation, + "Lookup" => AutoEnterType.Lookup, + _ => null + }; + + private static string AutoEnterTypeToString(AutoEnterType type) => type switch + { + AutoEnterType.Serial => "SerialNumber", + AutoEnterType.UUID => "UUID", + AutoEnterType.CreationDate => "CreationDate", + AutoEnterType.ModificationDate => "ModificationDate", + AutoEnterType.CreationTime => "CreationTime", + AutoEnterType.ModificationTime => "ModificationTime", + AutoEnterType.ConstantData => "ConstantData", + AutoEnterType.Calculation => "Calculation", + AutoEnterType.Lookup => "Lookup", + _ => "ConstantData" + }; + + private static FieldIndexing ParseIndexing(string? value) => value switch + { + "Minimal" => FieldIndexing.Minimal, + "All" => FieldIndexing.All, + _ => FieldIndexing.None + }; +} diff --git a/src/SharpFM/Schema/Model/FmTable.cs b/src/SharpFM/Schema/Model/FmTable.cs new file mode 100644 index 0000000..4c7c3e5 --- /dev/null +++ b/src/SharpFM/Schema/Model/FmTable.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using NLog; + +namespace SharpFM.Schema.Model; + +public class FmTable +{ + private static readonly Logger Log = LogManager.GetCurrentClassLogger(); + + public string Name { get; set; } = ""; + public int? Id { get; set; } + public List Fields { get; } + + public FmTable(string name, List? fields = null) + { + Name = name; + Fields = fields ?? new List(); + } + + public static FmTable FromXml(string xml) + { + if (string.IsNullOrWhiteSpace(xml)) + return new FmTable(""); + + XDocument doc; + try { doc = XDocument.Parse(xml); } + catch (XmlException ex) + { + Log.Error(ex, "Failed to parse table XML"); + return new FmTable(""); + } + + var root = doc.Root; + if (root == null) return new FmTable(""); + + var baseTable = root.Element("BaseTable") ?? root; + var tableName = baseTable.Attribute("name")?.Value ?? ""; + var tableId = int.TryParse(baseTable.Attribute("id")?.Value, out var id) ? id : (int?)null; + + var fields = baseTable.Elements("Field") + .Select(FmField.FromXml) + .ToList(); + + return new FmTable(tableName, fields) { Id = tableId }; + } + + public string ToXml() + { + var root = new XElement("fmxmlsnippet", new XAttribute("type", "FMObjectList")); + var baseTable = new XElement("BaseTable", new XAttribute("name", Name)); + if (Id.HasValue) + baseTable.Add(new XAttribute("id", Id.Value)); + + foreach (var field in Fields) + baseTable.Add(field.ToXml()); + + root.Add(baseTable); + + return PrettyPrint(root.ToString()); + } + + public void AddField(FmField field) + { + Fields.Add(field); + } + + public void RemoveField(FmField field) + { + Fields.Remove(field); + } + + private static string PrettyPrint(string xml) + { + try + { + var element = XElement.Parse(xml); + var sb = new StringBuilder(); + var settings = new XmlWriterSettings + { + OmitXmlDeclaration = true, + Indent = true, + NewLineOnAttributes = false + }; + using (var writer = XmlWriter.Create(sb, settings)) + { + element.Save(writer); + } + return sb.ToString(); + } + catch + { + return xml; + } + } +} diff --git a/tests/SharpFM.Tests/Schema/FmFieldTests.cs b/tests/SharpFM.Tests/Schema/FmFieldTests.cs new file mode 100644 index 0000000..f54eb2b --- /dev/null +++ b/tests/SharpFM.Tests/Schema/FmFieldTests.cs @@ -0,0 +1,282 @@ +using System.Xml.Linq; +using SharpFM.Schema.Model; +using Xunit; + +namespace SharpFM.Tests.Schema; + +public class FmFieldTests +{ + private static FmField Parse(string xml) => FmField.FromXml(XElement.Parse(xml)); + + [Fact] + public void FromXml_NormalTextField() + { + var f = Parse(""); + Assert.Equal("FirstName", f.Name); + Assert.Equal(FieldDataType.Text, f.DataType); + Assert.Equal(FieldKind.Normal, f.Kind); + Assert.Equal(1, f.Id); + } + + [Fact] + public void FromXml_NumberField() + { + var f = Parse(""); + Assert.Equal(FieldDataType.Number, f.DataType); + } + + [Fact] + public void FromXml_DateField() + { + var f = Parse(""); + Assert.Equal(FieldDataType.Date, f.DataType); + } + + [Fact] + public void FromXml_CalculatedField_HasCalculation() + { + var f = Parse( + "" + + ""); + Assert.Equal(FieldKind.Calculated, f.Kind); + Assert.Equal("Qty * Price", f.Calculation); + } + + [Fact] + public void FromXml_CalculatedField_AlwaysEvaluate() + { + var f = Parse( + "" + + ""); + Assert.True(f.AlwaysEvaluate); + Assert.Equal("Orders", f.CalculationContext); + } + + [Fact] + public void FromXml_SummaryField_HasOperation() + { + var f = Parse( + "" + + "" + + "" + + ""); + Assert.Equal(FieldKind.Summary, f.Kind); + Assert.Equal(SummaryOperation.Sum, f.SummaryOp); + Assert.Equal("Items::Price", f.SummaryTargetField); + } + + [Fact] + public void FromXml_AutoEnterSerial() + { + var f = Parse( + "" + + "" + + "" + + ""); + Assert.Equal(AutoEnterType.Serial, f.AutoEnter); + Assert.False(f.AllowEditing); + Assert.Equal("1001", f.AutoEnterValue); + } + + [Fact] + public void FromXml_AutoEnterUUID() + { + var f = Parse( + "" + + ""); + Assert.Equal(AutoEnterType.UUID, f.AutoEnter); + } + + [Fact] + public void FromXml_AutoEnterCalculation() + { + var f = Parse( + "" + + "" + + "" + + ""); + Assert.Equal(AutoEnterType.Calculation, f.AutoEnter); + Assert.Equal("First & \" \" & Last", f.AutoEnterValue); + } + + [Fact] + public void FromXml_AutoEnterCreationDate() + { + var f = Parse( + "" + + ""); + Assert.Equal(AutoEnterType.CreationDate, f.AutoEnter); + } + + [Fact] + public void FromXml_ValidationNotEmpty() + { + var f = Parse( + "" + + ""); + Assert.True(f.NotEmpty); + } + + [Fact] + public void FromXml_ValidationUnique() + { + var f = Parse( + "" + + ""); + Assert.True(f.Unique); + } + + [Fact] + public void FromXml_ValidationRange() + { + var f = Parse( + "" + + "0150"); + Assert.Equal("0", f.RangeMin); + Assert.Equal("150", f.RangeMax); + } + + [Fact] + public void FromXml_ValidationCalculation() + { + var f = Parse( + "" + + ""); + Assert.Equal("Length(Self) = 5", f.ValidationCalculation); + } + + [Fact] + public void FromXml_ValidationMaxLength() + { + var f = Parse( + "" + + ""); + Assert.Equal("500", f.MaxDataLength); + } + + [Fact] + public void FromXml_ValidationErrorMessage() + { + var f = Parse( + "" + + ""); + Assert.Equal("Please enter a value", f.ErrorMessage); + } + + [Fact] + public void FromXml_GlobalField() + { + var f = Parse( + "" + + ""); + Assert.True(f.IsGlobal); + } + + [Fact] + public void FromXml_IndexedField() + { + var f = Parse( + "" + + ""); + Assert.Equal(FieldIndexing.All, f.Indexing); + } + + [Fact] + public void FromXml_WithComment() + { + var f = Parse( + "" + + "Primary key"); + Assert.Equal("Primary key", f.Comment); + } + + [Fact] + public void FromXml_WithRepetitions() + { + var f = Parse(""); + Assert.Equal(5, f.Repetitions); + } + + [Fact] + public void ToXml_NormalField() + { + var f = new FmField { Id = 1, Name = "Test", DataType = FieldDataType.Text, Kind = FieldKind.Normal }; + var xml = f.ToXml(); + Assert.Equal("Test", xml.Attribute("name")?.Value); + Assert.Equal("Text", xml.Attribute("dataType")?.Value); + Assert.Equal("Normal", xml.Attribute("fieldType")?.Value); + } + + [Fact] + public void ToXml_CalculatedField() + { + var f = new FmField + { + Id = 2, Name = "Total", DataType = FieldDataType.Number, + Kind = FieldKind.Calculated, Calculation = "Qty * Price" + }; + var xml = f.ToXml(); + Assert.Equal("Calculated", xml.Attribute("fieldType")?.Value); + Assert.Equal("Qty * Price", xml.Element("Calculation")?.Value); + } + + [Fact] + public void ToXml_SummaryField() + { + var f = new FmField + { + Id = 3, Name = "Sum", DataType = FieldDataType.Number, + Kind = FieldKind.Summary, SummaryOp = SummaryOperation.Sum, + SummaryTargetField = "Items::Amount" + }; + var xml = f.ToXml(); + var summary = xml.Element("SummaryField"); + Assert.NotNull(summary); + Assert.Equal("Sum", summary!.Attribute("operation")?.Value); + } + + [Fact] + public void ToXml_FieldWithAutoEnter() + { + var f = new FmField + { + Id = 4, Name = "ID", DataType = FieldDataType.Number, + AutoEnter = AutoEnterType.Serial, AutoEnterValue = "100" + }; + var xml = f.ToXml(); + var autoEl = xml.Element("AutoEnter"); + Assert.NotNull(autoEl); + Assert.Equal("100", autoEl!.Element("Serial")?.Attribute("nextSerialNumber")?.Value); + } + + [Fact] + public void ToXml_FieldWithValidation() + { + var f = new FmField + { + Id = 5, Name = "Email", DataType = FieldDataType.Text, + NotEmpty = true, Unique = true, ErrorMessage = "Required" + }; + var xml = f.ToXml(); + var valEl = xml.Element("Validation"); + Assert.NotNull(valEl); + Assert.Equal("True", valEl!.Element("NotEmpty")?.Attribute("value")?.Value); + Assert.Equal("True", valEl.Element("Unique")?.Attribute("value")?.Value); + Assert.Equal("Required", valEl.Element("ErrorMessage")?.Value); + } + + [Fact] + public void ToXml_FieldWithStorage() + { + var f = new FmField + { + Id = 6, Name = "G", DataType = FieldDataType.Text, + IsGlobal = true, Indexing = FieldIndexing.Minimal + }; + var xml = f.ToXml(); + var storage = xml.Element("Storage"); + Assert.NotNull(storage); + Assert.Equal("True", storage!.Attribute("global")?.Value); + Assert.Equal("Minimal", storage.Attribute("indexed")?.Value); + } +} diff --git a/tests/SharpFM.Tests/Schema/FmTableTests.cs b/tests/SharpFM.Tests/Schema/FmTableTests.cs new file mode 100644 index 0000000..47967a4 --- /dev/null +++ b/tests/SharpFM.Tests/Schema/FmTableTests.cs @@ -0,0 +1,98 @@ +using System.Xml.Linq; +using SharpFM.Schema.Model; +using Xunit; + +namespace SharpFM.Tests.Schema; + +public class FmTableTests +{ + private static string Wrap(string inner) => + $"{inner}"; + + [Fact] + public void FromXml_ParsesTableName() + { + var xml = Wrap(""); + var table = FmTable.FromXml(xml); + Assert.Equal("Invoices", table.Name); + Assert.Equal(1, table.Id); + } + + [Fact] + public void FromXml_ParsesAllFields() + { + var xml = Wrap( + "" + + "" + + "" + + ""); + var table = FmTable.FromXml(xml); + Assert.Equal(2, table.Fields.Count); + } + + [Fact] + public void FromXml_EmptyTable() + { + var xml = Wrap(""); + var table = FmTable.FromXml(xml); + Assert.Equal("Empty", table.Name); + Assert.Empty(table.Fields); + } + + [Fact] + public void FromXml_MixedFieldTypes() + { + var xml = Wrap( + "" + + "" + + "" + + "" + + "" + + "" + + ""); + var table = FmTable.FromXml(xml); + Assert.Equal(3, table.Fields.Count); + Assert.Equal(FieldKind.Normal, table.Fields[0].Kind); + Assert.Equal(FieldKind.Calculated, table.Fields[1].Kind); + Assert.Equal(FieldKind.Summary, table.Fields[2].Kind); + } + + [Fact] + public void ToXml_OutputIsValid() + { + var table = new FmTable("Test"); + table.AddField(new FmField { Id = 1, Name = "F1", DataType = FieldDataType.Text }); + var xml = table.ToXml(); + XDocument.Parse(xml); // should not throw + } + + [Fact] + public void ToXml_PreservesTableName() + { + var table = new FmTable("Contacts") { Id = 5 }; + var xml = table.ToXml(); + var doc = XDocument.Parse(xml); + var bt = doc.Root!.Element("BaseTable"); + Assert.Equal("Contacts", bt?.Attribute("name")?.Value); + Assert.Equal("5", bt?.Attribute("id")?.Value); + } + + [Fact] + public void AddField_IncreasesCount() + { + var table = new FmTable("T"); + Assert.Empty(table.Fields); + table.AddField(new FmField { Name = "New" }); + Assert.Single(table.Fields); + } + + [Fact] + public void RemoveField_DecreasesCount() + { + var field = new FmField { Name = "ToRemove" }; + var table = new FmTable("T", new() { field }); + Assert.Single(table.Fields); + table.RemoveField(field); + Assert.Empty(table.Fields); + } +} diff --git a/tests/SharpFM.Tests/Schema/TableRoundTripTests.cs b/tests/SharpFM.Tests/Schema/TableRoundTripTests.cs new file mode 100644 index 0000000..e55f8fe --- /dev/null +++ b/tests/SharpFM.Tests/Schema/TableRoundTripTests.cs @@ -0,0 +1,160 @@ +using System.Xml.Linq; +using SharpFM.Schema.Model; +using Xunit; + +namespace SharpFM.Tests.Schema; + +public class TableRoundTripTests +{ + private static string Wrap(string inner) => + $"{inner}"; + + [Fact] + public void NormalFields_RoundTrip() + { + var xml = Wrap( + "" + + "" + + "" + + ""); + var table = FmTable.FromXml(xml); + var output = table.ToXml(); + var table2 = FmTable.FromXml(output); + + Assert.Equal(table.Name, table2.Name); + Assert.Equal(table.Fields.Count, table2.Fields.Count); + Assert.Equal("FirstName", table2.Fields[0].Name); + Assert.Equal(FieldDataType.Number, table2.Fields[1].DataType); + } + + [Fact] + public void CalculatedField_RoundTrip() + { + var xml = Wrap( + "" + + "" + + "" + + ""); + var table = FmTable.FromXml(xml); + var table2 = FmTable.FromXml(table.ToXml()); + + Assert.Equal("Qty * Price", table2.Fields[0].Calculation); + Assert.True(table2.Fields[0].AlwaysEvaluate); + } + + [Fact] + public void SummaryField_RoundTrip() + { + var xml = Wrap( + "" + + "" + + "" + + "" + + "" + + ""); + var table = FmTable.FromXml(xml); + var table2 = FmTable.FromXml(table.ToXml()); + + Assert.Equal(SummaryOperation.Average, table2.Fields[0].SummaryOp); + Assert.Equal("T::Score", table2.Fields[0].SummaryTargetField); + } + + [Fact] + public void AutoEnterFields_RoundTrip() + { + var xml = Wrap( + "" + + "" + + "" + + "" + + "" + + ""); + var table = FmTable.FromXml(xml); + var table2 = FmTable.FromXml(table.ToXml()); + + Assert.Equal(AutoEnterType.Serial, table2.Fields[0].AutoEnter); + Assert.True(table2.Fields[0].AllowEditing); + Assert.Equal("500", table2.Fields[0].AutoEnterValue); + } + + [Fact] + public void ValidationRules_RoundTrip() + { + var xml = Wrap( + "" + + "" + + "" + + "" + + "" + + "" + + "1100" + + "" + + "" + + ""); + var table = FmTable.FromXml(xml); + var table2 = FmTable.FromXml(table.ToXml()); + + var f = table2.Fields[0]; + Assert.True(f.NotEmpty); + Assert.True(f.Unique); + Assert.Equal("200", f.MaxDataLength); + Assert.Equal("1", f.RangeMin); + Assert.Equal("100", f.RangeMax); + Assert.Equal("Invalid", f.ErrorMessage); + } + + [Fact] + public void StorageOptions_RoundTrip() + { + var xml = Wrap( + "" + + "" + + "" + + ""); + var table = FmTable.FromXml(xml); + var table2 = FmTable.FromXml(table.ToXml()); + + Assert.True(table2.Fields[0].IsGlobal); + Assert.Equal(FieldIndexing.Minimal, table2.Fields[0].Indexing); + } + + [Fact] + public void MixedTable_RoundTrip() + { + var xml = Wrap( + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "Internal notes" + + "" + + ""); + + var table = FmTable.FromXml(xml); + Assert.Equal(6, table.Fields.Count); + Assert.Equal("Orders", table.Name); + Assert.Equal(42, table.Id); + + var table2 = FmTable.FromXml(table.ToXml()); + Assert.Equal(table.Fields.Count, table2.Fields.Count); + Assert.Equal("Orders", table2.Name); + Assert.Equal(42, table2.Id); + + // Spot check individual fields survived + Assert.Equal(AutoEnterType.Serial, table2.Fields[0].AutoEnter); + Assert.True(table2.Fields[0].NotEmpty); + Assert.Equal(FieldKind.Calculated, table2.Fields[3].Kind); + Assert.Equal("Sum(LineItems::Amount)", table2.Fields[3].Calculation); + Assert.Equal(SummaryOperation.Sum, table2.Fields[4].SummaryOp); + Assert.Equal("Internal notes", table2.Fields[5].Comment); + } +} From 47711a925cb883ac02d6a42fb72c1a1dfe2ec2ae Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Mon, 30 Mar 2026 20:14:17 -0500 Subject: [PATCH 02/13] feat: add DataGrid table editor with three-way editor visibility --- src/SharpFM/MainWindow.axaml | 20 +++- src/SharpFM/Schema/Editor/RelayCommand.cs | 23 +++++ .../Schema/Editor/TableEditorControl.axaml | 92 +++++++++++++++++++ .../Schema/Editor/TableEditorControl.axaml.cs | 17 ++++ .../Schema/Editor/TableEditorViewModel.cs | 89 ++++++++++++++++++ src/SharpFM/ViewModels/ClipViewModel.cs | 46 ++++++++-- 6 files changed, 275 insertions(+), 12 deletions(-) create mode 100644 src/SharpFM/Schema/Editor/RelayCommand.cs create mode 100644 src/SharpFM/Schema/Editor/TableEditorControl.axaml create mode 100644 src/SharpFM/Schema/Editor/TableEditorControl.axaml.cs create mode 100644 src/SharpFM/Schema/Editor/TableEditorViewModel.cs diff --git a/src/SharpFM/MainWindow.axaml b/src/SharpFM/MainWindow.axaml index 2d8045b..650f823 100644 --- a/src/SharpFM/MainWindow.axaml +++ b/src/SharpFM/MainWindow.axaml @@ -6,6 +6,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="using:SharpFM.ViewModels" + xmlns:schema="using:SharpFM.Schema.Editor" Icon="/Assets/noun-sharp-teeth-monster-4226695.small.png" Title="SharpFM" Width="700" @@ -172,26 +173,35 @@ - + - + + + + + + + + + - + diff --git a/src/SharpFM/Schema/Editor/RelayCommand.cs b/src/SharpFM/Schema/Editor/RelayCommand.cs new file mode 100644 index 0000000..7b5b8e4 --- /dev/null +++ b/src/SharpFM/Schema/Editor/RelayCommand.cs @@ -0,0 +1,23 @@ +using System; +using System.Windows.Input; + +namespace SharpFM.Schema.Editor; + +public class RelayCommand : ICommand +{ + private readonly Action _execute; + private readonly Func? _canExecute; + + public RelayCommand(Action execute, Func? canExecute = null) + { + _execute = execute; + _canExecute = canExecute; + } + + public event EventHandler? CanExecuteChanged; + + public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true; + public void Execute(object? parameter) => _execute(parameter); + + public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); +} diff --git a/src/SharpFM/Schema/Editor/TableEditorControl.axaml b/src/SharpFM/Schema/Editor/TableEditorControl.axaml new file mode 100644 index 0000000..54873c5 --- /dev/null +++ b/src/SharpFM/Schema/Editor/TableEditorControl.axaml @@ -0,0 +1,92 @@ + + + + + + + + +