Skip to content

Commit ed7ba84

Browse files
committed
Harden runtime playback regression coverage
1 parent 381b3fc commit ed7ba84

File tree

10 files changed

+220
-17
lines changed

10 files changed

+220
-17
lines changed

SDK/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Compiled TPS output is meant to be portable. The active runtimes treat the compi
4343
|--------|---------|----------------|---------------|
4444
| `SDK/ts` | canonical runtime source | changing TPS behavior or runtime contract | `npm --prefix SDK/js run build:tps`, `npm --prefix SDK/js run coverage:typescript` |
4545
| `SDK/js` | JavaScript package and Node validation | changing JS packaging or JS-specific tests | `npm --prefix SDK/js run test:js`, `npm --prefix SDK/js run coverage:js` |
46-
| `SDK/dotnet` | C# runtime and tests | changing .NET API or .NET behavior | `dotnet build SDK/dotnet/ManagedCode.Tps.slnx -warnaserror --no-restore`, `dotnet test SDK/dotnet/ManagedCode.Tps.slnx --no-build --no-restore` |
46+
| `SDK/dotnet` | C# runtime and tests | changing .NET API or .NET behavior | `dotnet build SDK/dotnet/ManagedCode.Tps.slnx -warnaserror --no-restore`, `dotnet test SDK/dotnet/ManagedCode.Tps.slnx --no-restore` |
4747
| `SDK/flutter` | Dart runtime for Flutter hosts | changing Flutter/Dart behavior or tests | `cd SDK/flutter && dart pub get && ./coverage.sh` |
4848
| `SDK/swift` | Swift runtime package | changing Apple-platform runtime behavior or tests | `cd SDK/swift && ./coverage.sh` |
4949
| `SDK/java` | Java runtime package | changing Java behavior or tests | `cd SDK/java && ./coverage.sh` |
@@ -108,7 +108,7 @@ On .NET UI hosts, also wire `TpsPlaybackSessionOptions.EventSynchronizationConte
108108

109109
- TypeScript: `npm --prefix SDK/js run coverage:typescript`
110110
- JavaScript: `npm --prefix SDK/js run coverage:js`
111-
- C#: `dotnet test SDK/dotnet/ManagedCode.Tps.slnx --no-build --no-restore /p:CollectCoverage=true /p:CoverletOutputFormat=json /p:ThresholdType=line%2Cbranch%2Cmethod /p:Threshold=90`
111+
- C#: `dotnet test SDK/dotnet/ManagedCode.Tps.slnx --no-restore /p:CollectCoverage=true /p:CoverletOutputFormat=json /p:ThresholdType=line%2Cbranch%2Cmethod /p:Threshold=90`
112112
- Flutter: `cd SDK/flutter && ./coverage.sh`
113113
- Swift: `cd SDK/swift && ./coverage.sh`
114114
- Java: `cd SDK/java && ./coverage.sh`

SDK/dotnet/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ The root [README.md](/Users/ksemenenko/Developer/TPS/README.md) is the authorita
9292
## Local Commands
9393

9494
- `dotnet build ManagedCode.Tps.slnx -warnaserror --no-restore`
95-
- `dotnet test ManagedCode.Tps.slnx --no-build --no-restore`
96-
- `dotnet test ManagedCode.Tps.slnx --no-build --no-restore /p:CollectCoverage=true /p:CoverletOutputFormat=json /p:ThresholdType=line%2Cbranch%2Cmethod /p:Threshold=90`
95+
- `dotnet test ManagedCode.Tps.slnx --no-restore`
96+
- `dotnet test ManagedCode.Tps.slnx --no-restore /p:CollectCoverage=true /p:CoverletOutputFormat=json /p:ThresholdType=line%2Cbranch%2Cmethod /p:Threshold=90`
9797

9898
## Target Runtime
9999

SDK/dotnet/tests/ManagedCode.Tps.Tests/TpsPlaybackSessionTests.cs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,65 @@ public async Task PlaybackSession_CanRunDeterministicallyWithFakeTimeProvider()
633633
Assert.Equal(TpsPlaybackStatus.Completed, session.Status);
634634
}
635635

636+
[Fact]
637+
public async Task PlaybackSession_ConcurrentControlCommands_KeepStateConsistent()
638+
{
639+
var script = TpsRuntime.Compile("""
640+
## [Intro]
641+
### [Lead]
642+
Ready now please stay focused for this longer playback sample.
643+
### [Close]
644+
Done soon after another phrase lands safely.
645+
""").Script;
646+
using var session = new TpsPlaybackSession(
647+
script,
648+
new TpsPlaybackSessionOptions
649+
{
650+
TickIntervalMs = 1_000
651+
});
652+
653+
session.Play();
654+
655+
var tasks = Enumerable.Range(0, 4).Select(async lane =>
656+
{
657+
for (var iteration = 0; iteration < 40; iteration++)
658+
{
659+
switch ((lane + iteration) % 6)
660+
{
661+
case 0:
662+
session.Seek((iteration * 37) % Math.Max(1, script.TotalDurationMs));
663+
break;
664+
case 1:
665+
session.NextWord();
666+
break;
667+
case 2:
668+
session.PreviousWord();
669+
break;
670+
case 3:
671+
session.NextBlock();
672+
break;
673+
case 4:
674+
session.PreviousBlock();
675+
break;
676+
default:
677+
session.SetSpeedOffsetWpm(((lane * 5) + iteration) % 41 - 20);
678+
break;
679+
}
680+
681+
await Task.Yield();
682+
}
683+
});
684+
685+
await Task.WhenAll(tasks);
686+
687+
var snapshot = session.CreateSnapshot();
688+
689+
Assert.InRange(snapshot.State.ElapsedMs, 0, script.TotalDurationMs);
690+
Assert.InRange(snapshot.State.CurrentWordIndex, -1, script.Words.Count - 1);
691+
Assert.InRange(snapshot.Tempo.EffectiveBaseWpm, TpsSpec.MinimumWpm, TpsSpec.MaximumWpm);
692+
Assert.NotNull(snapshot.State.CurrentSegment);
693+
}
694+
636695
private sealed class RecordingSynchronizationContext : SynchronizationContext
637696
{
638697
public int PostCount { get; private set; }

SDK/flutter/lib/src/managedcode_tps.dart

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1206,7 +1206,6 @@ class TpsPlaybackSession {
12061206
_clearTimer();
12071207
_scheduleNextTick();
12081208
}
1209-
_emitSnapshotChanged();
12101209
return snapshot;
12111210
}
12121211

@@ -1275,11 +1274,18 @@ class TpsPlaybackSession {
12751274
}
12761275

12771276
void _updateStatus(TpsPlaybackStatus nextStatus) {
1278-
if (status == nextStatus) {
1277+
final previousStatus = status;
1278+
if (previousStatus == nextStatus) {
12791279
return;
12801280
}
1281-
final previousStatus = status;
12821281
status = nextStatus;
1282+
if (nextStatus != TpsPlaybackStatus.playing) {
1283+
_playbackOffsetMs = currentState.elapsedMs;
1284+
_playbackStartedAtMs = 0;
1285+
}
1286+
if (nextStatus == TpsPlaybackStatus.completed && previousStatus == TpsPlaybackStatus.playing) {
1287+
_clearTimer();
1288+
}
12831289
_emit("statusChanged", {
12841290
"state": currentState,
12851291
"previousStatus": previousStatus,
@@ -1291,8 +1297,11 @@ class TpsPlaybackSession {
12911297
final previousState = currentState;
12921298
final nextState = player.getState(elapsedMs);
12931299
final previousStatus = status;
1300+
final resolvedStatus = nextStatus == TpsPlaybackStatus.playing && nextState.isComplete
1301+
? TpsPlaybackStatus.completed
1302+
: nextStatus;
12941303
currentState = nextState;
1295-
_updateStatus(nextStatus);
1304+
_updateStatus(resolvedStatus);
12961305
if (nextState.currentWord?.id != previousState.currentWord?.id) {
12971306
_emit("wordChanged", {"state": nextState, "previousState": previousState, "status": status});
12981307
}
@@ -1308,8 +1317,7 @@ class TpsPlaybackSession {
13081317
if (nextState.elapsedMs != previousState.elapsedMs || status != previousStatus) {
13091318
_emit("stateChanged", {"state": nextState, "previousState": previousState, "status": status});
13101319
}
1311-
if (!previousState.isComplete && nextState.isComplete) {
1312-
status = TpsPlaybackStatus.completed;
1320+
if (!previousState.isComplete && resolvedStatus == TpsPlaybackStatus.completed) {
13131321
_emit("completed", {"state": nextState, "previousState": previousState, "status": status});
13141322
}
13151323
_emitSnapshotChanged();

SDK/flutter/test/runtime_test.dart

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,36 @@ void main() {
227227
session.dispose();
228228
});
229229

230+
test("emits a single snapshot for speed changes and reports completed status transitions", () async {
231+
final compilation = compileTps("## [Signal]\n### [Body]\nReady now.");
232+
final session = TpsPlaybackSession(compilation.script, const TpsPlaybackSessionOptions(tickIntervalMs: 10));
233+
final statuses = <TpsPlaybackStatus>[];
234+
var snapshotEvents = 0;
235+
236+
session.on("statusChanged", (event) {
237+
statuses.add((event as Map<String, Object?>)["status"] as TpsPlaybackStatus);
238+
});
239+
session.on("snapshotChanged", (_) {
240+
snapshotEvents += 1;
241+
});
242+
243+
session.increaseSpeed(10);
244+
expect(snapshotEvents, 1);
245+
246+
final completer = Completer<void>();
247+
session.on("completed", (_) {
248+
if (!completer.isCompleted) {
249+
completer.complete();
250+
}
251+
});
252+
session.play();
253+
await completer.future.timeout(const Duration(seconds: 3));
254+
255+
expect(statuses, contains(TpsPlaybackStatus.playing));
256+
expect(statuses, contains(TpsPlaybackStatus.completed));
257+
session.dispose();
258+
});
259+
230260
test("compiles and navigates a large generated script", () {
231261
final buffer = StringBuffer()
232262
..writeln("---")

SDK/java/src/test/java/com/managedcode/tps/ManagedCodeTpsTests.java

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import java.util.Objects;
1313
import java.util.Set;
1414
import java.util.concurrent.CountDownLatch;
15+
import java.util.concurrent.CopyOnWriteArrayList;
1516
import java.util.concurrent.TimeUnit;
1617
import java.util.function.Consumer;
1718

@@ -49,6 +50,7 @@ public static void main(String[] args) throws Exception {
4950
testAuthoringEdgeCasesAndPlayerGuardRails();
5051
testCompiledJsonGuardsAndPlaybackLifecycle();
5152
testPlaybackNavigationAndTimer();
53+
testConcurrentControlCommands();
5254
testLargeGeneratedScript();
5355
System.out.println("ManagedCode.Tps Java tests passed.");
5456
}
@@ -258,6 +260,59 @@ private static void testPlaybackNavigationAndTimer() throws InterruptedException
258260
}
259261
}
260262

263+
private static void testConcurrentControlCommands() throws InterruptedException {
264+
ManagedCodeTps.TpsCompilationResult compilation = ManagedCodeTps.TpsRuntime.compileTps("""
265+
## [Intro]
266+
### [Lead]
267+
Ready now please stay focused for this longer playback sample.
268+
### [Close]
269+
Done soon after another phrase lands safely.
270+
""");
271+
ManagedCodeTps.TpsPlaybackSession session = new ManagedCodeTps.TpsPlaybackSession(compilation.script(), new ManagedCodeTps.TpsPlaybackSessionOptions(1_000, null, null, null, false));
272+
try {
273+
session.play();
274+
275+
CountDownLatch start = new CountDownLatch(1);
276+
CountDownLatch completed = new CountDownLatch(4);
277+
List<Throwable> failures = new CopyOnWriteArrayList<>();
278+
279+
for (int lane = 0; lane < 4; lane += 1) {
280+
final int laneId = lane;
281+
Thread thread = new Thread(() -> {
282+
try {
283+
start.await(3, TimeUnit.SECONDS);
284+
for (int iteration = 0; iteration < 40; iteration += 1) {
285+
switch ((laneId + iteration) % 6) {
286+
case 0 -> session.seek((iteration * 37) % Math.max(1, compilation.script().totalDurationMs()));
287+
case 1 -> session.nextWord();
288+
case 2 -> session.previousWord();
289+
case 3 -> session.nextBlock();
290+
case 4 -> session.previousBlock();
291+
default -> session.setSpeedOffsetWpm(((laneId * 5) + iteration) % 41 - 20);
292+
}
293+
}
294+
} catch (Throwable exception) {
295+
failures.add(exception);
296+
} finally {
297+
completed.countDown();
298+
}
299+
});
300+
thread.start();
301+
}
302+
303+
start.countDown();
304+
assertTrue(completed.await(3, TimeUnit.SECONDS), "concurrent control commands should complete");
305+
assertTrue(failures.isEmpty(), "concurrent control commands should not fail: " + failures);
306+
307+
ManagedCodeTps.TpsPlaybackSnapshot snapshot = session.snapshot();
308+
assertTrue(snapshot.state().elapsedMs() >= 0 && snapshot.state().elapsedMs() <= compilation.script().totalDurationMs(), "elapsedMs should stay in bounds");
309+
assertTrue(snapshot.state().currentWordIndex() >= -1 && snapshot.state().currentWordIndex() < compilation.script().words().size(), "currentWordIndex should stay in bounds");
310+
assertTrue(snapshot.tempo().effectiveBaseWpm() >= ManagedCodeTps.TpsSpec.MINIMUM_WPM && snapshot.tempo().effectiveBaseWpm() <= ManagedCodeTps.TpsSpec.MAXIMUM_WPM, "effectiveBaseWpm should stay in bounds");
311+
} finally {
312+
session.dispose();
313+
}
314+
}
315+
261316
private static void testLargeGeneratedScript() {
262317
StringBuilder builder = new StringBuilder();
263318
builder.append("---\nbase_wpm: 140\n---\n\n");

SDK/manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
3333
"setup": "dotnet",
3434
"enabled": true,
3535
"build": "dotnet build SDK/dotnet/ManagedCode.Tps.slnx -warnaserror --no-restore",
36-
"test": "dotnet test SDK/dotnet/ManagedCode.Tps.slnx --no-build --no-restore",
37-
"coverage": "dotnet test SDK/dotnet/ManagedCode.Tps.slnx --no-build --no-restore /p:CollectCoverage=true /p:CoverletOutputFormat=json /p:ThresholdType=line%2Cbranch%2Cmethod /p:Threshold=90"
36+
"test": "dotnet test SDK/dotnet/ManagedCode.Tps.slnx --no-restore",
37+
"coverage": "dotnet test SDK/dotnet/ManagedCode.Tps.slnx --no-restore /p:CollectCoverage=true /p:CoverletOutputFormat=json /p:ThresholdType=line%2Cbranch%2Cmethod /p:Threshold=90"
3838
},
3939
{
4040
"id": "flutter",

SDK/swift/Sources/ManagedCodeTps/ManagedCodeTps.swift

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -657,7 +657,6 @@ public final class TpsPlaybackSession {
657657
clearTimer()
658658
scheduleNextTick()
659659
}
660-
emitSnapshotChanged()
661660
return snapshot
662661
}
663662

@@ -714,18 +713,26 @@ public final class TpsPlaybackSession {
714713
}
715714

716715
private func updateStatus(_ nextStatus: TpsPlaybackStatus) {
717-
guard status != nextStatus else { return }
718716
let previousStatus = status
717+
guard previousStatus != nextStatus else { return }
719718
status = nextStatus
719+
if nextStatus != .playing {
720+
playbackOffsetMs = currentState.elapsedMs
721+
playbackStartedAtMs = 0
722+
}
723+
if nextStatus == .completed && previousStatus == .playing {
724+
clearTimer()
725+
}
720726
emit("statusChanged", event: ["state": currentState, "previousStatus": previousStatus.rawValue, "status": nextStatus.rawValue])
721727
}
722728

723729
private func updatePosition(_ elapsedMs: Int, _ nextStatus: TpsPlaybackStatus) -> PlayerState {
724730
let previousState = currentState
725731
let previousStatus = status
726732
let nextState = player.getState(elapsedMs)
733+
let resolvedStatus: TpsPlaybackStatus = nextStatus == .playing && nextState.isComplete ? .completed : nextStatus
727734
currentState = nextState
728-
updateStatus(nextStatus)
735+
updateStatus(resolvedStatus)
729736
if nextState.currentWord?.id != previousState.currentWord?.id {
730737
emit("wordChanged", event: ["state": nextState, "previousState": previousState, "status": status.rawValue])
731738
}
@@ -741,8 +748,7 @@ public final class TpsPlaybackSession {
741748
if nextState.elapsedMs != previousState.elapsedMs || status != previousStatus {
742749
emit("stateChanged", event: ["state": nextState, "previousState": previousState, "status": status.rawValue])
743750
}
744-
if !previousState.isComplete && nextState.isComplete {
745-
status = .completed
751+
if !previousState.isComplete && resolvedStatus == .completed {
746752
emit("completed", event: ["state": nextState, "previousState": previousState, "status": status.rawValue])
747753
}
748754
emitSnapshotChanged()

SDK/swift/Tests/ManagedCodeTpsTests/ManagedCodeTpsTests.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,37 @@ final class ManagedCodeTpsTests: XCTestCase {
214214
session.dispose()
215215
}
216216

217+
func testPlaybackStatusTransitionsAndSpeedSnapshotsStayCanonical() throws {
218+
let compilation = TpsRuntime.compileTps("## [Signal]\n### [Body]\nReady now.")
219+
let session = TpsPlaybackSession(compilation.script, options: .init(tickIntervalMs: 10))
220+
var statuses: [String] = []
221+
var snapshotEvents = 0
222+
223+
let disposeStatus = session.on("statusChanged") { event in
224+
let payload = event as! [String: Any]
225+
statuses.append(payload["status"] as! String)
226+
}
227+
let disposeSnapshot = session.on("snapshotChanged") { _ in
228+
snapshotEvents += 1
229+
}
230+
231+
_ = session.increaseSpeed(10)
232+
XCTAssertEqual(snapshotEvents, 1)
233+
234+
let completed = expectation(description: "completed-with-status")
235+
let disposeCompleted = session.on("completed") { _ in completed.fulfill() }
236+
_ = session.play()
237+
wait(for: [completed], timeout: 3)
238+
239+
disposeCompleted()
240+
disposeSnapshot()
241+
disposeStatus()
242+
243+
XCTAssertTrue(statuses.contains(TpsPlaybackStatus.playing.rawValue))
244+
XCTAssertTrue(statuses.contains(TpsPlaybackStatus.completed.rawValue))
245+
session.dispose()
246+
}
247+
217248
func testLargeGeneratedScript() {
218249
var lines: [String] = ["---", "base_wpm: 140", "---", ""]
219250
for segmentIndex in 1...8 {

SDK/ts/tests/runtime/examples.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import test from "node:test";
55
import { fileURLToPath } from "node:url";
66

77
import { compileTps, TpsPlaybackSession, TpsPlayer, TpsStandalonePlayer } from "../../src/index.ts";
8+
import { parseCompiledScriptJson } from "../../src/compiled-script.ts";
89
import { buildExampleSnapshot, EXAMPLE_FILES, loadExampleSnapshot } from "../../../scripts/example-snapshot-utils.mjs";
910

1011
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -148,3 +149,16 @@ test("source TypeScript standalone player can restore from compiled script and J
148149
playerFromJson.dispose();
149150
fromCanonicalTransport.dispose();
150151
});
152+
153+
test("source TypeScript runtime rejects malformed compiled JSON and invalid fixtures", () => {
154+
const invalid = compileTps(readFixture("invalid", "unknown-tag.tps"));
155+
assert.equal(invalid.ok, false);
156+
assert.ok(invalid.diagnostics.some((diagnostic) => diagnostic.code === "unknown-tag"));
157+
158+
assert.throws(() => parseCompiledScriptJson(""), /non-empty string/i);
159+
assert.throws(() => parseCompiledScriptJson("[]"), /script object/i);
160+
161+
const canonicalTransport = JSON.parse(readFixture("transport", "runtime-parity.compiled.json"));
162+
canonicalTransport.segments = [];
163+
assert.throws(() => parseCompiledScriptJson(JSON.stringify(canonicalTransport)), /at least one segment/i);
164+
});

0 commit comments

Comments
 (0)