using System.Collections.ObjectModel;
using System.Reflection;
using System.Text.Json;
using NewLife.Studio.Core.DTOs;
using NewLife.Studio.Modules.DataStudio.ViewModels;
using Xunit;
namespace NewLife.Studio.Modules.DataStudio.Tests;
public class ResultGridViewModelTests
{
private static ColumnInfo[] SampleColumns => new[]
{
new ColumnInfo { Name = "Id", DataType = "INTEGER", IsPrimaryKey = true, Ordinal = 0 },
new ColumnInfo { Name = "Name", DataType = "TEXT", Ordinal = 1 },
new ColumnInfo { Name = "Age", DataType = "INTEGER", Ordinal = 2 }
};
private static List<object?[]> SampleRows => new()
{
new object?[] { 1, "Alice", 30 },
new object?[] { 2, "Bob", 25 },
new object?[] { 3, "Charlie", 35 },
};
private static QueryResult CreateResult(int rowCount = 3, long elapsedMs = 150, bool truncated = false, string? error = null)
{
return new QueryResult
{
Columns = SampleColumns,
Rows = SampleRows,
RowCount = rowCount,
ElapsedMs = elapsedMs,
Truncated = truncated,
Error = error
};
}
[Fact]
public void InitialState_HasEmptyState()
{
var vm = new ResultGridViewModel();
Assert.Empty(vm.Columns);
Assert.Empty(vm.Rows);
Assert.Equal(0, vm.ElapsedMs);
Assert.Equal(0, vm.RowCount);
Assert.False(vm.IsTruncated);
Assert.Null(vm.Error);
}
[Fact]
public void SetResult_PopulatesColumns()
{
var vm = new ResultGridViewModel();
vm.SetResult(CreateResult());
Assert.Equal(3, vm.Columns.Count);
Assert.Equal("Id", vm.Columns[0].Name);
Assert.Equal("Name", vm.Columns[1].Name);
Assert.Equal("Age", vm.Columns[2].Name);
}
[Fact]
public void SetResult_PopulatesRows()
{
var vm = new ResultGridViewModel();
vm.SetResult(CreateResult());
Assert.Equal(3, vm.Rows.Count);
Assert.Equal(1, vm.Rows[0][0]);
Assert.Equal("Alice", vm.Rows[0][1]);
Assert.Equal(30, vm.Rows[0][2]);
}
[Fact]
public void SetResult_SetsElapsedMs()
{
var vm = new ResultGridViewModel();
vm.SetResult(CreateResult(elapsedMs: 250));
Assert.Equal(250, vm.ElapsedMs);
}
[Fact]
public void SetResult_SetsRowCount()
{
var vm = new ResultGridViewModel();
vm.SetResult(CreateResult(rowCount: 100));
Assert.Equal(100, vm.RowCount);
}
[Fact]
public void SetResult_IsTruncated_WhenTrue()
{
var vm = new ResultGridViewModel();
vm.SetResult(CreateResult(truncated: true));
Assert.True(vm.IsTruncated);
}
[Fact]
public void TruncatedWarning_WhenIsTruncated_ReturnsWarning()
{
var vm = new ResultGridViewModel();
vm.SetResult(CreateResult(truncated: true));
Assert.Equal("(结果已裁剪)", vm.TruncatedWarning);
}
[Fact]
public void TruncatedWarning_WhenNotTruncated_ReturnsEmpty()
{
var vm = new ResultGridViewModel();
vm.SetResult(CreateResult(truncated: false));
Assert.Equal("", vm.TruncatedWarning);
}
[Fact]
public void HasError_WhenErrorIsSet_ReturnsTrue()
{
var vm = new ResultGridViewModel();
vm.SetResult(CreateResult(error: "Connection failed"));
Assert.True(vm.HasError);
Assert.Equal("Connection failed", vm.Error);
}
[Fact]
public void HasError_WhenErrorIsNull_ReturnsFalse()
{
var vm = new ResultGridViewModel();
vm.SetResult(CreateResult(error: null));
Assert.False(vm.HasError);
}
[Fact]
public void HasError_WhenErrorIsEmpty_ReturnsFalse()
{
var vm = new ResultGridViewModel();
vm.SetResult(CreateResult(error: ""));
Assert.False(vm.HasError);
}
[Fact]
public void HasRows_WhenDataExists_ReturnsTrue()
{
var vm = new ResultGridViewModel();
vm.SetResult(CreateResult());
Assert.True(vm.HasRows);
}
[Fact]
public void HasRows_WhenEmpty_ReturnsFalse()
{
var vm = new ResultGridViewModel();
vm.SetResult(new QueryResult { Columns = [], Rows = [] });
Assert.False(vm.HasRows);
}
[Fact]
public void HasRows_WhenOnlyColumns_ReturnsFalse()
{
var vm = new ResultGridViewModel();
vm.SetResult(new QueryResult { Columns = SampleColumns, Rows = [] });
Assert.False(vm.HasRows);
}
[Fact]
public void HasRows_WhenOnlyRows_ReturnsFalse()
{
var vm = new ResultGridViewModel();
vm.SetResult(new QueryResult { Columns = [], Rows = SampleRows });
Assert.False(vm.HasRows);
}
[Fact]
public void ElapsedText_ReturnsFormattedString()
{
var vm = new ResultGridViewModel();
vm.SetResult(CreateResult(elapsedMs: 42));
Assert.Equal("耗时: 42ms", vm.ElapsedText);
}
[Fact]
public void RowCountText_ReturnsFormattedString()
{
var vm = new ResultGridViewModel();
vm.SetResult(CreateResult(rowCount: 7));
Assert.Equal("行数: 7", vm.RowCountText);
}
[Fact]
public async Task ExportCsvAsync_CreatesValidCsvFile()
{
var vm = new ResultGridViewModel();
vm.SetResult(CreateResult());
var filePath = Path.Combine(Path.GetTempPath(), $"test_export_{Guid.NewGuid():N}.csv");
try
{
await vm.ExportCsvAsync(filePath);
Assert.True(File.Exists(filePath));
var content = await File.ReadAllTextAsync(filePath);
var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(4, lines.Length); // header + 3 rows
Assert.Equal("Id,Name,Age", lines[0].TrimEnd('\r'));
Assert.Equal("1,Alice,30", lines[1].TrimEnd('\r'));
Assert.Equal("2,Bob,25", lines[2].TrimEnd('\r'));
Assert.Equal("3,Charlie,35", lines[3].TrimEnd('\r'));
}
finally
{
if (File.Exists(filePath))
File.Delete(filePath);
}
}
[Fact]
public async Task ExportCsvAsync_HandlesCommasInField()
{
var vm = new ResultGridViewModel();
vm.SetResult(new QueryResult
{
Columns = new[] { new ColumnInfo { Name = "Name", DataType = "TEXT" } },
Rows = new List<object?[]> { new object?[] { "Doe, John" } },
RowCount = 1
});
var filePath = Path.Combine(Path.GetTempPath(), $"test_export_comma_{Guid.NewGuid():N}.csv");
try
{
await vm.ExportCsvAsync(filePath);
var content = await File.ReadAllTextAsync(filePath);
var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(2, lines.Length);
Assert.Equal("\"Doe, John\"", lines[1].TrimEnd('\r'));
}
finally
{
if (File.Exists(filePath))
File.Delete(filePath);
}
}
[Fact]
public async Task ExportCsvAsync_HandlesQuotesInField()
{
var vm = new ResultGridViewModel();
vm.SetResult(new QueryResult
{
Columns = new[] { new ColumnInfo { Name = "Name", DataType = "TEXT" } },
Rows = new List<object?[]> { new object?[] { "He said \"Hello\"" } },
RowCount = 1
});
var filePath = Path.Combine(Path.GetTempPath(), $"test_export_quote_{Guid.NewGuid():N}.csv");
try
{
await vm.ExportCsvAsync(filePath);
var content = await File.ReadAllTextAsync(filePath);
var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(2, lines.Length);
Assert.Equal("\"He said \"\"Hello\"\"\"", lines[1].TrimEnd('\r'));
}
finally
{
if (File.Exists(filePath))
File.Delete(filePath);
}
}
[Fact]
public async Task ExportCsvAsync_HandlesNewlinesInField()
{
var vm = new ResultGridViewModel();
vm.SetResult(new QueryResult
{
Columns = new[] { new ColumnInfo { Name = "Description", DataType = "TEXT" } },
Rows = new List<object?[]> { new object?[] { "Line1\nLine2" } },
RowCount = 1
});
var filePath = Path.Combine(Path.GetTempPath(), $"test_export_newline_{Guid.NewGuid():N}.csv");
try
{
await vm.ExportCsvAsync(filePath);
var content = await File.ReadAllTextAsync(filePath);
// A newline inside a quoted field: the field is wrapped in quotes.
// SB.AppendLine uses Environment.NewLine (\r\n on Windows).
Assert.Contains("Line1", content);
Assert.Contains("Line2", content);
Assert.Contains("\"Line1\nLine2\"", content);
}
finally
{
if (File.Exists(filePath))
File.Delete(filePath);
}
}
[Fact]
public async Task ExportCsvAsync_HandlesMixedSpecialChars()
{
var vm = new ResultGridViewModel();
vm.SetResult(new QueryResult
{
Columns = new[] {
new ColumnInfo { Name = "Col1", DataType = "TEXT" },
new ColumnInfo { Name = "Col2", DataType = "TEXT" }
},
Rows = new List<object?[]> { new object?[] { "a,b", "x\"y" } },
RowCount = 1
});
var filePath = Path.Combine(Path.GetTempPath(), $"test_export_mixed_{Guid.NewGuid():N}.csv");
try
{
await vm.ExportCsvAsync(filePath);
var content = await File.ReadAllTextAsync(filePath);
var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(2, lines.Length);
Assert.Equal("\"a,b\",\"x\"\"y\"", lines[1].TrimEnd('\r'));
}
finally
{
if (File.Exists(filePath))
File.Delete(filePath);
}
}
[Fact]
public async Task ExportCsvAsync_NoSpecialChars_NoQuoting()
{
var vm = new ResultGridViewModel();
vm.SetResult(new QueryResult
{
Columns = new[] { new ColumnInfo { Name = "Simple", DataType = "TEXT" } },
Rows = new List<object?[]> { new object?[] { "PlainText" } },
RowCount = 1
});
var filePath = Path.Combine(Path.GetTempPath(), $"test_export_plain_{Guid.NewGuid():N}.csv");
try
{
await vm.ExportCsvAsync(filePath);
var content = await File.ReadAllTextAsync(filePath);
var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(2, lines.Length);
Assert.Equal("PlainText", lines[1].TrimEnd('\r'));
}
finally
{
if (File.Exists(filePath))
File.Delete(filePath);
}
}
[Fact]
public async Task ExportJsonAsync_CreatesValidJson()
{
var vm = new ResultGridViewModel();
vm.SetResult(CreateResult());
var filePath = Path.Combine(Path.GetTempPath(), $"test_export_{Guid.NewGuid():N}.json");
try
{
await vm.ExportJsonAsync(filePath);
Assert.True(File.Exists(filePath));
var content = await File.ReadAllTextAsync(filePath);
var list = JsonSerializer.Deserialize<List<Dictionary<string, JsonElement>>>(content);
Assert.NotNull(list);
Assert.Equal(3, list!.Count);
// Note: CamelCasePropertyNamingPolicy only affects property names,
// not dictionary keys. Dictionary keys retain their original casing.
Assert.Equal(1, list[0]["Id"].GetInt32());
Assert.Equal("Alice", list[0]["Name"].GetString());
Assert.Equal(30, list[0]["Age"].GetInt32());
}
finally
{
if (File.Exists(filePath))
File.Delete(filePath);
}
}
[Fact]
public async Task ExportJsonAsync_UsesIndentedFormat()
{
var vm = new ResultGridViewModel();
vm.SetResult(new QueryResult
{
Columns = new[] { new ColumnInfo { Name = "FullName", DataType = "TEXT" } },
Rows = new List<object?[]> { new object?[] { "Test" } },
RowCount = 1
});
var filePath = Path.Combine(Path.GetTempPath(), $"test_export_camel_{Guid.NewGuid():N}.json");
try
{
await vm.ExportJsonAsync(filePath);
var content = await File.ReadAllTextAsync(filePath);
// Verify it is valid JSON with indented formatting
Assert.Contains("FullName", content);
Assert.Contains("Test", content);
Assert.Contains("\n", content); // indented → multi-line
// Re-parse to confirm valid JSON
var list = JsonSerializer.Deserialize<List<Dictionary<string, JsonElement>>>(content);
Assert.NotNull(list);
Assert.Single(list!);
Assert.Equal("Test", list[0]["FullName"].GetString());
}
finally
{
if (File.Exists(filePath))
File.Delete(filePath);
}
}
[Fact]
public void EscapeCsvField_WithComma_WrapsInQuotes()
{
var vm = new ResultGridViewModel();
vm.SetResult(new QueryResult
{
Columns = new[] { new ColumnInfo { Name = "Field", DataType = "TEXT" } },
Rows = new List<object?[]> { new object?[] { "a,b" } },
RowCount = 1
});
var field = EscapeCsvFieldViaReflection("a,b");
Assert.Equal("\"a,b\"", field);
}
[Fact]
public void EscapeCsvField_WithQuote_DoublesQuoteAndWraps()
{
var field = EscapeCsvFieldViaReflection("a\"b");
Assert.Equal("\"a\"\"b\"", field);
}
[Fact]
public void EscapeCsvField_WithNewline_WrapsInQuotes()
{
var field = EscapeCsvFieldViaReflection("a\nb");
Assert.Equal("\"a\nb\"", field);
}
[Fact]
public void EscapeCsvField_WithCarriageReturn_WrapsInQuotes()
{
var field = EscapeCsvFieldViaReflection("a\rb");
Assert.Equal("\"a\rb\"", field);
}
[Fact]
public void EscapeCsvField_PlainText_ReturnsSame()
{
var field = EscapeCsvFieldViaReflection("HelloWorld");
Assert.Equal("HelloWorld", field);
}
[Fact]
public void EscapeCsvField_EmptyString_ReturnsEmpty()
{
var field = EscapeCsvFieldViaReflection("");
Assert.Equal("", field);
}
[Fact]
public void EscapeCsvField_NumericText_ReturnsSame()
{
var field = EscapeCsvFieldViaReflection("12345");
Assert.Equal("12345", field);
}
[Fact]
public async Task ExportCsvAsync_EmptyColumns_OnlyWritesEmptyLine()
{
var vm = new ResultGridViewModel();
vm.SetResult(new QueryResult { Columns = [], Rows = [], RowCount = 0 });
var filePath = Path.Combine(Path.GetTempPath(), $"test_export_empty_{Guid.NewGuid():N}.csv");
try
{
await vm.ExportCsvAsync(filePath);
var content = await File.ReadAllTextAsync(filePath);
var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
// Header with no columns yields an empty string then AppendLine adds \r\n
// Single empty line
Assert.Single(lines);
}
finally
{
if (File.Exists(filePath))
File.Delete(filePath);
}
}
[Fact]
public async Task ExportJsonAsync_EmptyData_ProducesEmptyJsonArray()
{
var vm = new ResultGridViewModel();
vm.SetResult(new QueryResult { Columns = [], Rows = [], RowCount = 0 });
var filePath = Path.Combine(Path.GetTempPath(), $"test_export_empty_{Guid.NewGuid():N}.json");
try
{
await vm.ExportJsonAsync(filePath);
var content = await File.ReadAllTextAsync(filePath);
Assert.Equal("[]", content.Trim());
}
finally
{
if (File.Exists(filePath))
File.Delete(filePath);
}
}
private static string EscapeCsvFieldViaReflection(string field)
{
var method = typeof(ResultGridViewModel).GetMethod(
"EscapeCsvField",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(method);
return (string)method!.Invoke(null, new object[] { field })!;
}
}
|