From 240b407f5dbda7c73077f45bf1884d1257c69cce Mon Sep 17 00:00:00 2001
From: luckydizzier <134082331+luckydizzier@users.noreply.github.com>
Date: Wed, 1 Oct 2025 22:23:59 +0200
Subject: [PATCH] feat(demo-app): deliver catalog shell milestone
---
src/demo/XBase.Demo.App/App.axaml | 29 +++
.../ViewModels/IndexListItemViewModel.cs | 26 +++
.../ViewModels/ShellViewModel.cs | 196 ++++++++++++++----
.../ViewModels/TableListItemViewModel.cs | 32 +++
.../ViewModels/TablePageViewModel.cs | 63 ++++++
.../XBase.Demo.App/Views/MainWindow.axaml | 56 ++++-
.../XBase.Demo.App/Views/MainWindow.axaml.cs | 27 +++
src/demo/tasks.md | 10 +-
.../ShellViewModelTests.cs | 92 ++++++++
.../XBase.Demo.App.Tests.csproj | 28 +++
xBase.sln | 7 +
11 files changed, 516 insertions(+), 50 deletions(-)
create mode 100644 src/demo/XBase.Demo.App/ViewModels/IndexListItemViewModel.cs
create mode 100644 src/demo/XBase.Demo.App/ViewModels/TableListItemViewModel.cs
create mode 100644 src/demo/XBase.Demo.App/ViewModels/TablePageViewModel.cs
create mode 100644 tests/XBase.Demo.App.Tests/ShellViewModelTests.cs
create mode 100644 tests/XBase.Demo.App.Tests/XBase.Demo.App.Tests.csproj
diff --git a/src/demo/XBase.Demo.App/App.axaml b/src/demo/XBase.Demo.App/App.axaml
index ed4fd08..b85c23e 100644
--- a/src/demo/XBase.Demo.App/App.axaml
+++ b/src/demo/XBase.Demo.App/App.axaml
@@ -1,5 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/demo/XBase.Demo.App/ViewModels/IndexListItemViewModel.cs b/src/demo/XBase.Demo.App/ViewModels/IndexListItemViewModel.cs
new file mode 100644
index 0000000..3d182d3
--- /dev/null
+++ b/src/demo/XBase.Demo.App/ViewModels/IndexListItemViewModel.cs
@@ -0,0 +1,26 @@
+using System;
+using XBase.Demo.Domain.Catalog;
+
+namespace XBase.Demo.App.ViewModels;
+
+///
+/// Represents an index entry for the selected table.
+///
+public sealed class IndexListItemViewModel
+{
+ public IndexListItemViewModel(IndexModel model)
+ {
+ Model = model ?? throw new ArgumentNullException(nameof(model));
+ Name = model.Name;
+ Expression = model.Expression;
+ Order = model.Order;
+ }
+
+ public IndexModel Model { get; }
+
+ public string Name { get; }
+
+ public string Expression { get; }
+
+ public int Order { get; }
+}
diff --git a/src/demo/XBase.Demo.App/ViewModels/ShellViewModel.cs b/src/demo/XBase.Demo.App/ViewModels/ShellViewModel.cs
index d2df08e..2bd724e 100644
--- a/src/demo/XBase.Demo.App/ViewModels/ShellViewModel.cs
+++ b/src/demo/XBase.Demo.App/ViewModels/ShellViewModel.cs
@@ -1,10 +1,13 @@
using System;
using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
using System.Reactive;
using System.Reactive.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using ReactiveUI;
+using System.Reactive.Threading.Tasks;
using XBase.Demo.Domain.Catalog;
using XBase.Demo.Domain.Diagnostics;
using XBase.Demo.Domain.Services;
@@ -19,8 +22,9 @@ public class ShellViewModel : ReactiveObject
private readonly ILogger _logger;
private string? _catalogRoot;
- private string? _tableSummary;
private bool _isBusy;
+ private TableListItemViewModel? _selectedTable;
+ private string? _catalogStatus;
public ShellViewModel(
ITableCatalogService catalogService,
@@ -33,11 +37,49 @@ public ShellViewModel(
_telemetrySink = telemetrySink;
_logger = logger;
- OpenCatalogCommand = ReactiveCommand.CreateFromTask(ExecuteOpenCatalogAsync);
+ Tables = new ReadOnlyObservableCollection(_tables);
+ TelemetryEvents = new ReadOnlyObservableCollection(_telemetryEvents);
+ TablePage = new TablePageViewModel();
+
+ var isIdle = this.WhenAnyValue(x => x.IsBusy).Select(isBusy => !isBusy);
+
+ OpenCatalogCommand = ReactiveCommand.CreateFromTask(ExecuteOpenCatalogAsync, isIdle);
+ OpenCatalogCommand.Subscribe(OnCatalogLoaded);
OpenCatalogCommand.ThrownExceptions.Subscribe(OnOpenCatalogFault);
+
+ BrowseCatalogCommand = ReactiveCommand.CreateFromTask(ExecuteBrowseCatalogAsync, isIdle);
+ BrowseCatalogCommand.ThrownExceptions.Subscribe(ex => _logger.LogError(ex, "Catalog browse failed"));
+
+ var canRefresh = this.WhenAnyValue(x => x.CatalogRoot, x => x.IsBusy, (root, busy) => !busy && !string.IsNullOrWhiteSpace(root));
+ RefreshCatalogCommand = ReactiveCommand.CreateFromTask(ExecuteRefreshCatalogAsync, canRefresh);
+
+ LoadTableCommand = ReactiveCommand.CreateFromTask(ExecuteLoadTableAsync);
+ LoadTableCommand.ThrownExceptions.Subscribe(OnLoadTableFault);
+
+ Observable.CombineLatest(
+ OpenCatalogCommand.IsExecuting.StartWith(false),
+ LoadTableCommand.IsExecuting.StartWith(false),
+ (isCatalogExecuting, isTableExecuting) => isCatalogExecuting || isTableExecuting)
+ .DistinctUntilChanged()
+ .Subscribe(executing => IsBusy = executing);
+
+ this.WhenAnyValue(x => x.SelectedTable)
+ .WhereNotNull()
+ .InvokeCommand(LoadTableCommand);
}
- public ReactiveCommand OpenCatalogCommand { get; }
+ private readonly ObservableCollection _tables = new();
+ private readonly ObservableCollection _telemetryEvents = new();
+
+ public Interaction SelectCatalogFolderInteraction { get; } = new();
+
+ public ReactiveCommand OpenCatalogCommand { get; }
+
+ public ReactiveCommand BrowseCatalogCommand { get; }
+
+ public ReactiveCommand RefreshCatalogCommand { get; }
+
+ public ReactiveCommand LoadTableCommand { get; }
public string? CatalogRoot
{
@@ -45,10 +87,10 @@ public string? CatalogRoot
private set => this.RaiseAndSetIfChanged(ref _catalogRoot, value);
}
- public string? TableSummary
+ public string? CatalogStatus
{
- get => _tableSummary;
- private set => this.RaiseAndSetIfChanged(ref _tableSummary, value);
+ get => _catalogStatus;
+ private set => this.RaiseAndSetIfChanged(ref _catalogStatus, value);
}
public bool IsBusy
@@ -57,41 +99,52 @@ public bool IsBusy
private set => this.RaiseAndSetIfChanged(ref _isBusy, value);
}
- private async Task ExecuteOpenCatalogAsync(string rootPath)
+ public ReadOnlyObservableCollection Tables { get; }
+
+ public TableListItemViewModel? SelectedTable
{
- if (string.IsNullOrWhiteSpace(rootPath))
+ get => _selectedTable;
+ set => this.RaiseAndSetIfChanged(ref _selectedTable, value);
+ }
+
+ public TablePageViewModel TablePage { get; }
+
+ public ReadOnlyObservableCollection TelemetryEvents { get; }
+
+ private async Task ExecuteBrowseCatalogAsync()
+ {
+ var folder = await SelectCatalogFolderInteraction.Handle(Unit.Default);
+ if (string.IsNullOrWhiteSpace(folder))
{
- return;
+ return Unit.Default;
}
- try
+ await OpenCatalogCommand.Execute(folder).ToTask();
+ return Unit.Default;
+ }
+
+ private async Task ExecuteRefreshCatalogAsync()
+ {
+ var root = CatalogRoot;
+ if (string.IsNullOrWhiteSpace(root))
{
- IsBusy = true;
- CatalogRoot = rootPath;
-
- var catalog = await _catalogService.LoadCatalogAsync(rootPath);
- var payload = new Dictionary
- {
- ["root"] = catalog.RootPath,
- ["tableCount"] = catalog.Tables.Count
- };
- _telemetrySink.Publish(new DemoTelemetryEvent("CatalogLoaded", DateTimeOffset.UtcNow, payload));
-
- if (catalog.Tables.Count > 0)
- {
- var firstTable = catalog.Tables[0];
- var page = await _pageService.LoadPageAsync(firstTable, new TablePageRequest(0, 25));
- TableSummary = $"{catalog.Tables.Count} tables discovered. Preview rows: {page.Rows.Count} from {firstTable.Name}.";
- }
- else
- {
- TableSummary = "Catalog scanned successfully with no tables detected.";
- }
+ return Unit.Default;
}
- finally
+
+ await OpenCatalogCommand.Execute(root).ToTask();
+ return Unit.Default;
+ }
+
+ private async Task ExecuteOpenCatalogAsync(string rootPath)
+ {
+ if (string.IsNullOrWhiteSpace(rootPath))
{
- IsBusy = false;
+ return new CatalogModel(string.Empty, Array.Empty());
}
+
+ CatalogRoot = rootPath;
+ var catalog = await _catalogService.LoadCatalogAsync(rootPath);
+ return catalog;
}
private void OnOpenCatalogFault(Exception exception)
@@ -101,7 +154,80 @@ private void OnOpenCatalogFault(Exception exception)
{
["message"] = exception.Message
};
- _telemetrySink.Publish(new DemoTelemetryEvent("CatalogLoadFailed", DateTimeOffset.UtcNow, payload));
- TableSummary = "Catalog load failed. Review diagnostics for details.";
+ RecordTelemetry(new DemoTelemetryEvent("CatalogLoadFailed", DateTimeOffset.UtcNow, payload));
+ CatalogStatus = "Catalog load failed. Review diagnostics for details.";
+ _tables.Clear();
+ SelectedTable = null;
+ }
+
+ private void OnCatalogLoaded(CatalogModel catalog)
+ {
+ CatalogRoot = catalog.RootPath;
+ _tables.Clear();
+
+ foreach (var table in catalog.Tables.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
+ {
+ _tables.Add(new TableListItemViewModel(table));
+ }
+
+ CatalogStatus = _tables.Count > 0
+ ? $"{_tables.Count} tables discovered. Select a table to preview rows."
+ : "Catalog scanned successfully with no tables detected.";
+
+ if (_tables.Count == 0)
+ {
+ var emptyRows = Array.Empty>();
+ TablePage.Apply(new TablePage(emptyRows, 0, 0, TablePage.PageSize));
+ }
+
+ var payload = new Dictionary
+ {
+ ["root"] = catalog.RootPath,
+ ["tableCount"] = catalog.Tables.Count
+ };
+ RecordTelemetry(new DemoTelemetryEvent("CatalogLoaded", DateTimeOffset.UtcNow, payload));
+
+ SelectedTable = _tables.FirstOrDefault();
+ }
+
+ private async Task ExecuteLoadTableAsync(TableListItemViewModel table)
+ {
+ var request = new TablePageRequest(0, 25);
+ var page = await _pageService.LoadPageAsync(table.Model, request);
+ TablePage.Apply(page);
+
+ CatalogStatus = _tables.Count > 0
+ ? $"{_tables.Count} tables available. Showing {table.Name}."
+ : CatalogStatus;
+
+ var payload = new Dictionary
+ {
+ ["table"] = table.Name,
+ ["indexes"] = table.Indexes.Count,
+ ["rows"] = page.Rows.Count
+ };
+ RecordTelemetry(new DemoTelemetryEvent("TablePreviewLoaded", DateTimeOffset.UtcNow, payload));
+ }
+
+ private void OnLoadTableFault(Exception exception)
+ {
+ _logger.LogError(exception, "Table preview load failed");
+ var payload = new Dictionary
+ {
+ ["message"] = exception.Message
+ };
+ RecordTelemetry(new DemoTelemetryEvent("TablePreviewFailed", DateTimeOffset.UtcNow, payload));
+ CatalogStatus = "Table preview failed. Check diagnostics for more information.";
+ }
+
+ private void RecordTelemetry(DemoTelemetryEvent telemetryEvent)
+ {
+ _telemetrySink.Publish(telemetryEvent);
+
+ _telemetryEvents.Insert(0, telemetryEvent);
+ while (_telemetryEvents.Count > 64)
+ {
+ _telemetryEvents.RemoveAt(_telemetryEvents.Count - 1);
+ }
}
}
diff --git a/src/demo/XBase.Demo.App/ViewModels/TableListItemViewModel.cs b/src/demo/XBase.Demo.App/ViewModels/TableListItemViewModel.cs
new file mode 100644
index 0000000..291667c
--- /dev/null
+++ b/src/demo/XBase.Demo.App/ViewModels/TableListItemViewModel.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using XBase.Demo.Domain.Catalog;
+
+namespace XBase.Demo.App.ViewModels;
+
+///
+/// Represents a single table entry displayed in the catalog browser.
+///
+public sealed class TableListItemViewModel
+{
+ public TableListItemViewModel(TableModel model)
+ {
+ Model = model ?? throw new ArgumentNullException(nameof(model));
+ Name = model.Name;
+ Path = model.Path;
+ Indexes = model.Indexes
+ .OrderBy(index => index.Order)
+ .ThenBy(index => index.Name, StringComparer.OrdinalIgnoreCase)
+ .Select(index => new IndexListItemViewModel(index))
+ .ToArray();
+ }
+
+ public TableModel Model { get; }
+
+ public string Name { get; }
+
+ public string Path { get; }
+
+ public IReadOnlyList Indexes { get; }
+}
diff --git a/src/demo/XBase.Demo.App/ViewModels/TablePageViewModel.cs b/src/demo/XBase.Demo.App/ViewModels/TablePageViewModel.cs
new file mode 100644
index 0000000..04fca26
--- /dev/null
+++ b/src/demo/XBase.Demo.App/ViewModels/TablePageViewModel.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Collections.Generic;
+using ReactiveUI;
+using XBase.Demo.Domain.Catalog;
+
+namespace XBase.Demo.App.ViewModels;
+
+///
+/// Represents the placeholder table page information shown in the browser.
+///
+public sealed class TablePageViewModel : ReactiveObject
+{
+ private IReadOnlyList> _rows = Array.Empty>();
+ private long _totalCount;
+ private int _pageNumber;
+ private int _pageSize = 25;
+
+ public IReadOnlyList> Rows
+ {
+ get => _rows;
+ private set => this.RaiseAndSetIfChanged(ref _rows, value);
+ }
+
+ public long TotalCount
+ {
+ get => _totalCount;
+ private set => this.RaiseAndSetIfChanged(ref _totalCount, value);
+ }
+
+ public int PageNumber
+ {
+ get => _pageNumber;
+ private set => this.RaiseAndSetIfChanged(ref _pageNumber, value);
+ }
+
+ public int PageSize
+ {
+ get => _pageSize;
+ private set => this.RaiseAndSetIfChanged(ref _pageSize, value);
+ }
+
+ public string Summary
+ {
+ get
+ {
+ var pageIndex = PageNumber + 1;
+ var totalPages = PageSize <= 0 ? 1 : Math.Max(1, (int)Math.Ceiling(TotalCount / (double)PageSize));
+ return $"Page {pageIndex} of {totalPages} · Showing {Rows.Count} rows (Total {TotalCount}).";
+ }
+ }
+
+ public void Apply(TablePage page)
+ {
+ ArgumentNullException.ThrowIfNull(page);
+
+ Rows = page.Rows;
+ TotalCount = page.TotalCount;
+ PageNumber = page.PageNumber;
+ PageSize = page.PageSize;
+
+ this.RaisePropertyChanged(nameof(Summary));
+ }
+}
diff --git a/src/demo/XBase.Demo.App/Views/MainWindow.axaml b/src/demo/XBase.Demo.App/Views/MainWindow.axaml
index f0fce1d..5505dd8 100644
--- a/src/demo/XBase.Demo.App/Views/MainWindow.axaml
+++ b/src/demo/XBase.Demo.App/Views/MainWindow.axaml
@@ -4,19 +4,55 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
x:Class="XBase.Demo.App.Views.MainWindow"
- Width="1024"
- Height="640"
+ Width="1100"
+ Height="680"
Title="xBase Demo Shell"
mc:Ignorable="d">
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/demo/XBase.Demo.App/Views/MainWindow.axaml.cs b/src/demo/XBase.Demo.App/Views/MainWindow.axaml.cs
index 76766da..ccd0e25 100644
--- a/src/demo/XBase.Demo.App/Views/MainWindow.axaml.cs
+++ b/src/demo/XBase.Demo.App/Views/MainWindow.axaml.cs
@@ -1,7 +1,12 @@
using System;
+using System.Linq;
+using Avalonia.Controls;
using Avalonia.Markup.Xaml;
+using Avalonia.Platform.Storage;
using Avalonia.ReactiveUI;
using Microsoft.Extensions.DependencyInjection;
+using ReactiveUI;
+using System.Reactive.Disposables;
using XBase.Demo.App.ViewModels;
namespace XBase.Demo.App.Views;
@@ -17,6 +22,28 @@ public MainWindow(ShellViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
+
+ this.WhenActivated(disposables =>
+ {
+ viewModel.SelectCatalogFolderInteraction.RegisterHandler(async interaction =>
+ {
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel?.StorageProvider is not IStorageProvider storageProvider)
+ {
+ interaction.SetOutput(null);
+ return;
+ }
+
+ var results = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
+ {
+ AllowMultiple = false
+ });
+
+ var selected = results?.FirstOrDefault();
+ interaction.SetOutput(selected?.Path.LocalPath);
+ })
+ .DisposeWith(disposables);
+ });
}
private static ShellViewModel ResolveViewModel()
diff --git a/src/demo/tasks.md b/src/demo/tasks.md
index dc7cb04..6e991c8 100644
--- a/src/demo/tasks.md
+++ b/src/demo/tasks.md
@@ -3,11 +3,11 @@
Source blueprint: [docs/demo/avalonia-reactiveui-demo-plan.md](../../docs/demo/avalonia-reactiveui-demo-plan.md)
## Milestone M1 – App Shell & Catalog Browser
-- [ ] Scaffold Avalonia desktop host with ReactiveUI composition root.
-- [ ] Implement catalog scanning service and basic directory selection flow.
-- [ ] Build table/index listing view models with paging placeholders.
-- [ ] Wire diagnostics/logging sinks and sample telemetry events.
-- [ ] Add integration smoke harness once basic navigation is ready.
+- [x] Scaffold Avalonia desktop host with ReactiveUI composition root.
+- [x] Implement catalog scanning service and basic directory selection flow.
+- [x] Build table/index listing view models with paging placeholders.
+- [x] Wire diagnostics/logging sinks and sample telemetry events.
+- [x] Add integration smoke harness once basic navigation is ready.
## Milestone M2 – DDL & Index Basics
- [ ] Generate DDL preview scripts for create/alter/drop operations.
diff --git a/tests/XBase.Demo.App.Tests/ShellViewModelTests.cs b/tests/XBase.Demo.App.Tests/ShellViewModelTests.cs
new file mode 100644
index 0000000..6f0a750
--- /dev/null
+++ b/tests/XBase.Demo.App.Tests/ShellViewModelTests.cs
@@ -0,0 +1,92 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Reactive.Threading.Tasks;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Xunit;
+using XBase.Demo.App.DependencyInjection;
+using XBase.Demo.App.ViewModels;
+
+namespace XBase.Demo.App.Tests;
+
+public sealed class ShellViewModelTests
+{
+ [Fact]
+ public async Task OpenCatalogCommand_LoadsTablesAndTelemetry()
+ {
+ using var catalog = new TempCatalog();
+ catalog.AddTable("CUSTOMERS");
+
+ using var host = CreateHost();
+ await host.StartAsync();
+
+ try
+ {
+ var viewModel = host.Services.GetRequiredService();
+
+ await viewModel.OpenCatalogCommand.Execute(catalog.Path).ToTask();
+
+ Assert.Equal(catalog.Path, viewModel.CatalogRoot);
+ var table = Assert.Single(viewModel.Tables);
+ Assert.Equal("CUSTOMERS", table.Name);
+ Assert.NotNull(viewModel.SelectedTable);
+ Assert.Equal(1, table.Indexes.Count);
+
+ Assert.Contains(viewModel.TelemetryEvents, evt => evt.Name == "CatalogLoaded");
+ Assert.Contains(viewModel.TelemetryEvents, evt => evt.Name == "TablePreviewLoaded");
+
+ Assert.Equal(0, viewModel.TablePage.TotalCount);
+ Assert.Equal(25, viewModel.TablePage.PageSize);
+ Assert.Equal(0, viewModel.TablePage.PageNumber);
+ Assert.Contains("Page 1", viewModel.TablePage.Summary, StringComparison.Ordinal);
+ }
+ finally
+ {
+ await host.StopAsync();
+ }
+ }
+
+ private static IHost CreateHost()
+ {
+ var builder = Host.CreateApplicationBuilder();
+ builder.Services.AddDemoApp();
+ return builder.Build();
+ }
+
+ private sealed class TempCatalog : IDisposable
+ {
+ private readonly DirectoryInfo _directory;
+
+ public TempCatalog()
+ {
+ _directory = Directory.CreateTempSubdirectory("xbase-demo-");
+ }
+
+ public string Path => _directory.FullName;
+
+ public void AddTable(string tableName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(tableName);
+
+ var tablePath = System.IO.Path.Combine(Path, tableName + ".dbf");
+ File.WriteAllBytes(tablePath, Array.Empty());
+
+ var indexPath = System.IO.Path.Combine(Path, tableName + ".ntx");
+ File.WriteAllBytes(indexPath, Array.Empty());
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ _directory.Delete(true);
+ }
+ catch
+ {
+ // best effort cleanup
+ }
+ }
+ }
+}
diff --git a/tests/XBase.Demo.App.Tests/XBase.Demo.App.Tests.csproj b/tests/XBase.Demo.App.Tests/XBase.Demo.App.Tests.csproj
new file mode 100644
index 0000000..6cf34ad
--- /dev/null
+++ b/tests/XBase.Demo.App.Tests/XBase.Demo.App.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
diff --git a/xBase.sln b/xBase.sln
index 34af54a..59f65dc 100644
--- a/xBase.sln
+++ b/xBase.sln
@@ -45,6 +45,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XBase.Demo.Diagnostics", "s
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XBase.Demo.App", "src\demo\XBase.Demo.App\XBase.Demo.App.csproj", "{1E80FE92-7098-4406-914A-24CE7CBED4AC}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XBase.Demo.App.Tests", "tests\XBase.Demo.App.Tests\XBase.Demo.App.Tests.csproj", "{E4D84052-3FD6-4AB7-9F64-64F04786B606}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -126,6 +128,10 @@ Global
{1E80FE92-7098-4406-914A-24CE7CBED4AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1E80FE92-7098-4406-914A-24CE7CBED4AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1E80FE92-7098-4406-914A-24CE7CBED4AC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E4D84052-3FD6-4AB7-9F64-64F04786B606}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E4D84052-3FD6-4AB7-9F64-64F04786B606}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E4D84052-3FD6-4AB7-9F64-64F04786B606}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E4D84052-3FD6-4AB7-9F64-64F04786B606}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{B2E0A9D6-E39E-44E6-9762-B1A5C50D29B6} = {DB34B0F0-48E5-441D-B062-A525C37FD21D}
@@ -147,5 +153,6 @@ Global
{25B73D78-0BB2-480D-8E6B-CAF36DE60690} = {8216414C-D70B-47BF-8296-90A08F02D90C}
{4C647419-449C-4B34-828A-103D6B7F8AAE} = {8216414C-D70B-47BF-8296-90A08F02D90C}
{1E80FE92-7098-4406-914A-24CE7CBED4AC} = {8216414C-D70B-47BF-8296-90A08F02D90C}
+ {E4D84052-3FD6-4AB7-9F64-64F04786B606} = {8944503E-D8CB-4870-A577-E3C76244B9F6}
EndGlobalSection
EndGlobal