Skip to content

Commit cf10267

Browse files
committed
feat: report the outcome of a scene's openExplorerUi request back to it
PBExplorerUiEventsResult is appended to the scene root entity, so a scene that asked for a panel learns that it actually appeared and, later, that it went away -- including dismissals it never asked for, like the player pressing Escape. The set is grow-only, so two events surviving the same tick is ordinary rather than a defect; the scene reads them as a (prevMax, curMax] window the way input.ts does for pointer results, which is also why timestamp carries the tick number and nothing finer. The events are emitted from inside ExplorerUiActions.OpenSectionAsync rather than from IMVCManager.OnViewShowed/OnViewClosed, and that is the decision worth spelling out. The proto's ui field is a section -- EU_MAP, EU_BACKPACK, ... -- while MVC's life cycle unit is the whole ExplorePanelController with seven tabs inside it. Switching tabs raises no MVC event at all and the section on screen lives in a private field, so an MVC-sourced implementation had nothing true to put in ui. Taken from the request, ui is correct by construction. This does not close the door on reporting opens the user initiated: a grow-only oneof takes new variants without a schema change. OpenSection answers the scene on its JS thread, so it asks MVC a second time after switching to the main thread and reports nothing if the user opened the panel in between. ShowAsync returns without doing anything for a controller that is not hidden, so the alternative is a reported open for a panel this scene never opened, followed by a close that never comes. The closed event is enqueued from a finally around ShowAsync, which resolves when the panel goes down however it got there. The hand-off is a plain Queue<ExplorerUiEvent> on ECSWorldInstanceSharedDependencies, built per scene next to EntityEventsBuilder and drained by WriteExplorerUiEventsSystem in SyncedPreRenderingSystemGroup. Both ends are main-thread-only -- the producer enqueues after SwitchToMainThread, the consumer is an ECS system -- so it needs no locking, and a late enqueue cannot throw once ECSWorldFacade.Dispose has taken the world down. It sits in ECS.Unity beside WriteEngineInfoSystem, the existing scene-root CRDT writer injected by ECSWorldFactory, because DCL.Social does not reference the CRDT assembly and cannot see anything placed next to IECSToCRDTWriter. IExplorerUiActions.OpenSection now takes the protocol value alongside the section it maps to. There is no reverse mapping and the two enums are not value-compatible, so the value is carried down rather than recovered. ExplorerUiActionsShould gains the pair, the two ways nothing is reported, and the close that has to survive a show which does not end normally; its panel-state arrangement moved off a positional Returns sequence because every accepted request now asks MVC twice. WriteExplorerUiEventsSystemShould covers the drain, and ExplorerUiEventsResultShould already pinned the append itself.
1 parent 59cc17f commit cf10267

15 files changed

Lines changed: 352 additions & 28 deletions

Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/RestrictedActions/ExplorerUi/ExplorerUiActions.cs

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22
using DCL.Communities;
33
using DCL.CrdtEcsBridge.JsModulesImplementation;
44
using DCL.Diagnostics;
5+
using DCL.ECSComponents;
56
using DCL.ExplorePanel;
67
using DCL.UI;
78
using Decentraland.Kernel.Apis;
9+
using ECS.Unity.ExplorerUiEvents;
810
using MVC;
911
using System;
12+
using System.Collections.Generic;
1013

1114
namespace DCL.Infrastructure.CrdtEcsBridge.JsModulesImplementation.RestrictedActions
1215
{
@@ -18,13 +21,15 @@ namespace DCL.Infrastructure.CrdtEcsBridge.JsModulesImplementation.RestrictedAct
1821
public class ExplorerUiActions : IExplorerUiActions
1922
{
2023
private readonly IMVCManager mvcManager;
24+
private readonly Queue<ExplorerUiEvent> events;
2125

22-
public ExplorerUiActions(IMVCManager mvcManager)
26+
public ExplorerUiActions(IMVCManager mvcManager, Queue<ExplorerUiEvent> events)
2327
{
2428
this.mvcManager = mvcManager;
29+
this.events = events;
2530
}
2631

27-
public OpenExplorerUiResult OpenSection(ExploreSections section)
32+
public OpenExplorerUiResult OpenSection(ExplorerUi ui, ExploreSections section)
2833
{
2934
// Communities availability depends on the user identity (feature flag + wallets allowlist),
3035
// so it cannot be gated through FeaturesRegistry like the other sections.
@@ -37,16 +42,28 @@ public OpenExplorerUiResult OpenSection(ExploreSections section)
3742
if (mvcManager.IsShowing<ExplorePanelView, ExplorePanelParameter>())
3843
return OpenExplorerUiResult.WasAlreadyOpen;
3944

40-
OpenSectionAsync(section).Forget();
45+
OpenSectionAsync(ui, section).Forget();
4146
return OpenExplorerUiResult.Opened;
4247
}
4348

44-
private async UniTask OpenSectionAsync(ExploreSections section)
49+
private async UniTask OpenSectionAsync(ExplorerUi ui, ExploreSections section)
4550
{
4651
try
4752
{
4853
await UniTask.SwitchToMainThread();
49-
await mvcManager.ShowAsync(ExplorePanelController.IssueCommand(new ExplorePanelParameter(section)));
54+
55+
// The answer given to the scene was decided on its JS thread; by now the user may have opened
56+
// the panel themselves, and ShowAsync does nothing for a controller that is not hidden.
57+
if (mvcManager.IsShowing<ExplorePanelView, ExplorePanelParameter>())
58+
return;
59+
60+
// ShowAsync resolves when the panel closes, so the pair brackets its whole life cycle. The
61+
// opened event goes out before the await because there is no later moment that still means
62+
// "shown".
63+
events.Enqueue(new ExplorerUiEvent(ui, ExplorerUiEventKind.Opened));
64+
65+
try { await mvcManager.ShowAsync(ExplorePanelController.IssueCommand(new ExplorePanelParameter(section))); }
66+
finally { events.Enqueue(new ExplorerUiEvent(ui, ExplorerUiEventKind.Closed)); }
5067
}
5168
catch (OperationCanceledException) { }
5269
catch (Exception e) { ReportHub.LogException(e, ReportCategory.RESTRICTED_ACTIONS); }
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1+
using DCL.ECSComponents;
12
using DCL.UI;
23
using Decentraland.Kernel.Apis;
34

45
namespace DCL.CrdtEcsBridge.JsModulesImplementation
56
{
67
public interface IExplorerUiActions
78
{
8-
OpenExplorerUiResult OpenSection(ExploreSections section);
9+
/// <summary>
10+
/// Opens the explore panel on <paramref name="section" />. <paramref name="ui" /> is the protocol
11+
/// value the request came in with: the section is what MVC needs, the protocol value is what the
12+
/// scene gets its life cycle events tagged with, and neither maps onto the other.
13+
/// </summary>
14+
OpenExplorerUiResult OpenSection(ExplorerUi ui, ExploreSections section);
915
}
1016
}

Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/RestrictedActions/RestrictedActionsAPIImplementation.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ public int TryOpenExplorerUi(int ui)
236236
return (int)OpenExplorerUiResult.RejectedFeatureDisabled;
237237
}
238238

239-
return (int)explorerUiActions.OpenSection(section);
239+
return (int)explorerUiActions.OpenSection((ExplorerUi)ui, section);
240240
}
241241

242242
public void Dispose() { }
Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,124 @@
1+
using Cysharp.Threading.Tasks;
2+
using DCL.ECSComponents;
13
using DCL.ExplorePanel;
24
using DCL.Infrastructure.CrdtEcsBridge.JsModulesImplementation.RestrictedActions;
35
using DCL.UI;
46
using Decentraland.Kernel.Apis;
7+
using ECS.Unity.ExplorerUiEvents;
58
using MVC;
69
using NSubstitute;
710
using NUnit.Framework;
11+
using System.Collections.Generic;
12+
using System.Threading;
813

914
namespace CrdtEcsBridge.RestrictedActions.Tests
1015
{
1116
/// <summary>
1217
/// Covers how <see cref="ExplorerUiActions" /> chooses between opening the explore panel and answering
13-
/// the scene that it was already open. One instance exists per scene, so it cannot have witnessed the
14-
/// panel opening before it was built, and MVC skips OnViewClosed when a view's lifecycle is cancelled —
15-
/// the panel state therefore has to be read from MVC at the moment of the decision.
18+
/// the scene that it was already open, and the life cycle events it reports back for the requests it
19+
/// did accept. One instance exists per scene, so it cannot have witnessed the panel opening before it
20+
/// was built, and MVC skips OnViewClosed when a view's lifecycle is cancelled — the panel state
21+
/// therefore has to be read from MVC at the moment of the decision.
1622
/// </summary>
1723
[TestFixture]
1824
public class ExplorerUiActionsShould
1925
{
2026
private IMVCManager mvcManager;
27+
private Queue<ExplorerUiEvent> events;
2128
private ExplorerUiActions explorerUiActions;
2229

2330
[SetUp]
2431
public void SetUp()
2532
{
2633
mvcManager = Substitute.For<IMVCManager>();
34+
events = new Queue<ExplorerUiEvent>();
2735

2836
// Built before any panel state is arranged, the way a scene load builds it long after the user
2937
// could have opened a panel.
30-
explorerUiActions = new ExplorerUiActions(mvcManager);
38+
explorerUiActions = new ExplorerUiActions(mvcManager, events);
3139
}
3240

3341
[Test]
3442
public void AnswerWasAlreadyOpenForAPanelOpenedBeforeTheSceneLoaded()
3543
{
3644
mvcManager.IsShowing<ExplorePanelView, ExplorePanelParameter>().Returns(true);
3745

38-
Assert.That(explorerUiActions.OpenSection(ExploreSections.Navmap), Is.EqualTo(OpenExplorerUiResult.WasAlreadyOpen));
46+
Assert.That(explorerUiActions.OpenSection(ExplorerUi.EuMap, ExploreSections.Navmap), Is.EqualTo(OpenExplorerUiResult.WasAlreadyOpen));
47+
Assert.That(events, Is.Empty);
3948
}
4049

4150
[Test]
4251
public void OpenTheSectionWhileThePanelIsHidden()
4352
{
4453
mvcManager.IsShowing<ExplorePanelView, ExplorePanelParameter>().Returns(false);
4554

46-
Assert.That(explorerUiActions.OpenSection(ExploreSections.Navmap), Is.EqualTo(OpenExplorerUiResult.Opened));
55+
Assert.That(explorerUiActions.OpenSection(ExplorerUi.EuMap, ExploreSections.Navmap), Is.EqualTo(OpenExplorerUiResult.Opened));
56+
mvcManager.Received(1).ShowAsync(Arg.Any<ShowCommand<ExplorePanelView, ExplorePanelParameter>>(), Arg.Any<CancellationToken>());
4757
}
4858

4959
[Test]
5060
public void FollowThePanelStateAcrossCalls()
5161
{
52-
mvcManager.IsShowing<ExplorePanelView, ExplorePanelParameter>().Returns(false, true, false);
62+
// Re-arranged before every call rather than queued up as a sequence: each accepted request asks
63+
// MVC twice, once per thread it runs on, so the number of calls is not the test's business.
64+
mvcManager.IsShowing<ExplorePanelView, ExplorePanelParameter>().Returns(false);
65+
Assert.That(explorerUiActions.OpenSection(ExplorerUi.EuMap, ExploreSections.Navmap), Is.EqualTo(OpenExplorerUiResult.Opened));
5366

54-
Assert.That(explorerUiActions.OpenSection(ExploreSections.Navmap), Is.EqualTo(OpenExplorerUiResult.Opened));
55-
Assert.That(explorerUiActions.OpenSection(ExploreSections.Places), Is.EqualTo(OpenExplorerUiResult.WasAlreadyOpen));
67+
mvcManager.IsShowing<ExplorePanelView, ExplorePanelParameter>().Returns(true);
68+
Assert.That(explorerUiActions.OpenSection(ExplorerUi.EuPlaces, ExploreSections.Places), Is.EqualTo(OpenExplorerUiResult.WasAlreadyOpen));
5669

5770
// A cached flag would stay stuck on the middle answer; the panel closing has to be picked up.
58-
Assert.That(explorerUiActions.OpenSection(ExploreSections.Places), Is.EqualTo(OpenExplorerUiResult.Opened));
71+
mvcManager.IsShowing<ExplorePanelView, ExplorePanelParameter>().Returns(false);
72+
Assert.That(explorerUiActions.OpenSection(ExplorerUi.EuPlaces, ExploreSections.Places), Is.EqualTo(OpenExplorerUiResult.Opened));
73+
}
74+
75+
[Test]
76+
public void ReportTheOpenedAndClosedPairOfItsOwnRequest()
77+
{
78+
mvcManager.IsShowing<ExplorePanelView, ExplorePanelParameter>().Returns(false);
79+
80+
explorerUiActions.OpenSection(ExplorerUi.EuBackpack, ExploreSections.Backpack);
81+
82+
// The events carry the protocol value the scene asked with, not the section MVC was driven by:
83+
// the two enums are unrelated and only this direction of the mapping exists.
84+
Assert.That(events, Is.EqualTo(new[]
85+
{
86+
new ExplorerUiEvent(ExplorerUi.EuBackpack, ExplorerUiEventKind.Opened),
87+
new ExplorerUiEvent(ExplorerUi.EuBackpack, ExplorerUiEventKind.Closed),
88+
}));
89+
}
90+
91+
[Test]
92+
public void ReportNothingWhenTheUserOpensThePanelFirst()
93+
{
94+
// False on the scene's JS thread, true once the request reaches the main thread. ShowAsync would
95+
// silently do nothing from here on, so a reported pair would describe a panel this scene never
96+
// opened and would never see closed either.
97+
mvcManager.IsShowing<ExplorePanelView, ExplorePanelParameter>().Returns(false, true);
98+
99+
Assert.That(explorerUiActions.OpenSection(ExplorerUi.EuMap, ExploreSections.Navmap), Is.EqualTo(OpenExplorerUiResult.Opened));
100+
101+
Assert.That(events, Is.Empty);
102+
mvcManager.DidNotReceive().ShowAsync(Arg.Any<ShowCommand<ExplorePanelView, ExplorePanelParameter>>(), Arg.Any<CancellationToken>());
103+
}
104+
105+
[Test]
106+
public void ReportClosedWhenTheShowDoesNotEndNormally()
107+
{
108+
mvcManager.IsShowing<ExplorePanelView, ExplorePanelParameter>().Returns(false);
109+
110+
mvcManager.ShowAsync(Arg.Any<ShowCommand<ExplorePanelView, ExplorePanelParameter>>(), Arg.Any<CancellationToken>())
111+
.Returns(UniTask.FromCanceled());
112+
113+
explorerUiActions.OpenSection(ExplorerUi.EuMap, ExploreSections.Navmap);
114+
115+
// Every way the show can end leaves the panel down, so the pair has to close on all of them: a
116+
// reported open with no reported close is a scene waiting forever.
117+
Assert.That(events, Is.EqualTo(new[]
118+
{
119+
new ExplorerUiEvent(ExplorerUi.EuMap, ExplorerUiEventKind.Opened),
120+
new ExplorerUiEvent(ExplorerUi.EuMap, ExplorerUiEventKind.Closed),
121+
}));
59122
}
60123
}
61124
}

Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/RestrictedActions/Tests/RestrictedActionsAPIImplementationShould.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public void SetUp()
5858
});
5959
systemClipboard = Substitute.For<ISystemClipboard>();
6060
explorerUiActions = Substitute.For<IExplorerUiActions>();
61-
explorerUiActions.OpenSection(Arg.Any<ExploreSections>()).Returns(OpenExplorerUiResult.Opened);
61+
explorerUiActions.OpenSection(Arg.Any<ExplorerUi>(), Arg.Any<ExploreSections>()).Returns(OpenExplorerUiResult.Opened);
6262
sceneWorld = World.Create();
6363
Entity scenePlayerEntity = sceneWorld.Create();
6464
restrictedActionsAPIImplementation = new RestrictedActionsAPIImplementation(
@@ -169,7 +169,7 @@ public void OpenExplorerUi_MapOpensNavmap()
169169

170170
// Assert
171171
Assert.AreEqual((int)OpenExplorerUiResult.Opened, result);
172-
explorerUiActions.Received(1).OpenSection(ExploreSections.Navmap);
172+
explorerUiActions.Received(1).OpenSection(ExplorerUi.EuMap, ExploreSections.Navmap);
173173
}
174174

175175
[Test]
@@ -183,7 +183,7 @@ public void OpenExplorerUi_NotCurrentScene_Rejects()
183183

184184
// Assert
185185
Assert.AreEqual((int)OpenExplorerUiResult.RejectedNotCurrentScene, result);
186-
explorerUiActions.DidNotReceive().OpenSection(Arg.Any<ExploreSections>());
186+
explorerUiActions.DidNotReceive().OpenSection(Arg.Any<ExplorerUi>(), Arg.Any<ExploreSections>());
187187
}
188188

189189
[Test]
@@ -200,14 +200,14 @@ public void OpenExplorerUi_NoRecentGesture_Rejects(int lastUserInputTick)
200200

201201
// Assert
202202
Assert.AreEqual((int)OpenExplorerUiResult.RejectedNoUserGesture, result);
203-
explorerUiActions.DidNotReceive().OpenSection(Arg.Any<ExploreSections>());
203+
explorerUiActions.DidNotReceive().OpenSection(Arg.Any<ExplorerUi>(), Arg.Any<ExploreSections>());
204204
}
205205

206206
[Test]
207207
public void OpenExplorerUi_AlreadyOpen_ReturnsWasAlreadyOpen()
208208
{
209209
// Arrange
210-
explorerUiActions.OpenSection(Arg.Any<ExploreSections>()).Returns(OpenExplorerUiResult.WasAlreadyOpen);
210+
explorerUiActions.OpenSection(Arg.Any<ExplorerUi>(), Arg.Any<ExploreSections>()).Returns(OpenExplorerUiResult.WasAlreadyOpen);
211211

212212
// Act
213213
int result = restrictedActionsAPIImplementation.TryOpenExplorerUi((int)ExplorerUi.EuMap);
@@ -224,7 +224,7 @@ public void OpenExplorerUi_UnknownUiValue_Rejects()
224224

225225
// Assert
226226
Assert.AreEqual((int)OpenExplorerUiResult.RejectedFeatureDisabled, result);
227-
explorerUiActions.DidNotReceive().OpenSection(Arg.Any<ExploreSections>());
227+
explorerUiActions.DidNotReceive().OpenSection(Arg.Any<ExplorerUi>(), Arg.Any<ExploreSections>());
228228
}
229229

230230
[Test]
@@ -241,7 +241,7 @@ public void OpenExplorerUi_FeatureDisabled_Rejects()
241241

242242
// Assert
243243
Assert.AreEqual((int)OpenExplorerUiResult.RejectedFeatureDisabled, result);
244-
explorerUiActions.DidNotReceive().OpenSection(Arg.Any<ExploreSections>());
244+
explorerUiActions.DidNotReceive().OpenSection(Arg.Any<ExplorerUi>(), Arg.Any<ExploreSections>());
245245
}
246246

247247
[Test]
@@ -250,7 +250,7 @@ public void OpenExplorerUi_CommunitiesRejectionPropagates()
250250
// Arrange
251251
// Communities availability is identity-dependent, so its gate lives inside the
252252
// IExplorerUiActions implementation; the API must return that rejection to the scene.
253-
explorerUiActions.OpenSection(ExploreSections.Communities).Returns(OpenExplorerUiResult.RejectedFeatureDisabled);
253+
explorerUiActions.OpenSection(ExplorerUi.EuCommunities, ExploreSections.Communities).Returns(OpenExplorerUiResult.RejectedFeatureDisabled);
254254

255255
// Act
256256
int result = restrictedActionsAPIImplementation.TryOpenExplorerUi((int)ExplorerUi.EuCommunities);

0 commit comments

Comments
 (0)