feat: 初始化NewLife Studio项目,完成基础框架与数据管理模块
何炳宏 authored at 2026-05-26 12:09:09
16.41 KiB
NewLife.Studio
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 })!;
    }
}