From 234d6db39512fbaf994e69bcbffdf4d2d3e28265 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sun, 30 Aug 2026 09:00:46 +0800 Subject: [PATCH 1/2] feat(playtime): add playtime.extend command vocabulary Adds the shared names for an authorized playtime extension card, so Core can grant extra time on a running session from a scanned token: **playtime.extend:15m?profile= **playtime.extend:today?profile= The positional amount is a Go duration, or the literal "today" to waive the session limit for the rest of the local day. The two can never be confused because a Go duration always ends in a unit. The profile argument carries the switch ID authorizing the grant, the same value the profile command takes positionally. It names who permits the extension, not who receives it: the recipient is always whoever is being limited at the time, and is deliberately not selectable. No grammar change is required. Dotted command names and per-command advanced arguments already parse; this only adds the constants, the typed argument struct, and tests covering both amount forms. --- models.go | 2 + playtime_test.go | 155 +++++++++++++++++++++++++++++++++++++++++++++++ types.go | 19 ++++++ 3 files changed, 176 insertions(+) create mode 100644 playtime_test.go diff --git a/models.go b/models.go index 7624be7..87a5c5e 100644 --- a/models.go +++ b/models.go @@ -68,6 +68,8 @@ const ( ZapScriptCmdProfile = "profile" ZapScriptCmdProfileClear = "profile.clear" + ZapScriptCmdPlaytimeExtend = "playtime.extend" + ZapScriptCmdInputKey = "input.key" // DEPRECATED ZapScriptCmdKey = "key" // DEPRECATED ZapScriptCmdCoinP1 = "coinp1" // DEPRECATED diff --git a/playtime_test.go b/playtime_test.go new file mode 100644 index 0000000..d59936a --- /dev/null +++ b/playtime_test.go @@ -0,0 +1,155 @@ +// Copyright 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package zapscript_test + +import ( + "testing" + + "github.com/ZaparooProject/go-zapscript" + "github.com/google/go-cmp/cmp" +) + +func TestParsePlaytimeExtend(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want zapscript.Script + }{ + { + name: "duration amount", + input: `**playtime.extend:15m?profile=abc123`, + want: zapscript.Script{ + Cmds: []zapscript.Command{ + { + Name: zapscript.ZapScriptCmdPlaytimeExtend, + Args: []string{"15m"}, + AdvArgs: zapscript.NewAdvArgs(map[string]string{"profile": "abc123"}), + }, + }, + }, + }, + { + name: "compound duration amount", + input: `**playtime.extend:1h30m?profile=abc123`, + want: zapscript.Script{ + Cmds: []zapscript.Command{ + { + Name: zapscript.ZapScriptCmdPlaytimeExtend, + Args: []string{"1h30m"}, + AdvArgs: zapscript.NewAdvArgs(map[string]string{"profile": "abc123"}), + }, + }, + }, + }, + { + name: "today amount", + input: `**playtime.extend:today?profile=abc123`, + want: zapscript.Script{ + Cmds: []zapscript.Command{ + { + Name: zapscript.ZapScriptCmdPlaytimeExtend, + Args: []string{zapscript.PlaytimeExtendToday}, + AdvArgs: zapscript.NewAdvArgs(map[string]string{"profile": "abc123"}), + }, + }, + }, + }, + { + // The when argument is global, so it has to survive alongside a + // command-specific one. + name: "with global when argument", + input: `**playtime.extend:15m?profile=abc123&when=true`, + want: zapscript.Script{ + Cmds: []zapscript.Command{ + { + Name: zapscript.ZapScriptCmdPlaytimeExtend, + Args: []string{"15m"}, + AdvArgs: zapscript.NewAdvArgs(map[string]string{ + "profile": "abc123", + "when": "true", + }), + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := zapscript.NewParser(tt.input) + got, err := p.ParseScript() + if err != nil { + t.Fatalf("ParseScript() error = %v", err) + } + if diff := cmp.Diff(tt.want, got, cmp.AllowUnexported(zapscript.AdvArgs{})); diff != "" { + t.Errorf("ParseScript() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestPlaytimeExtendRoundTrip(t *testing.T) { + t.Parallel() + + inputs := []string{ + `**playtime.extend:15m?profile=abc123`, + `**playtime.extend:1h30m?profile=abc123`, + `**playtime.extend:today?profile=abc123`, + } + + for _, input := range inputs { + t.Run(input, func(t *testing.T) { + t.Parallel() + + first, err := zapscript.NewParser(input).ParseScript() + if err != nil { + t.Fatalf("ParseScript() error = %v", err) + } + if len(first.Cmds) != 1 { + t.Fatalf("expected 1 command, got %d", len(first.Cmds)) + } + + second, err := zapscript.NewParser(first.Cmds[0].String()).ParseScript() + if err != nil { + t.Fatalf("reparse error = %v", err) + } + + if diff := cmp.Diff(first, second, cmp.AllowUnexported(zapscript.AdvArgs{})); diff != "" { + t.Errorf("round-trip mismatch (-original +reparsed):\n string(): %s\n%s", + first.Cmds[0].String(), diff) + } + }) + } +} + +// TestPlaytimeExtendArgs checks the advanced argument tag matches the key +// constant, so the two cannot drift apart. +func TestPlaytimeExtendArgs(t *testing.T) { + t.Parallel() + + cmd := zapscript.Command{ + Name: zapscript.ZapScriptCmdPlaytimeExtend, + Args: []string{"15m"}, + AdvArgs: zapscript.NewAdvArgs(map[string]string{string(zapscript.KeyProfile): "abc123"}), + } + + if got := cmd.AdvArgs.Get(zapscript.KeyProfile); got != "abc123" { + t.Errorf("AdvArgs.Get(KeyProfile) = %q, want %q", got, "abc123") + } +} diff --git a/types.go b/types.go index c59e44e..eacbb06 100644 --- a/types.go +++ b/types.go @@ -51,6 +51,15 @@ const ( KeyName Key = "name" KeyPreNotice Key = "pre_notice" KeyHidden Key = "hidden" + KeyProfile Key = "profile" +) + +// Playtime extension amounts for the playtime.extend command. +const ( + // PlaytimeExtendToday waives the session limit for the rest of the + // local day instead of adding a fixed amount of time. Any other value + // is a Go duration. + PlaytimeExtendToday = "today" ) // Action values for the action advanced argument. @@ -187,3 +196,13 @@ type MisterScriptArgs struct { // Hidden controls whether the script window is hidden. Hidden string `advarg:"hidden"` } + +// PlaytimeExtendArgs contains advanced arguments for the playtime.extend +// command. +type PlaytimeExtendArgs struct { + GlobalArgs + // Profile is the switch ID authorizing the extension, the same value + // the profile command takes. It names who permits the grant, not who + // receives it. + Profile string `advarg:"profile"` +} From 0de7b6f186c127253f264b9c423b73d08a2910ed Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sun, 30 Aug 2026 09:15:05 +0800 Subject: [PATCH 2/2] test(playtime): assert the profile advarg tag against its key constant The previous test wrote and read the same AdvArgs map key, so it never touched PlaytimeExtendArgs.Profile and would have passed with the tag misspelled. This library declares the tags but does no decoding, so a tag that stops matching its key constant fails silently in the consumer instead of here. Assert the struct tag directly, and that GlobalArgs stays embedded so the global when argument keeps reaching the command. Verified by mutation: misspelling the tag now fails the test. --- playtime_test.go | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/playtime_test.go b/playtime_test.go index d59936a..ff0b827 100644 --- a/playtime_test.go +++ b/playtime_test.go @@ -16,12 +16,15 @@ package zapscript_test import ( + "reflect" "testing" "github.com/ZaparooProject/go-zapscript" "github.com/google/go-cmp/cmp" ) +// TestParsePlaytimeExtend covers both amount forms the command accepts, plus +// the argument combinations a written card is likely to carry. func TestParsePlaytimeExtend(t *testing.T) { t.Parallel() @@ -104,6 +107,9 @@ func TestParsePlaytimeExtend(t *testing.T) { } } +// TestPlaytimeExtendRoundTrip checks a parsed command survives String() and +// reparsing unchanged, so tooling that rewrites a card cannot alter the +// amount or drop the authorizing profile. func TestPlaytimeExtendRoundTrip(t *testing.T) { t.Parallel() @@ -138,18 +144,26 @@ func TestPlaytimeExtendRoundTrip(t *testing.T) { } } -// TestPlaytimeExtendArgs checks the advanced argument tag matches the key -// constant, so the two cannot drift apart. -func TestPlaytimeExtendArgs(t *testing.T) { +// TestPlaytimeExtendArgsContract pins the parts of PlaytimeExtendArgs that +// consumers bind against. This library only declares the tags; the decoding +// lives in the consumer, so a tag that stops matching its key constant would +// otherwise fail silently and far from here. +func TestPlaytimeExtendArgsContract(t *testing.T) { t.Parallel() - cmd := zapscript.Command{ - Name: zapscript.ZapScriptCmdPlaytimeExtend, - Args: []string{"15m"}, - AdvArgs: zapscript.NewAdvArgs(map[string]string{string(zapscript.KeyProfile): "abc123"}), + argsType := reflect.TypeOf(zapscript.PlaytimeExtendArgs{}) + + profile, ok := argsType.FieldByName("Profile") + if !ok { + t.Fatal("PlaytimeExtendArgs.Profile is missing") + } + if got, want := profile.Tag.Get("advarg"), string(zapscript.KeyProfile); got != want { + t.Errorf("Profile advarg tag = %q, want %q", got, want) } - if got := cmd.AdvArgs.Get(zapscript.KeyProfile); got != "abc123" { - t.Errorf("AdvArgs.Get(KeyProfile) = %q, want %q", got, "abc123") + // GlobalArgs has to stay embedded or the global when argument silently + // stops reaching the command. + if _, ok := argsType.FieldByName("When"); !ok { + t.Error("PlaytimeExtendArgs does not embed GlobalArgs") } }