Files
2026-07-13 12:29:49 +08:00

179 lines
6.5 KiB
Zig

//! Behavioral harness for dual-notes-autosave, zig track. Injected by the
//! grader as src/eval_behavior_spec.zig and run through `native test`.
//! Drives update with the deterministic fake effects executor and asserts
//! the same behavioral spec the ts track's harness asserts.
const std = @import("std");
const native_sdk = @import("native_sdk");
const canvas = native_sdk.canvas;
const main = @import("main.zig");
const testing = std.testing;
const serialized_after_edit =
"1\tGroceries\tmilk, eggs, bread\n" ++
"2\tIdeas\tnative first\n" ++
"3\tStandup\tdemo the panel\n";
const Rig = struct {
model: main.Model,
fx: main.Effects,
fn init() Rig {
var fx = main.Effects.init(testing.allocator);
fx.executor = .fake;
return .{ .model = main.initialModel(), .fx = fx };
}
fn deinit(self: *Rig) void {
self.fx.deinit();
}
fn dispatch(self: *Rig, msg: main.Msg) void {
main.update(&self.model, msg, &self.fx);
}
fn drain(self: *Rig) void {
while (self.fx.takeMsg()) |msg| main.update(&self.model, msg, &self.fx);
}
/// Replace the selected note's body: clear, then insert.
fn editBody(self: *Rig, text: []const u8) void {
self.dispatch(.{ .edit = .clear });
self.dispatch(.{ .edit = .{ .insert_text = text } });
}
/// Fire the armed autosave debounce and drain it through update.
fn fireAutosave(self: *Rig) !void {
const timer = self.fx.pendingTimerAt(0) orelse return error.NoDebounceArmed;
try self.fx.fireTimer(timer.key);
self.drain();
}
/// The single pending write, or an error naming what is missing.
fn pendingWrite(self: *Rig) !main.Effects.FileRequest {
const request = self.fx.pendingFileAt(0) orelse return error.NoWriteIssued;
try testing.expectEqual(native_sdk.EffectFileOp.write, request.op);
return request;
}
fn finishWrite(self: *Rig, outcome: native_sdk.EffectFileOutcome) !void {
const request = try self.pendingWrite();
try self.fx.feedFileResult(request.key, outcome, "");
self.drain();
}
};
test "the starter behavior is intact: selection and text editing" {
var rig = Rig.init();
defer rig.deinit();
try testing.expectEqualStrings("milk, eggs", rig.model.editorText());
rig.dispatch(.{ .select = 2 });
try testing.expectEqualStrings("Ideas", rig.model.selectedTitle());
try testing.expectEqualStrings("native first", rig.model.editorText());
rig.dispatch(.{ .select = 1 });
rig.editBody("call mom");
try testing.expectEqualStrings("call mom", rig.model.editorText());
rig.dispatch(.{ .select = 3 });
try testing.expectEqualStrings("demo the panel", rig.model.editorText());
}
test "starts clean; edits mark dirty and re-arm one keyed 800ms debounce" {
var rig = Rig.init();
defer rig.deinit();
try testing.expect(rig.model.save_state == .clean);
rig.editBody("milk");
try testing.expect(rig.model.save_state == .dirty);
const timer = rig.fx.pendingTimerAt(0) orelse return error.NoDebounceArmed;
try testing.expectEqual(@as(u64, 800), timer.interval_ms);
rig.editBody("milk, eggs");
// Still exactly ONE armed timer, same key: the re-arm idiom, not a pile.
try testing.expectEqual(@as(usize, 1), rig.fx.pendingTimerCount());
const rearmed = rig.fx.pendingTimerAt(0) orelse return error.DebounceNotRearmed;
try testing.expectEqual(timer.key, rearmed.key);
try testing.expectEqual(@as(u64, 800), rearmed.interval_ms);
}
test "the autosave fire writes the pinned serialization to notes.tsv" {
var rig = Rig.init();
defer rig.deinit();
rig.editBody("milk, eggs, bread");
try rig.fireAutosave();
try testing.expect(rig.model.save_state == .saving);
const write = try rig.pendingWrite();
try testing.expectEqualStrings("notes.tsv", write.path);
try testing.expectEqualStrings(serialized_after_edit, write.bytes);
try rig.finishWrite(.ok);
try testing.expect(rig.model.save_state == .saved);
}
test "save now writes immediately, cancels the pending autosave, and no-ops when clean" {
var rig = Rig.init();
defer rig.deinit();
// Clean: nothing to save.
rig.dispatch(.save_now);
try testing.expectEqual(@as(usize, 0), rig.fx.pendingFileCount());
try testing.expect(rig.model.save_state == .clean);
rig.editBody("milk, eggs, bread");
try testing.expectEqual(@as(usize, 1), rig.fx.pendingTimerCount());
rig.dispatch(.save_now);
// The pending autosave is CANCELLED, not left to fire into a no-op.
try testing.expectEqual(@as(usize, 0), rig.fx.pendingTimerCount());
const write = try rig.pendingWrite();
try testing.expectEqualStrings("notes.tsv", write.path);
try testing.expectEqualStrings(serialized_after_edit, write.bytes);
try testing.expect(rig.model.save_state == .saving);
// Save now while a save is in flight: nothing new.
rig.dispatch(.save_now);
try testing.expectEqual(@as(usize, 1), rig.fx.pendingFileCount());
try rig.finishWrite(.ok);
try testing.expect(rig.model.save_state == .saved);
// A stale fire after everything saved writes nothing.
rig.dispatch(.{ .autosave_fired = .{ .key = 0, .timestamp_ns = 0, .outcome = .fired } });
try testing.expectEqual(@as(usize, 0), rig.fx.pendingFileCount());
try testing.expect(rig.model.save_state == .saved);
}
test "a save result landing after newer edits does not mark them saved" {
var rig = Rig.init();
defer rig.deinit();
rig.editBody("v1");
try rig.fireAutosave();
try testing.expect(rig.model.save_state == .saving);
rig.editBody("v2");
try testing.expect(rig.model.save_state == .dirty);
try rig.finishWrite(.ok);
try testing.expect(rig.model.save_state == .dirty);
}
test "a write failure is a visible failed state; editing recovers to dirty" {
var rig = Rig.init();
defer rig.deinit();
rig.editBody("v1");
try rig.fireAutosave();
try rig.finishWrite(.io_failed);
try testing.expect(rig.model.save_state == .failed);
rig.editBody("v2");
try testing.expect(rig.model.save_state == .dirty);
}
test "serialization follows edits on other notes" {
var rig = Rig.init();
defer rig.deinit();
rig.dispatch(.{ .select = 2 });
rig.editBody("native first, always");
try rig.fireAutosave();
const write = try rig.pendingWrite();
try testing.expectEqualStrings(
"1\tGroceries\tmilk, eggs\n" ++
"2\tIdeas\tnative first, always\n" ++
"3\tStandup\tdemo the panel\n",
write.bytes,
);
}