Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions OneGateApp.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@
<Project Path="OneGate.DebugProtocol/OneGate.DebugProtocol.csproj" />
<Project Path="OneGate.DebugProtocol.Tests/OneGate.DebugProtocol.Tests.csproj" />
<Project Path="OneGate.Tools/OneGate.Tools.csproj" />
<Project Path="tests/p2-10/SearchAvailability.Tests.csproj" />
</Solution>
6 changes: 6 additions & 0 deletions OneGateApp/Pages/GlobalSearchPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
<ActivityIndicator IsRunning="{Binding LoadingService.IsLoading}"
IsVisible="{Binding LoadingService.IsLoading}"
HorizontalOptions="Center" />
<Border StyleClass="Card" IsVisible="{Binding HasSearchErrors}">
<VerticalStackLayout Spacing="8">
<Label StyleClass="Secondary" Text="{Binding SearchErrorText}" />
<Button Text="{x:Static og:Strings.Retry}" Command="{Binding LoadingService}" />
</VerticalStackLayout>
</Border>
<Border StyleClass="Card" IsVisible="{Binding IsEmpty}">
<Label StyleClass="Secondary"
Text="{x:Static og:Strings.DefaultEmptyViewMessage}"
Expand Down
111 changes: 95 additions & 16 deletions OneGateApp/Pages/GlobalSearchPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public partial class GlobalSearchPage : ContentPage

readonly ApplicationDbContext dbContext;
readonly TokenManager tokenManager;
readonly object searchErrorLock = new();

bool hasLoaded;
int searchVersion;
Expand All @@ -36,7 +37,19 @@ private set
} = [];
public bool HasResults => Results.Length > 0;
public bool HasQuery => !string.IsNullOrWhiteSpace(query);
public bool IsEmpty => HasQuery && !LoadingService.IsLoading && Results.Length == 0;
public bool IsEmpty => HasQuery && !LoadingService.IsLoading && !HasSearchErrors && Results.Length == 0;
public bool HasSearchErrors => !string.IsNullOrEmpty(SearchErrorText);
public string SearchErrorText
{
get;
private set
{
field = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasSearchErrors));
OnPropertyChanged(nameof(IsEmpty));
}
} = "";

public GlobalSearchPage(IServiceProvider serviceProvider, ApplicationDbContext dbContext, TokenManager tokenManager)
{
Expand All @@ -62,27 +75,92 @@ protected override void OnAppearing()
}

async Task LoadSearchDataAsync()
{
SearchErrorText = "";
// Settings are part of the discovery boundary. A prior developer-mode
// index cannot remain usable while settings are unavailable or pending.
dappIndex = [];
UpdateResults();
await LoadSearchGroupAsync(async () =>
{
contactIndex = (await dbContext.Contacts.AsNoTracking().ToArrayAsync())
.Select(p => new GlobalSearchIndex<Contact>(p, p.Label, p.Address))
.ToArray();
}, Strings.AddressBook);

// Read settings before starting network work: TokenManager also uses the
// application DbContext, which must not run concurrent database queries.
List<int> recentDAppIds = [];
bool developerModeEnabled = false;
bool catalogSettingsLoaded = false;
await LoadSearchGroupAsync(async () =>
{
recentDAppIds = await dbContext.Settings.GetAsync<List<int>>("dapps/recent") ?? [];
developerModeEnabled = await DAppCatalogPolicy.GetDeveloperModeEnabledAsync(dbContext);
catalogSettingsLoaded = true;
}, Strings.Apps);

await Task.WhenAll(
LoadSearchGroupAsync(LoadSearchAssetsAsync, Strings.Asset),
catalogSettingsLoaded
? LoadSearchGroupAsync(() => LoadSearchDAppsAsync(recentDAppIds, developerModeEnabled), Strings.Apps)
: Task.CompletedTask);
}

async Task LoadSearchGroupAsync(Func<Task> load, string group)
{
try
{
await load();
}
catch (Exception)
{
lock (searchErrorLock)
{
string message = $"{group}: {Strings.Unavailable}";
SearchErrorText = string.IsNullOrEmpty(SearchErrorText)
? message
: $"{SearchErrorText}{Environment.NewLine}{message}";
}
}
finally
{
UpdateResults();
}
}

async Task LoadSearchAssetsAsync()
{
IReadOnlyList<AssetInfo> assets = await tokenManager.LoadAssetsAsync();
assetIndex = assets
.Select(p => new GlobalSearchIndex<AssetInfo>(p, p.Token.Symbol, p.Token.Name, p.Token.Hash.ToString()))
.ToArray();
contactIndex = (await dbContext.Contacts.AsNoTracking().ToArrayAsync())
.Select(p => new GlobalSearchIndex<Contact>(p, p.Label, p.Address))
.ToArray();
List<int> recentDAppIds = await dbContext.Settings.GetAsync<List<int>>("dapps/recent") ?? [];
bool developerModeEnabled = await DAppCatalogPolicy.GetDeveloperModeEnabledAsync(dbContext);
await DApps.LoadAsync("/api/dapps", TimeSpan.FromDays(1));
dappIndex = DApps
.Where(p => p.IsRegularApp && DAppCatalogPolicy.IsDiscoverable(p, developerModeEnabled))
.Select(p => new GlobalSearchIndex<DApp>(
}

async Task LoadSearchDAppsAsync(List<int> recentDAppIds, bool developerModeEnabled)
{
Dictionary<int, int> recentDAppRanks = [];
for (int i = 0; i < recentDAppIds.Count; i++)
recentDAppRanks.TryAdd(recentDAppIds[i], i);
try
{
await DApps.LoadAsync("/api/dapps", TimeSpan.FromDays(1));
}
finally
{
// CachedCollection has already loaded disk data even when refreshing
// the network fails. Keep those usable results alongside the error.
dappIndex = DApps
.Where(p => p.IsRegularApp && DAppCatalogPolicy.IsDiscoverable(p, developerModeEnabled))
.Select(p => new GlobalSearchIndex<DApp>(
p,
recentDAppIds.IndexOf(p.Id),
p.NameLocalizer.Localize(),
p.DescriptionLocalizer?.Localize(),
p.Url,
p.Tags is null ? null : string.Join(' ', p.Tags.Select(DApp.LocalizeTag))))
.ToArray();
recentDAppRanks.GetValueOrDefault(p.Id, -1),
p.NameLocalizer.Localize(),
p.DescriptionLocalizer?.Localize(),
p.Url,
p.Tags is null ? null : string.Join(' ', p.Tags.Select(DApp.LocalizeTag))))
.ToArray();
}
}

void OnLoaded(object? sender, EventArgs e)
Expand Down Expand Up @@ -173,6 +251,7 @@ async Task OpenResultAsync(GlobalSearchResult result)
});
break;
case GlobalSearchResultType.DApp:
if (!dappIndex.Any(p => ReferenceEquals(p.Item, result.DApp))) return;
await Commands.LaunchDApp.ExecuteAsync(result.DApp!);
break;
}
Expand Down
21 changes: 21 additions & 0 deletions tests/p2-10/SearchAvailability.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsTestProject>true</IsTestProject>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" PrivateAssets="all" />
<Compile Include="../../OneGateApp/Pages/GlobalSearchPage.xaml.cs" Link="GlobalSearchPage.cs" />
</ItemGroup>
<ItemGroup>
<!-- App relationship only; linked-source tests run without MAUI workloads. -->
<ProjectReference Include="../../OneGateApp/OneGateApp.csproj"
ReferenceOutputAssembly="false" BuildReference="false"
SkipGetTargetFrameworkProperties="true" />
</ItemGroup>
</Project>
150 changes: 150 additions & 0 deletions tests/p2-10/SearchAvailabilityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
using NeoOrder.OneGate.Data;
using NeoOrder.OneGate.Models;
using NeoOrder.OneGate.Pages;
using NeoOrder.OneGate.Services;
using Xunit;

public class SearchAvailabilityTests
{
[Fact]
public async Task SettingsFailureDropsOldAppIndexAndRetryRebuildsWithCurrentPolicy()
{
var catalog = new CachedCollection<DApp> { new() { Name = "Find developer", IsInDevelopment = true }, new() { Name = "Find safe" } };
var database = new ApplicationDbContext();
database.Settings.Values["developer"] = true;
database.Contacts.Add(new() { Label = "Find contact" });
var page = new GlobalSearchPage(new TestServices(catalog), database, new());
page.ChangeQueryForTest("Find");
await page.LoadForTestAsync();
Assert.Equal(3, page.Results.Length);
GlobalSearchResult stale = page.Results.Single(p => p.DApp?.IsInDevelopment == true);
database.Settings.Failure = new IOException("settings unavailable");
await page.LoadForTestAsync();
Assert.DoesNotContain(page.Results, p => p.Type == GlobalSearchResultType.DApp);
Assert.Contains(page.Results, p => p.Type == GlobalSearchResultType.Contact);
Assert.True(page.HasSearchErrors);
Commands.LaunchDApp.Last = null;
await page.OpenForTestAsync(stale);
Assert.Null(Commands.LaunchDApp.Last);
database.Settings.Failure = null;
database.Settings.Values["developer"] = false;
await page.LoadForTestAsync();
Assert.False(page.HasSearchErrors);
Assert.Equal(2, page.Results.Length);
Assert.DoesNotContain(page.Results, p => p.Title == "Find developer");
}

[Fact]
public async Task SettingsPendingCannotExposePreviousDeveloperIndex()
{
var catalog = new CachedCollection<DApp> { new() { Name = "Find developer", IsInDevelopment = true } };
var database = new ApplicationDbContext();
database.Settings.Values["developer"] = true;
var page = new GlobalSearchPage(new TestServices(catalog), database, new());
page.ChangeQueryForTest("Find"); await page.LoadForTestAsync(); Assert.Single(page.Results);
var blocked = new TaskCompletionSource(); database.Settings.BeforeRead = () => blocked.Task;
Task load = page.LoadForTestAsync();
try { Assert.Empty(page.Results); }
finally { blocked.SetResult(); await load; }
}

[Fact]
public async Task AssetFailureDoesNotHideLocalContactsOrApplications()
{
var catalog = new CachedCollection<DApp> { new() { Name = "Find application" } };
var database = new ApplicationDbContext();
database.Contacts.Add(new() { Label = "Find contact" });
var tokens = new TokenManager { Load = () => throw new HttpRequestException("RPC unavailable") };
var page = new GlobalSearchPage(new TestServices(catalog), database, tokens);
page.ChangeQueryForTest("Find");

await page.LoadForTestAsync();

Assert.Equal(2, page.Results.Length);
Assert.Contains(page.Results, result => result.Type == GlobalSearchResultType.Contact);
Assert.Contains(page.Results, result => result.Type == GlobalSearchResultType.DApp);
}

[Fact]
public async Task LocalResultsAppearWhileAssetRequestIsStillPending()
{
var pendingAssets = new TaskCompletionSource<IReadOnlyList<AssetInfo>>();
var catalog = new CachedCollection<DApp> { new() { Name = "Find application" } };
var database = new ApplicationDbContext();
database.Contacts.Add(new() { Label = "Find contact" });
var page = new GlobalSearchPage(new TestServices(catalog), database, new() { Load = () => pendingAssets.Task });
page.ChangeQueryForTest("Find");

Task load = page.LoadForTestAsync();
try
{
Assert.Contains(page.Results, result => result.Type == GlobalSearchResultType.Contact);
Assert.Contains(page.Results, result => result.Type == GlobalSearchResultType.DApp);
}
finally
{
pendingAssets.SetResult([]);
await load;
}
}

[Fact]
public async Task CatalogFailureRetainsCachedDAppsAndOtherGroups()
{
var catalog = new CachedCollection<DApp> { new() { Name = "Find cached application" } };
catalog.Load = () => throw new HttpRequestException("Catalog unavailable");
var page = new GlobalSearchPage(new TestServices(catalog), new(), new() { Load = () => Task.FromResult<IReadOnlyList<AssetInfo>>([new()]) });

await page.LoadForTestAsync();
page.ChangeQueryForTest("Find");
page.Dispatcher.Flush();
Assert.Single(page.Results);
page.ChangeQueryForTest("NEO");
page.Dispatcher.Flush();
Assert.Single(page.Results);
}

[Fact]
public async Task FailedGroupsDoNotPretendToBeAnEmptySuccessfulSearch()
{
var catalog = new CachedCollection<DApp> { Load = () => throw new HttpRequestException() };
var page = new GlobalSearchPage(new TestServices(catalog), new(), new() { Load = () => throw new HttpRequestException() });
page.ChangeQueryForTest("anything");

await page.LoadForTestAsync();

Assert.Empty(page.Results);
Assert.False(page.IsEmpty);
Assert.True(page.HasSearchErrors);
}

[Fact]
public async Task ConcurrentGroupFailuresKeepEachErrorMessage()
{
var catalog = new CachedCollection<DApp> { Load = () => throw new HttpRequestException() };
var tokens = new TokenManager { Load = () => throw new HttpRequestException() };
var page = new GlobalSearchPage(new TestServices(catalog), new(), tokens);
page.ChangeQueryForTest("anything");

await page.LoadForTestAsync();

Assert.Contains("Asset: Unavailable", page.SearchErrorText);
Assert.Contains("Apps: Unavailable", page.SearchErrorText);
}

[Fact]
public async Task RetryClearsErrorsAndSuccessfulNoMatchesShowsEmptyState()
{
var tokens = new TokenManager { Load = () => throw new HttpRequestException() };
var page = new GlobalSearchPage(new TestServices(new()), new(), tokens);
page.ChangeQueryForTest("missing");
await page.LoadForTestAsync();
Assert.True(page.HasSearchErrors);

tokens.Load = () => Task.FromResult<IReadOnlyList<AssetInfo>>([]);
await page.LoadForTestAsync();

Assert.False(page.HasSearchErrors);
Assert.True(page.IsEmpty);
}
}
Loading