diff --git a/internal/archdocs/pssg/render/funcs.go b/internal/archdocs/pssg/render/funcs.go index 6029636..631de85 100644 --- a/internal/archdocs/pssg/render/funcs.go +++ b/internal/archdocs/pssg/render/funcs.go @@ -8,6 +8,7 @@ import ( "net/url" "reflect" "regexp" + "sort" "strconv" "strings" @@ -141,7 +142,8 @@ func formatNumber(n interface{}) string { var isoDuration = regexp.MustCompile(`PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?`) -// durationMinutes converts an ISO 8601 duration to minutes. +// durationMinutes converts an ISO 8601 duration to minutes, truncating any +// remaining seconds. func durationMinutes(d string) int { matches := isoDuration.FindStringSubmatch(d) if matches == nil { @@ -149,7 +151,8 @@ func durationMinutes(d string) int { } hours, _ := strconv.Atoi(matches[1]) minutes, _ := strconv.Atoi(matches[2]) - return hours*60 + minutes + seconds, _ := strconv.Atoi(matches[3]) + return hours*60 + minutes + seconds/60 } // totalTime adds two ISO 8601 durations. @@ -237,32 +240,30 @@ func dict(values ...interface{}) map[string]interface{} { return result } +func clampSliceBounds(start, end, length int) (int, int) { + if start < 0 { + start = 0 + } + if end > length { + end = length + } + if start > end { + start = end + } + return start, end +} + func sliceHelper(items interface{}, start, end int) interface{} { switch v := items.(type) { case []string: - if start < 0 { - start = 0 - } - if end > len(v) { - end = len(v) - } - return v[start:end] + s, e := clampSliceBounds(start, end, len(v)) + return v[s:e] case []*entity.Entity: - if start < 0 { - start = 0 - } - if end > len(v) { - end = len(v) - } - return v[start:end] + s, e := clampSliceBounds(start, end, len(v)) + return v[s:e] case []interface{}: - if start < 0 { - start = 0 - } - if end > len(v) { - end = len(v) - } - return v[start:end] + s, e := clampSliceBounds(start, end, len(v)) + return v[s:e] } return items } @@ -282,6 +283,7 @@ func length(v interface{}) int { func sortStrings(s []string) []string { result := make([]string, len(s)) copy(result, s) + sort.Strings(result) return result } diff --git a/internal/archdocs/pssg/render/funcs_test.go b/internal/archdocs/pssg/render/funcs_test.go new file mode 100644 index 0000000..cd08927 --- /dev/null +++ b/internal/archdocs/pssg/render/funcs_test.go @@ -0,0 +1,69 @@ +package render + +import ( + "testing" +) + +func TestDurationMinutes(t *testing.T) { + cases := []struct { + input string + want int + }{ + {"PT30M", 30}, + {"PT1H", 60}, + {"PT1H30M", 90}, + {"PT90S", 1}, // 90 seconds → 1 minute (truncated) + {"PT30S", 0}, // 30 seconds → 0 minutes (truncated) + {"PT2H30M45S", 150}, // seconds truncated + {"PT10M30S", 10}, // 10 min 30 sec → 10 min + {"PT0S", 0}, + {"", 0}, + {"invalid", 0}, + } + for _, c := range cases { + got := durationMinutes(c.input) + if got != c.want { + t.Errorf("durationMinutes(%q) = %d, want %d", c.input, got, c.want) + } + } +} + +func TestSliceHelper(t *testing.T) { + s := []string{"a", "b", "c"} + + // Normal case + got := sliceHelper(s, 0, 2).([]string) + if len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Errorf("sliceHelper normal: got %v", got) + } + + // start > len(v) — must not panic, return empty + got = sliceHelper(s, 5, 10).([]string) + if len(got) != 0 { + t.Errorf("sliceHelper start>len: want empty, got %v", got) + } + + // start > end after clamping — must not panic + got = sliceHelper(s, 2, 1).([]string) + if len(got) != 0 { + t.Errorf("sliceHelper start>end: want empty, got %v", got) + } + + // negative start + got = sliceHelper(s, -1, 2).([]string) + if len(got) != 2 { + t.Errorf("sliceHelper negative start: got %v", got) + } +} + +func TestSortStrings(t *testing.T) { + input := []string{"c", "a", "b"} + got := sortStrings(input) + if got[0] != "a" || got[1] != "b" || got[2] != "c" { + t.Errorf("sortStrings: got %v, want [a b c]", got) + } + // Original must not be modified + if input[0] != "c" { + t.Errorf("sortStrings modified original slice") + } +}