mirror of
https://github.com/ppy/osu.git
synced 2026-05-26 10:49:57 +08:00
809298ddeb
## [Rewrite `BackgroundMusicManager` to not run into framework breakage](https://github.com/ppy/osu/commit/622216d8911832c39fa4e126b2810e4e0f46cbf7) The attempted proper fix to this was https://github.com/ppy/osu-framework/pull/6727. Unfortunately when presented with [the framework bump](https://github.com/ppy/osu/pull/37217) with that change, CI says "you're stupid" and fails on some disposal idiocy that of course is undebuggable and irreproducible: The active test run was aborted. Reason: Test host process crashed : Unhandled exception. System.AggregateException: One or more errors occurred. (Object reference not set to an instance of an object.) ---> System.NullReferenceException: Object reference not set to an instance of an object. at osu.Framework.Audio.Sample.SampleChannelBass.Dispose(Boolean disposing) at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state) --- End of stack trace from previous location --- at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state) at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread) --- End of inner exception stack trace --- at osu.Framework.Audio.AudioCollectionManager`1.UpdateChildren() at osu.Framework.Audio.AudioCollectionManager`1.UpdateChildren() at osu.Framework.Audio.AudioCollectionManager`1.UpdateChildren() at osu.Framework.Audio.AudioCollectionManager`1.UpdateChildren() at osu.Framework.Threading.AudioThread.OnExit() at osu.Framework.Threading.GameThread.setExitState(GameThreadState exitState) at osu.Framework.Threading.GameThread.RunSingleFrame() at osu.Framework.Threading.GameThread.<createThread>g__runWork|70_0() at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state) --- End of stack trace from previous location --- at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state) (https://github.com/ppy/osu/actions/runs/24019928154/job/70046733058?pr=37217#step:5:119) I no longer have the energy for any of this shit. @nekodex would appreciate if you could check that I actually haven't broken anything with the bgm here. Seems okay to me in test scenes at least. ## [Apply lowest-effort maybe-fixing changes to a bunch of flaking tests](https://github.com/ppy/osu/commit/7bd3ca4adfcce5b90add11565a13f3fe9177ad5e) None of the failures are reproducible locally, of course. I'm tired of this. If anyone else wants to subject themselves to actually investigating any of these, by all means, godspeed and good luck.
498 lines
19 KiB
C#
498 lines
19 KiB
C#
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
|
// See the LICENCE file in the repository root for full licence text.
|
|
|
|
#nullable disable
|
|
|
|
using System;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using NUnit.Framework;
|
|
using osu.Framework.Allocation;
|
|
using osu.Framework.Graphics;
|
|
using osu.Framework.Graphics.Containers;
|
|
using osu.Framework.Screens;
|
|
using osu.Framework.Testing;
|
|
using osu.Framework.Utils;
|
|
using osu.Game.Beatmaps;
|
|
using osu.Game.Database;
|
|
using osu.Game.Graphics.UserInterface;
|
|
using osu.Game.Rulesets;
|
|
using osu.Game.Rulesets.Difficulty;
|
|
using osu.Game.Rulesets.Osu;
|
|
using osu.Game.Rulesets.Scoring;
|
|
using osu.Game.Scoring;
|
|
using osu.Game.Screens;
|
|
using osu.Game.Screens.Play;
|
|
using osu.Game.Screens.Ranking;
|
|
using osu.Game.Screens.Ranking.Expanded.Accuracy;
|
|
using osu.Game.Screens.Ranking.Expanded.Statistics;
|
|
using osu.Game.Screens.Ranking.Statistics;
|
|
using osu.Game.Skinning;
|
|
using osu.Game.Tests.Resources;
|
|
using osuTK;
|
|
using osuTK.Input;
|
|
using Realms;
|
|
|
|
namespace osu.Game.Tests.Visual.Ranking
|
|
{
|
|
[TestFixture]
|
|
public partial class TestSceneResultsScreen : OsuManualInputManagerTestScene
|
|
{
|
|
[Resolved]
|
|
private BeatmapManager beatmaps { get; set; }
|
|
|
|
[Resolved]
|
|
private RealmAccess realm { get; set; }
|
|
|
|
[Resolved]
|
|
private SkinManager skins { get; set; }
|
|
|
|
protected override void LoadComplete()
|
|
{
|
|
base.LoadComplete();
|
|
|
|
realm.Run(r =>
|
|
{
|
|
var beatmapInfo = r.All<BeatmapInfo>()
|
|
.Filter($"{nameof(BeatmapInfo.Ruleset)}.{nameof(RulesetInfo.OnlineID)} = $0", 0)
|
|
.FirstOrDefault();
|
|
|
|
if (beatmapInfo != null)
|
|
Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmapInfo);
|
|
});
|
|
}
|
|
|
|
[SetUp]
|
|
public void SetUp() => Schedule(() => skins.CurrentSkinInfo.SetDefault());
|
|
|
|
[Test]
|
|
public void TestScaling()
|
|
{
|
|
// scheduling is needed as scaling the content immediately causes the entire scene to shake badly, for some odd reason.
|
|
AddSliderStep("scale", 0.5f, 1.6f, 1f, v => Schedule(() =>
|
|
{
|
|
Content.Scale = new Vector2(v);
|
|
Content.Size = new Vector2(1f / v);
|
|
}));
|
|
}
|
|
|
|
[Test]
|
|
public void TestLegacySkin()
|
|
{
|
|
AddToggleStep("toggle legacy classic skin", v =>
|
|
{
|
|
if (skins != null)
|
|
skins.CurrentSkinInfo.Value = v ? skins.DefaultClassicSkin.SkinInfo : skins.CurrentSkinInfo.Default;
|
|
});
|
|
}
|
|
|
|
private int onlineScoreID = 1;
|
|
|
|
[TestCase(1, ScoreRank.X, 0)]
|
|
[TestCase(0.9999, ScoreRank.S, 0)]
|
|
[TestCase(0.975, ScoreRank.S, 0)]
|
|
[TestCase(0.975, ScoreRank.A, 1)]
|
|
[TestCase(0.925, ScoreRank.A, 5)]
|
|
[TestCase(0.85, ScoreRank.B, 9)]
|
|
[TestCase(0.75, ScoreRank.C, 11)]
|
|
[TestCase(0.5, ScoreRank.D, 21)]
|
|
[TestCase(0.2, ScoreRank.D, 51)]
|
|
public void TestResultsWithPlayer(double accuracy, ScoreRank rank, int missCount)
|
|
{
|
|
TestResultsScreen screen = null;
|
|
|
|
loadResultsScreen(() =>
|
|
{
|
|
var score = TestResources.CreateTestScoreInfo();
|
|
|
|
score.OnlineID = onlineScoreID++;
|
|
score.HitEvents = TestSceneStatisticsPanel.CreatePositionDistributedHitEvents();
|
|
score.Accuracy = accuracy;
|
|
score.Rank = rank;
|
|
score.Statistics[HitResult.Miss] = missCount;
|
|
|
|
return screen = createResultsScreen(score);
|
|
});
|
|
AddUntilStep("wait for loaded", () => screen.IsLoaded);
|
|
AddAssert("retry overlay present", () => screen.RetryOverlay != null);
|
|
}
|
|
|
|
[Test]
|
|
public void TestResultsWithoutPlayer()
|
|
{
|
|
TestResultsScreen screen = null;
|
|
OsuScreenStack stack;
|
|
|
|
AddStep("load results", () =>
|
|
{
|
|
Child = stack = new OsuScreenStack
|
|
{
|
|
RelativeSizeAxes = Axes.Both
|
|
};
|
|
|
|
var score = TestResources.CreateTestScoreInfo();
|
|
|
|
stack.Push(screen = createResultsScreen(score));
|
|
});
|
|
AddUntilStep("wait for loaded", () => screen.IsLoaded);
|
|
AddAssert("retry overlay not present", () => screen.RetryOverlay == null);
|
|
}
|
|
|
|
[Test]
|
|
public void TestResultsForUnranked()
|
|
{
|
|
UnrankedSoloResultsScreen screen = null;
|
|
|
|
loadResultsScreen(() => screen = createUnrankedSoloResultsScreen());
|
|
AddUntilStep("wait for loaded", () => screen.IsLoaded);
|
|
AddAssert("retry overlay present", () => screen.RetryOverlay != null);
|
|
}
|
|
|
|
[Test]
|
|
public void TestResultsWithFailingRank()
|
|
{
|
|
TestResultsScreen screen = null;
|
|
|
|
loadResultsScreen(() =>
|
|
{
|
|
var score = TestResources.CreateTestScoreInfo();
|
|
|
|
score.OnlineID = onlineScoreID++;
|
|
score.HitEvents = TestSceneStatisticsPanel.CreatePositionDistributedHitEvents();
|
|
score.Rank = ScoreRank.F;
|
|
return screen = createResultsScreen(score);
|
|
});
|
|
AddUntilStep("wait for loaded", () => screen.IsLoaded);
|
|
AddAssert("retry overlay present", () => screen.RetryOverlay != null);
|
|
AddAssert("no badges displayed", () => this.ChildrenOfType<RankBadge>().All(b => !b.IsPresent));
|
|
}
|
|
|
|
[Test]
|
|
public void TestResultsWithFailingRankOnLegacySkin()
|
|
{
|
|
TestResultsScreen screen = null;
|
|
|
|
AddStep("set legacy skin", () => skins.CurrentSkinInfo.Value = skins.DefaultClassicSkin.SkinInfo);
|
|
|
|
loadResultsScreen(() =>
|
|
{
|
|
var score = TestResources.CreateTestScoreInfo();
|
|
|
|
score.OnlineID = onlineScoreID++;
|
|
score.HitEvents = TestSceneStatisticsPanel.CreatePositionDistributedHitEvents();
|
|
score.Rank = ScoreRank.F;
|
|
return screen = createResultsScreen(score);
|
|
});
|
|
AddUntilStep("wait for loaded", () => screen.IsLoaded);
|
|
AddAssert("retry overlay present", () => screen.RetryOverlay != null);
|
|
AddAssert("no badges displayed", () => this.ChildrenOfType<RankBadge>().All(b => !b.IsPresent));
|
|
}
|
|
|
|
[Test]
|
|
public void TestShowHideStatisticsViaOutsideClick()
|
|
{
|
|
TestResultsScreen screen = null;
|
|
|
|
loadResultsScreen(() => screen = createResultsScreen());
|
|
AddUntilStep("wait for load", () => this.ChildrenOfType<ScorePanelList>().Single().AllPanelsVisible);
|
|
|
|
AddStep("click expanded panel", () =>
|
|
{
|
|
var expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
|
|
InputManager.MoveMouseTo(expandedPanel);
|
|
InputManager.Click(MouseButton.Left);
|
|
});
|
|
|
|
AddAssert("statistics shown", () => this.ChildrenOfType<StatisticsPanel>().Single().State.Value == Visibility.Visible);
|
|
|
|
AddUntilStep("expanded panel at the left of the screen", () =>
|
|
{
|
|
var expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
|
|
return expandedPanel.ScreenSpaceDrawQuad.TopLeft.X - screen.ScreenSpaceDrawQuad.TopLeft.X < 150;
|
|
});
|
|
|
|
AddStep("click to right of panel", () =>
|
|
{
|
|
var expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
|
|
InputManager.MoveMouseTo(expandedPanel.ScreenSpaceDrawQuad.TopRight + new Vector2(50, 0));
|
|
InputManager.Click(MouseButton.Left);
|
|
});
|
|
|
|
AddAssert("statistics hidden", () => this.ChildrenOfType<StatisticsPanel>().Single().State.Value == Visibility.Hidden);
|
|
|
|
AddUntilStep("expanded panel in centre of screen", () =>
|
|
{
|
|
var expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
|
|
return Precision.AlmostEquals(expandedPanel.ScreenSpaceDrawQuad.Centre.X, screen.ScreenSpaceDrawQuad.Centre.X, 1);
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void TestShowHideStatistics()
|
|
{
|
|
TestResultsScreen screen = null;
|
|
|
|
loadResultsScreen(() => screen = createResultsScreen());
|
|
AddUntilStep("wait for load", () => this.ChildrenOfType<ScorePanelList>().Single().AllPanelsVisible);
|
|
|
|
AddStep("click expanded panel", () =>
|
|
{
|
|
var expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
|
|
InputManager.MoveMouseTo(expandedPanel);
|
|
InputManager.Click(MouseButton.Left);
|
|
});
|
|
|
|
AddAssert("statistics shown", () => this.ChildrenOfType<StatisticsPanel>().Single().State.Value == Visibility.Visible);
|
|
|
|
AddUntilStep("expanded panel at the left of the screen", () =>
|
|
{
|
|
var expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
|
|
return expandedPanel.ScreenSpaceDrawQuad.TopLeft.X - screen.ScreenSpaceDrawQuad.TopLeft.X < 150;
|
|
});
|
|
|
|
AddStep("click expanded panel", () =>
|
|
{
|
|
var expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
|
|
InputManager.MoveMouseTo(expandedPanel);
|
|
InputManager.Click(MouseButton.Left);
|
|
});
|
|
|
|
AddAssert("statistics hidden", () => this.ChildrenOfType<StatisticsPanel>().Single().State.Value == Visibility.Hidden);
|
|
|
|
AddUntilStep("expanded panel in centre of screen", () =>
|
|
{
|
|
var expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
|
|
return Precision.AlmostEquals(expandedPanel.ScreenSpaceDrawQuad.Centre.X, screen.ScreenSpaceDrawQuad.Centre.X, 1);
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public void TestShowStatisticsAndClickOtherPanel()
|
|
{
|
|
TestResultsScreen screen = null;
|
|
|
|
loadResultsScreen(() => screen = createResultsScreen());
|
|
AddUntilStep("wait for load", () => this.ChildrenOfType<ScorePanelList>().Single().AllPanelsVisible);
|
|
|
|
ScorePanel expandedPanel = null;
|
|
ScorePanel contractedPanel = null;
|
|
|
|
AddUntilStep("retrieve expanded panel",
|
|
() => expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded),
|
|
() => Is.Not.Null);
|
|
AddUntilStep("retrieve contracted panel",
|
|
() => contractedPanel = this.ChildrenOfType<ScorePanel>().First(p => p.State == PanelState.Contracted && p.ScreenSpaceDrawQuad.TopLeft.X > screen.ScreenSpaceDrawQuad.TopLeft.X),
|
|
() => Is.Not.Null);
|
|
|
|
AddStep("click expanded panel then contracted panel", () =>
|
|
{
|
|
InputManager.MoveMouseTo(expandedPanel);
|
|
InputManager.Click(MouseButton.Left);
|
|
|
|
InputManager.MoveMouseTo(contractedPanel);
|
|
InputManager.Click(MouseButton.Left);
|
|
});
|
|
|
|
AddAssert("statistics shown", () => this.ChildrenOfType<StatisticsPanel>().Single().State.Value == Visibility.Visible);
|
|
|
|
AddAssert("contracted panel still contracted", () => contractedPanel.State == PanelState.Contracted);
|
|
AddAssert("expanded panel still expanded", () => expandedPanel.State == PanelState.Expanded);
|
|
}
|
|
|
|
[Test]
|
|
public void TestFetchScoresAfterShowingStatistics()
|
|
{
|
|
DelayedFetchResultsScreen screen = null;
|
|
|
|
var tcs = new TaskCompletionSource<bool>();
|
|
|
|
loadResultsScreen(() => screen = new DelayedFetchResultsScreen(TestResources.CreateTestScoreInfo(), tcs.Task));
|
|
|
|
AddUntilStep("wait for loaded", () => screen.IsLoaded);
|
|
|
|
AddStep("click expanded panel", () =>
|
|
{
|
|
var expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
|
|
InputManager.MoveMouseTo(expandedPanel);
|
|
InputManager.Click(MouseButton.Left);
|
|
});
|
|
|
|
AddAssert("no fetch yet", () => !screen.FetchCompleted);
|
|
|
|
AddStep("allow fetch", () => tcs.SetResult(true));
|
|
|
|
AddUntilStep("wait for fetch", () => screen.FetchCompleted);
|
|
AddAssert("expanded panel still on screen", () => this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded).ScreenSpaceDrawQuad.TopLeft.X > 0);
|
|
}
|
|
|
|
[Test]
|
|
public void TestDownloadButtonInitiallyDisabled()
|
|
{
|
|
TestResultsScreen screen = null;
|
|
|
|
loadResultsScreen(() => screen = createResultsScreen());
|
|
AddUntilStep("wait for load", () => this.ChildrenOfType<ScorePanelList>().Single().AllPanelsVisible);
|
|
|
|
AddAssert("download button is disabled", () => !screen.ChildrenOfType<DownloadButton>().Last().Enabled.Value);
|
|
|
|
AddStep("click contracted panel", () =>
|
|
{
|
|
var contractedPanel = this.ChildrenOfType<ScorePanel>().First(p => p.State == PanelState.Contracted && p.ScreenSpaceDrawQuad.TopLeft.X > screen.ScreenSpaceDrawQuad.TopLeft.X);
|
|
InputManager.MoveMouseTo(contractedPanel);
|
|
InputManager.Click(MouseButton.Left);
|
|
});
|
|
|
|
AddAssert("download button is enabled", () => screen.ChildrenOfType<DownloadButton>().Last().Enabled.Value);
|
|
}
|
|
|
|
[Test]
|
|
public void TestRulesetWithNoPerformanceCalculator()
|
|
{
|
|
var ruleset = new RulesetWithNoPerformanceCalculator();
|
|
var score = TestResources.CreateTestScoreInfo(ruleset.RulesetInfo);
|
|
|
|
loadResultsScreen(() => createResultsScreen(score));
|
|
AddUntilStep("wait for load", () => this.ChildrenOfType<ScorePanelList>().Single().AllPanelsVisible);
|
|
|
|
AddAssert("PP displayed as 0", () =>
|
|
{
|
|
var performance = this.ChildrenOfType<PerformanceStatistic>().Single();
|
|
var counter = performance.ChildrenOfType<StatisticCounter>().Single();
|
|
return counter.Current.Value == 0;
|
|
});
|
|
}
|
|
|
|
private void loadResultsScreen(Func<ResultsScreen> createResults)
|
|
{
|
|
ResultsScreen results = null;
|
|
|
|
AddStep("load results", () => Child = new TestResultsContainer(results = createResults()));
|
|
|
|
// expanded panel should be centered the moment results screen is loaded
|
|
// but can potentially be scrolled away on certain specific load scenarios.
|
|
// see: https://github.com/ppy/osu/issues/18226
|
|
AddUntilStep("expanded panel in centre of screen", () =>
|
|
{
|
|
var expandedPanel = this.ChildrenOfType<ScorePanel>().Single(p => p.State == PanelState.Expanded);
|
|
return Precision.AlmostEquals(expandedPanel.ScreenSpaceDrawQuad.Centre.X, results.ScreenSpaceDrawQuad.Centre.X, 1);
|
|
});
|
|
}
|
|
|
|
private TestResultsScreen createResultsScreen(ScoreInfo score = null) => new TestResultsScreen(score ?? TestResources.CreateTestScoreInfo());
|
|
|
|
private UnrankedSoloResultsScreen createUnrankedSoloResultsScreen() => new UnrankedSoloResultsScreen(TestResources.CreateTestScoreInfo());
|
|
|
|
private partial class TestResultsContainer : Container
|
|
{
|
|
[Cached(typeof(Player))]
|
|
private readonly Player player = new TestPlayer();
|
|
|
|
public TestResultsContainer(IScreen screen)
|
|
{
|
|
RelativeSizeAxes = Axes.Both;
|
|
OsuScreenStack stack;
|
|
|
|
InternalChild = stack = new OsuScreenStack
|
|
{
|
|
RelativeSizeAxes = Axes.Both,
|
|
};
|
|
|
|
stack.Push(screen);
|
|
}
|
|
}
|
|
|
|
private partial class TestResultsScreen : SoloResultsScreen
|
|
{
|
|
public HotkeyRetryOverlay RetryOverlay;
|
|
|
|
public TestResultsScreen(ScoreInfo score)
|
|
: base(score)
|
|
{
|
|
AllowRetry = true;
|
|
IsLocalPlay = true;
|
|
}
|
|
|
|
protected override void LoadComplete()
|
|
{
|
|
base.LoadComplete();
|
|
|
|
RetryOverlay = InternalChildren.OfType<HotkeyRetryOverlay>().SingleOrDefault();
|
|
}
|
|
|
|
protected override Task<ScoreInfo[]> FetchScores()
|
|
{
|
|
var scores = new ScoreInfo[20];
|
|
|
|
for (int i = 0; i < scores.Length; i++)
|
|
{
|
|
var score = TestResources.CreateTestScoreInfo();
|
|
score.TotalScore += 10 - i;
|
|
score.HasOnlineReplay = true;
|
|
scores[i] = score;
|
|
}
|
|
|
|
return Task.FromResult(scores);
|
|
}
|
|
}
|
|
|
|
private partial class DelayedFetchResultsScreen : TestResultsScreen
|
|
{
|
|
private readonly Task fetchWaitTask;
|
|
|
|
public bool FetchCompleted { get; private set; }
|
|
|
|
public DelayedFetchResultsScreen(ScoreInfo score, Task fetchWaitTask = null)
|
|
: base(score)
|
|
{
|
|
this.fetchWaitTask = fetchWaitTask ?? Task.CompletedTask;
|
|
}
|
|
|
|
protected override Task<ScoreInfo[]> FetchScores()
|
|
{
|
|
return Task.Run(async () =>
|
|
{
|
|
await fetchWaitTask;
|
|
|
|
var scores = new ScoreInfo[20];
|
|
|
|
for (int i = 0; i < scores.Length; i++)
|
|
{
|
|
var score = TestResources.CreateTestScoreInfo();
|
|
score.TotalScore += 10 - i;
|
|
scores[i] = score;
|
|
}
|
|
|
|
Schedule(() => FetchCompleted = true);
|
|
|
|
return scores;
|
|
});
|
|
}
|
|
}
|
|
|
|
private partial class UnrankedSoloResultsScreen : SoloResultsScreen
|
|
{
|
|
public HotkeyRetryOverlay RetryOverlay;
|
|
|
|
public UnrankedSoloResultsScreen(ScoreInfo score)
|
|
: base(score)
|
|
{
|
|
AllowRetry = true;
|
|
Score!.BeatmapInfo!.OnlineID = 0;
|
|
Score.BeatmapInfo.Status = BeatmapOnlineStatus.Pending;
|
|
}
|
|
|
|
protected override void LoadComplete()
|
|
{
|
|
base.LoadComplete();
|
|
|
|
RetryOverlay = InternalChildren.OfType<HotkeyRetryOverlay>().SingleOrDefault();
|
|
}
|
|
}
|
|
|
|
private class RulesetWithNoPerformanceCalculator : OsuRuleset
|
|
{
|
|
public override PerformanceCalculator CreatePerformanceCalculator() => null!;
|
|
}
|
|
}
|
|
}
|