mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-15 21:13:00 +08:00
186 lines
6.0 KiB
Go
186 lines
6.0 KiB
Go
package domain
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func mustParse(t *testing.T, text string) ClockTime {
|
|
t.Helper()
|
|
value, err := ParseClockTime(text)
|
|
if err != nil {
|
|
t.Fatalf("ParseClockTime(%q): %v", text, err)
|
|
}
|
|
return value
|
|
}
|
|
|
|
func beijing(t *testing.T, text string) time.Time {
|
|
t.Helper()
|
|
at, err := time.ParseInLocation("2006-01-02 15:04", text, Beijing)
|
|
if err != nil {
|
|
t.Fatalf("parse %q: %v", text, err)
|
|
}
|
|
return at
|
|
}
|
|
|
|
func TestParseClockTimeAcceptsOnlyHHMM(t *testing.T) {
|
|
valid := map[string][2]int{
|
|
"00:00": {0, 0},
|
|
"06:00": {6, 0},
|
|
"23:59": {23, 59},
|
|
}
|
|
for text, want := range valid {
|
|
got := mustParse(t, text)
|
|
if got.Hour() != want[0] || got.Minute() != want[1] {
|
|
t.Errorf("%q parsed to %02d:%02d", text, got.Hour(), got.Minute())
|
|
}
|
|
if got.String() != text {
|
|
t.Errorf("round trip of %q produced %q", text, got.String())
|
|
}
|
|
}
|
|
|
|
// Lenient forms are rejected: they mean something other than the UI wrote
|
|
// this value, and accepting them hides the real problem.
|
|
for _, text := range []string{
|
|
"6:00", "06:0", " 06:00", "06:00 ", "06:00:00", "24:00", "12:60",
|
|
"aa:bb", "0600", "", "06-00", "12:00",
|
|
} {
|
|
if _, err := ParseClockTime(text); err == nil {
|
|
t.Errorf("accepted %q", text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClockTimeJSONRoundTrip(t *testing.T) {
|
|
type holder struct {
|
|
At ClockTime `json:"at"`
|
|
}
|
|
encoded, err := json.Marshal(holder{At: mustParse(t, "06:05")})
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
if string(encoded) != `{"at":"06:05"}` {
|
|
t.Fatalf("encoded = %s, want {\"at\":\"06:05\"}", encoded)
|
|
}
|
|
|
|
var decoded holder
|
|
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if decoded.At != mustParse(t, "06:05") {
|
|
t.Fatalf("decoded = %v", decoded.At)
|
|
}
|
|
|
|
for _, bad := range []string{`{"at":"6:05"}`, `{"at":605}`, `{"at":null}`} {
|
|
if err := json.Unmarshal([]byte(bad), &decoded); err == nil {
|
|
t.Errorf("accepted %s", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
// T20 -- the three window shapes and the boundaries between them.
|
|
func TestQuietWindowMembership(t *testing.T) {
|
|
sameDay := QuietWindow{Start: mustParse(t, "01:00"), End: mustParse(t, "06:00")}
|
|
overnight := QuietWindow{Start: mustParse(t, "23:00"), End: mustParse(t, "06:00")}
|
|
empty := QuietWindow{Start: mustParse(t, "03:00"), End: mustParse(t, "03:00")}
|
|
|
|
cases := []struct {
|
|
name string
|
|
window QuietWindow
|
|
at string
|
|
want bool
|
|
}{
|
|
{"same day, before", sameDay, "2026-09-12 00:59", false},
|
|
{"same day, at start is inside", sameDay, "2026-09-12 01:00", true},
|
|
{"same day, middle", sameDay, "2026-09-12 03:00", true},
|
|
{"same day, at end is outside", sameDay, "2026-09-12 06:00", false},
|
|
{"overnight, evening", overnight, "2026-09-12 23:30", true},
|
|
{"overnight, after midnight", overnight, "2026-09-12 02:00", true},
|
|
{"overnight, at end is outside", overnight, "2026-09-12 06:00", false},
|
|
{"overnight, daytime", overnight, "2026-09-12 12:00", false},
|
|
// start == end is an empty window, not a 24-hour one. Reading it as
|
|
// all-day would log every account out forever the moment a user typed
|
|
// the same value twice.
|
|
{"equal endpoints exclude everything", empty, "2026-09-12 03:00", false},
|
|
{"equal endpoints exclude noon", empty, "2026-09-12 12:00", false},
|
|
}
|
|
|
|
for _, testCase := range cases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
if got := testCase.window.Contains(beijing(t, testCase.at)); got != testCase.want {
|
|
t.Fatalf("Contains(%s) = %v, want %v", testCase.at, got, testCase.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The window is Beijing time regardless of the caller's zone, so a router whose
|
|
// local zone is wrong still applies the campus policy at the right moment.
|
|
func TestQuietWindowIsEvaluatedInBeijingTime(t *testing.T) {
|
|
window := QuietWindow{Start: mustParse(t, "01:00"), End: mustParse(t, "06:00")}
|
|
utc := time.Date(2026, 9, 11, 18, 0, 0, 0, time.UTC) // 02:00 Beijing
|
|
|
|
if !window.Contains(utc) {
|
|
t.Fatal("a UTC instant inside the Beijing window was reported outside")
|
|
}
|
|
if !window.Contains(utc.In(time.FixedZone("UTC-5", -5*3600))) {
|
|
t.Fatal("the same instant in another zone gave a different answer")
|
|
}
|
|
}
|
|
|
|
// Forced logout runs once per occurrence. Deriving the identity from the
|
|
// occurrence's start instant means a clock correction inside the window maps to
|
|
// the same id, so the sweep does not run twice.
|
|
func TestQuietOccurrenceIDIsStableWithinOneOccurrence(t *testing.T) {
|
|
overnight := QuietWindow{Start: mustParse(t, "23:00"), End: mustParse(t, "06:00")}
|
|
|
|
evening, ok := overnight.OccurrenceID(beijing(t, "2026-09-12 23:10"))
|
|
if !ok {
|
|
t.Fatal("23:10 is inside the window")
|
|
}
|
|
afterMidnight, ok := overnight.OccurrenceID(beijing(t, "2026-09-13 02:00"))
|
|
if !ok {
|
|
t.Fatal("02:00 is inside the same occurrence")
|
|
}
|
|
if evening != afterMidnight {
|
|
t.Fatalf("occurrence ids differ across midnight: %q vs %q; forced "+
|
|
"logout would run a second time", evening, afterMidnight)
|
|
}
|
|
|
|
nextNight, ok := overnight.OccurrenceID(beijing(t, "2026-09-13 23:10"))
|
|
if !ok {
|
|
t.Fatal("the next night is inside the window")
|
|
}
|
|
if nextNight == evening {
|
|
t.Fatal("two different nights share an occurrence id, so the second " +
|
|
"night would be skipped")
|
|
}
|
|
|
|
if _, ok := overnight.OccurrenceID(beijing(t, "2026-09-13 12:00")); ok {
|
|
t.Fatal("noon is outside the window but produced an occurrence id")
|
|
}
|
|
}
|
|
|
|
func TestQuietWindowNextBoundary(t *testing.T) {
|
|
overnight := QuietWindow{Start: mustParse(t, "23:00"), End: mustParse(t, "06:00")}
|
|
|
|
next, ok := overnight.NextBoundary(beijing(t, "2026-09-12 12:00"))
|
|
if !ok {
|
|
t.Fatal("a non-empty window always has a next boundary")
|
|
}
|
|
if want := beijing(t, "2026-09-12 23:00"); !next.Equal(want) {
|
|
t.Fatalf("next = %s, want %s", next, want)
|
|
}
|
|
|
|
next, _ = overnight.NextBoundary(beijing(t, "2026-09-12 23:30"))
|
|
if want := beijing(t, "2026-09-13 06:00"); !next.Equal(want) {
|
|
t.Fatalf("next = %s, want %s", next, want)
|
|
}
|
|
|
|
empty := QuietWindow{Start: mustParse(t, "03:00"), End: mustParse(t, "03:00")}
|
|
if _, ok := empty.NextBoundary(beijing(t, "2026-09-12 12:00")); ok {
|
|
t.Fatal("an empty window has no boundary to wait for")
|
|
}
|
|
}
|