Skip to content
Merged
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,4 @@ Assets/Digger/
Assets/Samples/
Assets/Silantro/
Assets/Runtime/StraightFour/3rd-party/
.worldkit/
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,13 @@ public static bool AddRigFollower(BaseEntity entityToFollowRig)
WebVerseRuntime.Instance.vrRig.rigFollowers.Add(entityToFollowRig.internalEntity);
}
}

// The rig is now the sole writer of this entity's position via UpdateFollowers.
// Suppress the entity's own FixedUpdate motion to prevent two-writer ghosting.
if (entityToFollowRig.internalEntity is StraightFour.Entity.CharacterEntity ce)
{
ce.externalPositionControl = true;
}
return true;
}

Expand Down Expand Up @@ -714,6 +721,12 @@ public static bool RemoveRigFollower(BaseEntity entityToFollowRig)
WebVerseRuntime.Instance.vrRig.rigFollowers.Remove(entityToFollowRig.internalEntity);
}
}

// Restore entity-owned FixedUpdate motion now that the rig no longer drives position.
if (entityToFollowRig.internalEntity is StraightFour.Entity.CharacterEntity ce)
{
ce.externalPositionControl = false;
}
return true;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ public static string GetQueryParam(string key)
return WebVerseRuntime.Instance.straightFour.GetParam(key);
}

/// <summary>
/// Get the URL of the currently loaded World or Web Page.
/// </summary>
/// <returns>The URL of the current World or Web Page, or null if none has been loaded.</returns>
public static string GetWorldURL()
{
return WebVerseRuntime.Instance.currentURL;
}

/// <summary>
/// Get the current World Load State.
/// </summary>
Expand Down Expand Up @@ -59,6 +68,19 @@ public static string GetWorldLoadState()
/// </summary>
/// <param name="url">The URL of the World to load.</param>
public static void LoadWorld(string url)
{
LoadWorld(url, null);
}

/// <summary>
/// Load a World from a URL, along with a script to run in the same JINT engine as the world's
/// own scripts.
/// </summary>
/// <param name="url">The URL of the World to load.</param>
/// <param name="requireScript">Either inline JavaScript logic, or a URI ending in ".js" pointing
/// to a script resource. The script is prepended to the world's script list and runs first.
/// Only supported for VEML worlds; ignored for x3d and glTF worlds.</param>
public static void LoadWorld(string url, string requireScript)
{
WebVerseRuntime.Instance.LoadWorld(url, new System.Action<string>((name) =>
{
Expand All @@ -68,7 +90,33 @@ public static void LoadWorld(string url)
multibar.ToggleMultibar();
multibar.ToggleMultibar();
}
}));
}), requireScript);
}

/// <summary>
/// Dry-run validation of a World's VEML without switching to it. Downloads and parses the
/// VEML, downloads (but does not execute) referenced scripts, and HEAD-requests referenced
/// asset URIs. Reports the result via the JS callback. Does not unload the active world,
/// mutate runtime state, or touch the JINT engine.
/// </summary>
/// <param name="url">The URL of the World to test.</param>
/// <param name="onTestComplete">Name of a JS function to invoke when the test completes. The
/// function is called with three arguments: (success: bool, errorMessage: string|null, title:
/// string|null). On success, errorMessage is null. On failure, errorMessage is a
/// newline-separated list of issues. title is the parsed metadata.title when the document
/// parsed, otherwise null.</param>
public static void TestLoadWorld(string url, string onTestComplete)
{
WebVerseRuntime.Instance.TestLoadWorld(url,
new System.Action<bool, string, string>((success, errorMessage, title) =>
{
if (string.IsNullOrEmpty(onTestComplete))
{
return;
}
WebVerseRuntime.Instance.javascriptHandler.CallWithParams(
onTestComplete, new object[] { success, errorMessage, title });
}));
}

/// <summary>
Expand Down
259 changes: 259 additions & 0 deletions Assets/Runtime/Handlers/JavascriptHandler/Tests/WorldAPITests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
// Copyright (c) 2019-2026 Five Squared Interactive. All rights reserved.

using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using FiveSQD.WebVerse.Runtime;
using FiveSQD.WebVerse.LocalStorage;
using System.IO;
using System.Reflection;
using System.Text.RegularExpressions;
using WorldAPI = FiveSQD.WebVerse.Handlers.Javascript.APIs.Utilities.World;

/// <summary>
/// Unit tests for the World JavaScript API.
/// </summary>
public class WorldAPITests
{
private WebVerseRuntime runtime;
private GameObject runtimeGO;
private string testDirectory;

[OneTimeSetUp]
public void OneTimeSetUp()
{
LogAssert.ignoreFailingMessages = true;
}

[SetUp]
public void SetUp()
{
LogAssert.ignoreFailingMessages = true;

runtimeGO = new GameObject("runtime");
runtime = runtimeGO.AddComponent<WebVerseRuntime>();

runtime.highlightMaterial = new Material(Shader.Find("Standard"));
runtime.skyMaterial = new Material(Shader.Find("Standard"));
runtime.characterControllerPrefab = new GameObject("DummyCharacterController");
runtime.inputEntityPrefab = new GameObject("DummyInputEntity");
runtime.voxelPrefab = new GameObject("DummyVoxel");
runtime.webVerseWebViewPrefab = new GameObject("DummyWebView");

testDirectory = Path.Combine(Path.GetTempPath(), "WorldAPITests");
runtime.Initialize(LocalStorageManager.LocalStorageMode.Cache, 128, 128, 128, testDirectory);
}

[TearDown]
public void TearDown()
{
WebVerseRuntime.Instance = null;
if (runtime != null && Directory.Exists(testDirectory))
{
Directory.Delete(testDirectory, true);
}
if (runtimeGO != null)
{
Object.DestroyImmediate(runtimeGO);
}
}

private static void SetCurrentURL(WebVerseRuntime runtime, string url)
{
PropertyInfo prop = typeof(WebVerseRuntime).GetProperty(
"currentURL", BindingFlags.Public | BindingFlags.Instance);
Assert.NotNull(prop, "currentURL property must exist on WebVerseRuntime.");
prop.SetValue(runtime, url);
}
Comment on lines +61 to +67

[Test]
public void GetWorldURL_BeforeAnyLoad_ReturnsNull()
{
Assert.IsNull(WorldAPI.GetWorldURL());
}

[Test]
public void GetWorldURL_AfterCurrentURLSet_ReturnsThatURL()
{
const string url = "https://example.test/world.veml";
SetCurrentURL(runtime, url);

Assert.AreEqual(url, WorldAPI.GetWorldURL());
}

[Test]
public void GetWorldURL_ReflectsLatestAssignment()
{
SetCurrentURL(runtime, "https://example.test/first.veml");
SetCurrentURL(runtime, "https://example.test/second.veml");

Assert.AreEqual("https://example.test/second.veml", WorldAPI.GetWorldURL());
}

[Test]
public void LoadWorld_SetsCurrentURL()
{
const string url = "https://example.test/load-world.veml";

// The full load pipeline pulls in handlers and HTTP that aren't wired
// up in this test context, so downstream work may throw. We only care
// that the currentURL assignment at the top of LoadWorld ran.
try { runtime.LoadWorld(url, null); } catch { }

Assert.AreEqual(url, WorldAPI.GetWorldURL());
}

[Test]
public void LoadWebPage_SetsCurrentURL()
{
const string url = "https://example.test/page.html";

// The placeholder WebView in this test context isn't fully set up, so LoadURL on it
// intentionally errors. We only care that currentURL was assigned at the top of LoadWebPage
// before the WebView call ran.
LogAssert.Expect(LogType.Error, "[WebVerseWebView->LoadURL] WebVerse WebView not set up.");
try { runtime.LoadWebPage(url, null); } catch { }

Assert.AreEqual(url, WorldAPI.GetWorldURL());
}

[Test]
public void LoadWorld_WithInlineRequireScript_AcceptsArgument()
{
const string url = "https://example.test/with-require.veml";

// The new overload should accept an inline JS body without throwing at the
// signature/dispatch boundary. Downstream load machinery may still throw.
try { runtime.LoadWorld(url, null, "var __requireSentinel = 1;"); } catch { }

Assert.AreEqual(url, WorldAPI.GetWorldURL());
}

[Test]
public void LoadWorld_WithURIRequireScript_AcceptsArgument()
{
const string url = "https://example.test/with-require-uri.veml";

try { runtime.LoadWorld(url, null, "init.js"); } catch { }

Assert.AreEqual(url, WorldAPI.GetWorldURL());
}

[Test]
public void LoadWorld_DefaultRequireScript_BackwardCompatible()
{
// The single-onLoaded overload signature still works (default param = null).
const string url = "https://example.test/no-require.veml";

try { runtime.LoadWorld(url, null); } catch { }

Assert.AreEqual(url, WorldAPI.GetWorldURL());
}

[Test]
public void JSAPI_LoadWorld_OneArg_Compiles()
{
// Verifies the World.LoadWorld(url) overload remains callable.
try { WorldAPI.LoadWorld("https://example.test/one-arg.veml"); } catch { }
}

[Test]
public void JSAPI_LoadWorld_TwoArg_Compiles()
{
// Verifies the new World.LoadWorld(url, requireScript) overload is callable.
try
{
WorldAPI.LoadWorld("https://example.test/two-arg.veml", "var __sentinel = 1;");
}
catch { }
}

[Test]
public void TestLoadWorld_DoesNotMutateCurrentURL()
{
// Pre-condition: simulate a previously loaded world.
const string sentinel = "https://example.test/already-loaded.veml";
SetCurrentURL(runtime, sentinel);

try
{
runtime.TestLoadWorld("https://example.test/test-target.veml",
(success, errorMessage, title) => { });
}
catch { }

// The contract: TestLoadWorld must not overwrite currentURL even if the network
// call later fails or hangs.
Assert.AreEqual(sentinel, WorldAPI.GetWorldURL());
}

[Test]
public void TestLoadWorld_RejectsNonVEMLExtensions()
{
bool callbackFired = false;
bool reportedSuccess = true;
string reportedError = null;

try
{
runtime.TestLoadWorld("https://example.test/world.glb",
(success, errorMessage, title) =>
{
callbackFired = true;
reportedSuccess = success;
reportedError = errorMessage;
});
}
catch { }

Assert.IsTrue(callbackFired, "Callback should fire synchronously for non-VEML extensions.");
Assert.IsFalse(reportedSuccess);
StringAssert.Contains("VEML", reportedError);
}

[Test]
public void TestLoadWorld_X3DAlsoRejected()
{
bool callbackFired = false;
bool reportedSuccess = true;

try
{
runtime.TestLoadWorld("https://example.test/world.x3d",
(success, errorMessage, title) =>
{
callbackFired = true;
reportedSuccess = success;
});
}
catch { }

Assert.IsTrue(callbackFired);
Assert.IsFalse(reportedSuccess);
}

[Test]
public void JSAPI_TestLoadWorld_Compiles()
{
// Verifies the JS-facing World.TestLoadWorld(url, callbackName) signature is callable.
try
{
WorldAPI.TestLoadWorld("https://example.test/world.veml", "onTestComplete");
}
catch { }
}

[Test]
public void JSAPI_TestLoadWorld_NullCallback_DoesNotThrow()
{
// A null/empty callback name should be tolerated (just no JS invocation).
Assert.DoesNotThrow(() =>
{
try
{
WorldAPI.TestLoadWorld("https://example.test/world.glb", null);
}
catch { }
});
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading