Repository: j178/leetgo (language: python)

Issue:
retrieve output from leetcode for custom cases
For user provided test cases, the expected output can be left blank, we submit the code (must can be compiled) to leetcode to retrieve the output, and write it back to `input.txt`

ref: https://github.com/j178/leetgo/pull/90#issuecomment-1416735809


Discussion / hints:
Do we need to sanitize this? What if a user provides a test case with the wrong output

Also, do we need to sanitize the input to make sure it's legal
> Do we need to sanitize this? What if a user provides a test case with the wrong output

We can't tell, wrong output will get wrong result, user will recognize it.



> do we need to sanitize the input to make sure it's legal

Not necessary I think, LeetCode will report an error if it's illegal, we just need to show the error to user.

Fix patch:
diff --git a/README.md b/README.md
index 2458d862..df361f7d 100644
--- a/README.md
+++ b/README.md
@@ -304,6 +304,25 @@ There are three ways to make cookies available to `leetgo`:
 
 ## Advanced Usage
 
+### `testcases.txt`
+
+`testcasts.txt` is generated by `leetgo` and contains all the test cases of the question.
+
+You can add a new test case by specifying only the input and leaving the output blank. When you run `leetgo test` (without `-L`), the expected output will be retrieved from the remote server.
+For example:
+
+```
+input:
+[3,3]
+6
+output:
+
+input:
+[1,2,3,4]
+7
+output:
+```
+
 ### Templates
 
 Several fields in leetgo's config file support templating. These fields are often suffixed with `_template`.
diff --git a/README_zh.md b/README_zh.md
index 1e0869e9..10b6d893 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -296,51 +296,69 @@ editor:
 
 ## 进阶用法
 
-1. template 相关
-
-    `leetgo` 的配置中有许多支持 Go template，如果你熟悉 Go template 语法的话，可以配置出更加个性化的文件名和代码模板。
-
-2. Blocks
-
-    可以用 blocks 来自定义代码中的一些部分，目前支持的 block 有：
-    - header
-    - description
-    - title
-    - beforeMarker
-    - beforeCode
-    - code
-    - afterCode
-    - afterMarker
-    
-    示例：
-    ```yaml
-    code:
-    lang: cpp
-    cpp:
-      blocks:
-      - name: beforeCode
-        template: |
-          #include <iostream>
-          using namespace std;
-      - name: afterMarker
-        template: |
-          int main() {}
-     ```
-
-3. Script
-
-    `leetgo` 支持自定义一个 JavaScript 脚本来处理函数代码，示例：
-    ```yaml
-    code:
-      lang: cpp
-      cpp:
-        modifiers:
-        - name: removeUselessComments
-        - script: |
-            function modify(code) {
-              return "// hello world\n" + code;
-            }
-    ```
+### `testcases.txt` 相关
+
+`leetgo` 会自动为你生成 `testcases.txt` 文件，这个文件包含了所有测试用例，你可以在这个文件中添加自己的测试用例，`leetgo test` 会自动读取这个文件中的测试用例来作为程序的输入。
+
+当你尚不清楚用例的输出时，你可以将 `output:` 部分留空。当执行 `leetgo test` 时，`leetgo` 自动将远程输出的正确结果填充到 `output:` 部分。示例：
+
+```
+input:
+[3,3]
+6
+output:
+
+input:
+[1,2,3,4]
+7
+output:
+```
+
+### template 相关
+
+`leetgo` 的配置中有许多支持 Go template，如果你熟悉 Go template 语法的话，可以配置出更加个性化的文件名和代码模板。
+
+### Blocks
+
+可以用 blocks 来自定义代码中的一些部分，目前支持的 block 有：
+- header
+- description
+- title
+- beforeMarker
+- beforeCode
+- code
+- afterCode
+- afterMarker
+
+示例：
+```yaml
+code:
+lang: cpp
+cpp:
+  blocks:
+  - name: beforeCode
+    template: |
+      #include <iostream>
+      using namespace std;
+  - name: afterMarker
+    template: |
+      int main() {}
+ ```
+
+### Script
+
+`leetgo` 支持自定义一个 JavaScript 脚本来处理函数代码，示例：
+```yaml
+code:
+  lang: cpp
+  cpp:
+    modifiers:
+    - name: removeUselessComments
+    - script: |
+        function modify(code) {
+          return "// hello world\n" + code;
+        }
+```
 
 ## FAQ
 
diff --git a/lang/gen.go b/lang/gen.go
index 76c3b27b..6adcea1f 100644
--- a/lang/gen.go
+++ b/lang/gen.go
@@ -1,6 +1,7 @@
 package lang
 
 import (
+	"errors"
 	"fmt"
 	"os"
 	"strings"
@@ -201,13 +202,9 @@ func GeneratePathsOnly(q *leetcode.QuestionData) (*GenerateResult, error) {
 }
 
 func GetSolutionCode(q *leetcode.QuestionData) (string, error) {
-	result, err := GeneratePathsOnly(q)
+	codeFile, err := GetFileOutput(q, CodeFile)
 	if err != nil {
-		return "", err
-	}
-	codeFile := result.GetFile(CodeFile)
-	if codeFile == nil {
-		return "", fmt.Errorf("no code file generated")
+		return "", errors.New("code file not found")
 	}
 	code, err := codeFile.GetContent()
 	if err != nil {
@@ -243,13 +240,9 @@ func GetSolutionCode(q *leetcode.QuestionData) (string, error) {
 }
 
 func UpdateSolutionCode(q *leetcode.QuestionData, newCode string) error {
-	result, err := GeneratePathsOnly(q)
+	codeFile, err := GetFileOutput(q, CodeFile)
 	if err != nil {
-		return err
-	}
-	codeFile := result.GetFile(CodeFile)
-	if codeFile == nil {
-		return fmt.Errorf("no code file generated")
+		return errors.New("code file not found")
 	}
 	code, err := codeFile.GetContent()
 	if err != nil {
@@ -280,14 +273,14 @@ func UpdateSolutionCode(q *leetcode.QuestionData, newCode string) error {
 	return nil
 }
 
-func GetTestCases(q *leetcode.QuestionData) (TestCases, error) {
+func GetFileOutput(q *leetcode.QuestionData, fileType FileType) (*FileOutput, error) {
 	result, err := GeneratePathsOnly(q)
 	if err != nil {
-		return TestCases{}, err
+		return nil, err
 	}
-	testCasesFile := result.GetFile(TestCasesFile)
-	if testCasesFile == nil {
-		return TestCases{}, fmt.Errorf("no test cases file generated")
+	f := result.GetFile(fileType)
+	if f == nil {
+		return nil, errors.New("file not found")
 	}
-	return ParseTestCases(q, testCasesFile)
+	return f, nil
 }
diff --git a/lang/judge.go b/lang/judge.go
index 5a5036e8..dc113588 100644
--- a/lang/judge.go
+++ b/lang/judge.go
@@ -67,8 +67,11 @@ func (j *sliceJudger) Judge(input []string, output, actualOutput string) JudgeRe
 		return accepted()
 	}
 
-	a, _ := goutils.SplitArray(output)
-	b, _ := goutils.SplitArray(actualOutput)
+	a, err1 := goutils.SplitArray(output)
+	b, err2 := goutils.SplitArray(actualOutput)
+	if err1 != nil || err2 != nil {
+		return failed(fmt.Sprintf("expected %q, got %q", output, actualOutput))
+	}
 	if len(a) != len(b) {
 		return failed(fmt.Sprintf("expected %d elements, got %d", len(a), len(b)))
 	}
diff --git a/leetcode/models.go b/leetcode/models.go
index 39105a2b..122e2411 100644
--- a/leetcode/models.go
+++ b/leetcode/models.go
@@ -240,6 +240,10 @@ func (r *RunCheckResult) GetState() string {
 	return r.State
 }
 
+func (r *RunCheckResult) Accepted() bool {
+	return r.StatusCode == int(Accepted)
+}
+
 type QuestionList struct {
 	Questions []*QuestionData `json:"questions"`
 	HasMore   bool            `json:"hasMore"`


Test patch:
diff --git a/cmd/test.go b/cmd/test.go
index 15b76e41..03736f4e 100644
--- a/cmd/test.go
+++ b/cmd/test.go
@@ -160,10 +160,15 @@ func runTestRemotely(
 	if err != nil {
 		return nil, fmt.Errorf("failed to fetch question: %s", err)
 	}
-	cases, err := lang.GetTestCases(q)
+
+	testCasesFile, err := lang.GetFileOutput(q, lang.TestCasesFile)
 	if err != nil {
 		return nil, fmt.Errorf("failed to get test cases: %w", err)
 	}
+	cases, err := lang.ParseTestCases(q, testCasesFile)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse test cases: %w", err)
+	}
 	if len(cases.Cases) == 0 {
 		return nil, fmt.Errorf("no test cases found")
 	}
@@ -192,6 +197,22 @@ func runTestRemotely(
 	}
 	r := testResult.(*leetcode.RunCheckResult)
 	r.InputData = interResult.TestCase
+
+	if r.Accepted() {
+		updated, err := cases.UpdateOutputs(r.ExpectedCodeAnswer)
+		if err != nil {
+			log.Debug("failed to update test cases", "err", err)
+		} else if updated {
+			content := []byte(cases.String())
+			err = utils.WriteFile(testCasesFile.GetPath(), content)
+			if err != nil {
+				log.Debug("failed to update test cases", "err", err)
+			} else {
+				log.Info("testcases.txt updated")
+			}
+		}
+	}
+
 	return r, nil
 }
 
diff --git a/lang/test.go b/lang/test.go
index ecaf17fa..915aa15c 100644
--- a/lang/test.go
+++ b/lang/test.go
@@ -3,11 +3,9 @@ package lang
 import (
 	"bytes"
 	"context"
-	"errors"
 	"fmt"
 	"os/exec"
 	"reflect"
-	"strconv"
 	"strings"
 	"time"
 
@@ -74,6 +72,7 @@ func typeNameToType(ty string) reflect.Type {
 	return nil
 }
 
+// deserialize deserializes a string to a reflect.Value. The tpName is a LeeCode type name.
 func deserialize(tpName string, raw string) (reflect.Value, error) {
 	raw = strings.TrimSpace(raw)
 	goTpName := toGoType(tpName)
@@ -84,283 +83,7 @@ func deserialize(tpName string, raw string) (reflect.Value, error) {
 	return goutils.DeserializeValue(ty, raw)
 }
 
-type TestCase struct {
-	Question *leetcode.QuestionData
-	No       int
-	Input    []string
-	Output   string
-}
-
-func (c *TestCase) Check() error {
-	q := c.Question
-	err := q.Fulfill()
-	if err != nil {
-		return fmt.Errorf("failed to get question data: %w", err)
-	}
-	narg := q.MetaData.NArg()
-	if q.MetaData.SystemDesign {
-		// System design questions have two inputs, the first one is a list of strings, but the second is a list of
-		// different types. We just check if it's a valid list.
-		// input:
-		// ["LRUCache","put","put","get","put","get","put","get","get","get"]
-		// [[2],[1,1],[2,2],[1],[3,3],[2],[4,4],[1],[3],[4]]
-		// output:
-		// [null,null,null,1,null,-1,null,-1,3,4]
-		if len(c.Input) != narg {
-			return fmt.Errorf("should have %d arguments, got %d", narg, len(c.Input))
-		}
-		l1, err := deserialize("[]string", c.Input[0])
-		if err != nil {
-			return fmt.Errorf("cannot parse %s as []string", c.Input[0])
-		}
-		l2, err := goutils.SplitArray(c.Input[1])
-		if err != nil {
-			return fmt.Errorf("%s is not a valid list", c.Input[0])
-		}
-		l3, err := goutils.SplitArray(c.Output)
-		if err != nil {
-			return fmt.Errorf("%s is not a valid list", c.Input[0])
-		}
-		if l1.Len() != len(l2) || l1.Len() != len(l3) {
-			return fmt.Errorf("input and output should have the same length")
-		}
-		return nil
-	}
-
-	resultType := q.MetaData.ResultType()
-	if len(c.Input) != narg {
-		return fmt.Errorf("should have %d arguments, got %d", narg, len(c.Input))
-	}
-	for j, arg := range c.Input {
-		tp := q.MetaData.Params[j].Type
-		if _, err := deserialize(tp, arg); err != nil {
-			return fmt.Errorf("cannot parse %s as %s", arg, tp)
-		}
-	}
-	if _, err := deserialize(resultType, c.Output); err != nil {
-		return fmt.Errorf("cannot parse %s as %s", c.Output, resultType)
-	}
-	return nil
-}
-
-func (c *TestCase) InputString() string {
-	return utils.EnsureTrailingNewline(strings.Join(c.Input, "\n"))
-}
-
-type TestCases struct {
-	Cases    []TestCase
-	Question *leetcode.QuestionData
-}
-
-func (tc *TestCases) AddCase(c TestCase) {
-	c.No = len(tc.Cases) + 1
-	c.Question = tc.Question
-	tc.Cases = append(tc.Cases, c)
-}
-
-func (tc *TestCases) Contains(c TestCase) bool {
-	for _, tc := range tc.Cases {
-		if reflect.DeepEqual(c.Input, tc.Input) {
-			return true
-		}
-	}
-	return false
-}
-
-func (tc *TestCases) String() string {
-	buf := new(bytes.Buffer)
-	for i, c := range tc.Cases {
-		_, _ = fmt.Fprintln(buf, testCaseInputMark)
-		_, _ = fmt.Fprint(buf, c.InputString())
-		_, _ = fmt.Fprintln(buf, testCaseOutputMark)
-		_, _ = fmt.Fprintln(buf, c.Output)
-		if i != len(tc.Cases)-1 {
-			_, _ = fmt.Fprintln(buf)
-		}
-	}
-	return buf.String()
-}
-
-func (tc *TestCases) InputString() string {
-	buf := new(bytes.Buffer)
-	for _, c := range tc.Cases {
-		buf.WriteString(c.InputString())
-	}
-	return buf.String()
-}
-
-func (tc *TestCases) Check() error {
-	q := tc.Question
-	err := q.Fulfill()
-	if err != nil {
-		return fmt.Errorf("failed to get question data: %w", err)
-	}
-	for i, c := range tc.Cases {
-		if err := c.Check(); err != nil {
-			return fmt.Errorf("case %d: %w", i, err)
-		}
-	}
-	return nil
-}
-
-func ParseTestCases(q *leetcode.QuestionData, f *FileOutput) (TestCases, error) {
-	tc := TestCases{Question: q}
-
-	content, err := f.GetContent()
-	if err != nil {
-		return tc, err
-	}
-	var (
-		inputLines    []string
-		output        string
-		inputStarted  bool
-		outputStarted bool
-	)
-	lines := utils.SplitLines(content)
-	for _, line := range lines {
-		line := strings.TrimSpace(line)
-		switch {
-		case line == "":
-			continue
-		case strings.HasPrefix(line, testCaseInputMark):
-			inputStarted = true
-			outputStarted = false
-			if len(inputLines) > 0 && len(output) > 0 {
-				tc.Cases = append(
-					tc.Cases, TestCase{
-						Question: q,
-						No:       len(tc.Cases) + 1,
-						Input:    append([]string(nil), inputLines...),
-						Output:   output,
-					},
-				)
-				inputLines = inputLines[:0]
-				output = ""
-			}
-		case strings.HasPrefix(line, testCaseOutputMark):
-			outputStarted = true
-			inputStarted = false
-		case inputStarted:
-			inputLines = append(inputLines, line)
-		case outputStarted:
-			if len(output) > 0 {
-				return tc, errors.New("invalid test case: output should be a single line")
-			}
-			output = line
-		}
-	}
-	if len(inputLines) > 0 && len(output) > 0 {
-		tc.Cases = append(
-			tc.Cases, TestCase{
-				Question: q,
-				No:       len(tc.Cases) + 1,
-				Input:    append([]string(nil), inputLines...),
-				Output:   output,
-			},
-		)
-	}
-
-	if err := tc.Check(); err != nil {
-		return tc, fmt.Errorf("invalid test case: %w", err)
-	}
-
-	return tc, nil
-}
-
-type Range struct {
-	whole  bool
-	max    int
-	ranges [][2]int
-}
-
-func (r *Range) Contains(idx int) bool {
-	if r.whole {
-		return true
-	}
-	for _, rg := range r.ranges {
-		if idx >= rg[0] && idx <= rg[1] {
-			return true
-		}
-	}
-	return false
-}
-
-func ParseRange(expr string, max int) (*Range, error) {
-	r := &Range{max: max}
-
-	if expr == "" || expr == "-" {
-		r.whole = true
-		return r, nil
-	}
-
-	parts := strings.Split(expr, ",")
-	for _, part := range parts {
-		var start, end int
-		var startNegative bool
-		if strings.HasPrefix(part, "-") {
-			startNegative = true
-			part = part[1:]
-		}
-		rangeParts := strings.SplitN(part, "-", 2)
-		switch len(rangeParts) {
-		case 1:
-			idx, err := strconv.Atoi(rangeParts[0])
-			if err != nil {
-				return nil, fmt.Errorf("invalid range: %s", part)
-			}
-			if startNegative && idx < 0 {
-				return nil, fmt.Errorf("invalid range: %s", part)
-			}
-			if startNegative {
-				idx = -idx
-			}
-			start, end = idx, idx
-		case 2:
-			var err error
-			start, err = strconv.Atoi(rangeParts[0])
-			if err != nil {
-				return nil, fmt.Errorf("invalid range: %s", part)
-			}
-			if startNegative && start < 0 {
-				return nil, fmt.Errorf("invalid range: %s", part)
-			}
-			if startNegative {
-				start = -start
-			}
-			endStr := rangeParts[1]
-			if endStr == "" {
-				end = -1
-			} else {
-				end, err = strconv.Atoi(rangeParts[1])
-				if err != nil {
-					return nil, fmt.Errorf("invalid range: %s", part)
-				}
-			}
-		default:
-			return nil, fmt.Errorf("invalid range: %s", part)
-		}
-
-		if start < 0 {
-			start = max + start + 1
-		}
-		if end < 0 {
-			end = max + end + 1
-		}
-		if start <= 0 || start > max {
-			return nil, fmt.Errorf("invalid range: %s", part)
-		}
-		if end <= 0 || end > max {
-			return nil, fmt.Errorf("invalid range: %s", part)
-		}
-		if start > end {
-			return nil, fmt.Errorf("invalid range: %s", part)
-		}
-		r.ranges = append(r.ranges, [2]int{start, end})
-	}
-
-	return r, nil
-}
-
+// extractOutput extracts the output from the stdout of the test program.
 func extractOutput(s string) (string, string) {
 	var output string
 	var others []string
@@ -375,6 +98,9 @@ func extractOutput(s string) (string, string) {
 	return output, strings.Join(others, "\n")
 }
 
+// checkOutput checks if the output is valid.
+// If the question is a system design question, it checks if the output is an array and has the same length as the input.
+// Otherwise, it checks if the output can be deserialized to the expected type.
 func checkOutput(q *leetcode.QuestionData, input []string, outputLine string) error {
 	if outputLine == "" {
 		return fmt.Errorf("no output found")
@@ -453,6 +179,10 @@ func runTest(q *leetcode.QuestionData, genResult *GenerateResult, args []string,
 				l.AppendItem(fmt.Sprintf("Case %d:    %s", c.No, config.SkippedStyle.Render("Skipped")))
 				return
 			}
+			if !c.HasOutput() {
+				l.AppendItem(fmt.Sprintf("Case %d:    %s", c.No, config.SkippedStyle.Render("Skipped: no output")))
+				return
+			}
 			ran++
 			ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
 			defer cancel()
diff --git a/lang/testcase.go b/lang/testcase.go
new file mode 100644
index 00000000..fd1c0d98
--- /dev/null
+++ b/lang/testcase.go
@@ -0,0 +1,311 @@
+package lang
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"reflect"
+	"slices"
+	"strconv"
+	"strings"
+
+	"github.com/j178/leetgo/leetcode"
+	goutils "github.com/j178/leetgo/testutils/go"
+	"github.com/j178/leetgo/utils"
+)
+
+type TestCase struct {
+	Question *leetcode.QuestionData
+	No       int
+	Input    []string
+	Output   string
+}
+
+func (c *TestCase) Check() error {
+	q := c.Question
+	err := q.Fulfill()
+	if err != nil {
+		return fmt.Errorf("failed to get question data: %w", err)
+	}
+	narg := q.MetaData.NArg()
+	if q.MetaData.SystemDesign {
+		// System design questions have two inputs, the first one is a list of strings, but the second is a list of
+		// different types. We just check if it's a valid list.
+		// input:
+		// ["LRUCache","put","put","get","put","get","put","get","get","get"]
+		// [[2],[1,1],[2,2],[1],[3,3],[2],[4,4],[1],[3],[4]]
+		// output:
+		// [null,null,null,1,null,-1,null,-1,3,4]
+		if len(c.Input) != narg {
+			return fmt.Errorf("should have %d arguments, got %d", narg, len(c.Input))
+		}
+		l1, err := deserialize("[]string", c.Input[0])
+		if err != nil {
+			return fmt.Errorf("cannot parse %s as []string", c.Input[0])
+		}
+		l2, err := goutils.SplitArray(c.Input[1])
+		if err != nil {
+			return fmt.Errorf("%s is not a valid list", c.Input[0])
+		}
+		if l1.Len() != len(l2) {
+			return fmt.Errorf("input[0] and input[1] should have the same length")
+		}
+		if c.HasOutput() {
+			l3, err := goutils.SplitArray(c.Output)
+			if err != nil {
+				return fmt.Errorf("%s is not a valid list", c.Input[0])
+			}
+			if l1.Len() != len(l3) {
+				return fmt.Errorf("input and output should have the same length")
+			}
+		}
+		return nil
+	}
+
+	resultType := q.MetaData.ResultType()
+	if len(c.Input) != narg {
+		return fmt.Errorf("should have %d arguments, got %d", narg, len(c.Input))
+	}
+	for j, arg := range c.Input {
+		tp := q.MetaData.Params[j].Type
+		if _, err := deserialize(tp, arg); err != nil {
+			return fmt.Errorf("cannot parse %s as %s", arg, tp)
+		}
+	}
+	if c.HasOutput() {
+		if _, err := deserialize(resultType, c.Output); err != nil {
+			return fmt.Errorf("cannot parse %s as %s", c.Output, resultType)
+		}
+	}
+
+	return nil
+}
+
+func (c *TestCase) InputString() string {
+	return utils.EnsureTrailingNewline(strings.Join(c.Input, "\n"))
+}
+
+func (c *TestCase) HasOutput() bool {
+	return c.Output != ""
+}
+
+type TestCases struct {
+	Cases    []TestCase
+	Question *leetcode.QuestionData
+}
+
+func (tc *TestCases) AddCase(c TestCase) {
+	c.No = len(tc.Cases) + 1
+	c.Question = tc.Question
+	tc.Cases = append(tc.Cases, c)
+}
+
+func (tc *TestCases) Contains(c TestCase) bool {
+	for _, tc := range tc.Cases {
+		if reflect.DeepEqual(c.Input, tc.Input) {
+			return true
+		}
+	}
+	return false
+}
+
+func (tc *TestCases) String() string {
+	buf := new(bytes.Buffer)
+	for i, c := range tc.Cases {
+		buf.WriteString(testCaseInputMark + "\n")
+		buf.WriteString(c.InputString())
+		buf.WriteString(testCaseOutputMark + "\n")
+		buf.WriteString(c.Output + "\n")
+		if i != len(tc.Cases)-1 {
+			buf.WriteString("\n")
+		}
+	}
+	return buf.String()
+}
+
+func (tc *TestCases) InputString() string {
+	buf := new(bytes.Buffer)
+	for _, c := range tc.Cases {
+		buf.WriteString(c.InputString())
+	}
+	return buf.String()
+}
+
+func (tc *TestCases) Check() error {
+	for i, c := range tc.Cases {
+		if err := c.Check(); err != nil {
+			return fmt.Errorf("case %d: %w", i, err)
+		}
+	}
+	return nil
+}
+
+// UpdateOutputs updates the output of the test cases with the result of the last run.
+func (tc *TestCases) UpdateOutputs(answers []string) (bool, error) {
+	if len(answers) != len(tc.Cases) {
+		return false, fmt.Errorf("expected %d answers, got %d", len(tc.Cases), len(answers))
+	}
+	updated := false
+	for i, c := range tc.Cases {
+		if c.Output != answers[i] {
+			c.Output = answers[i]
+			tc.Cases[i] = c
+			updated = true
+		}
+	}
+	return updated, nil
+}
+
+func ParseTestCases(q *leetcode.QuestionData, f *FileOutput) (TestCases, error) {
+	tc := TestCases{Question: q}
+
+	content, err := f.GetContent()
+	if err != nil {
+		return tc, err
+	}
+	var (
+		inputLines    []string
+		output        string
+		inputStarted  bool
+		outputStarted bool
+	)
+	lines := utils.SplitLines(content)
+	for _, line := range lines {
+		line := strings.TrimSpace(line)
+		switch {
+		case line == "":
+			continue
+		case strings.HasPrefix(line, testCaseInputMark):
+			inputStarted = true
+			outputStarted = false
+			if len(inputLines) > 0 {
+				tc.AddCase(
+					TestCase{
+						Input:  slices.Clone(inputLines),
+						Output: output,
+					},
+				)
+				inputLines = inputLines[:0]
+				output = ""
+			}
+		case strings.HasPrefix(line, testCaseOutputMark):
+			outputStarted = true
+			inputStarted = false
+		case inputStarted:
+			inputLines = append(inputLines, line)
+		case outputStarted:
+			if len(output) > 0 {
+				return tc, errors.New("invalid test case: output should be a single line")
+			}
+			output = line
+		}
+	}
+	if len(inputLines) > 0 {
+		tc.AddCase(
+			TestCase{
+				Input:  slices.Clone(inputLines),
+				Output: output,
+			},
+		)
+	}
+
+	if err := tc.Check(); err != nil {
+		return tc, fmt.Errorf("invalid test case: %w", err)
+	}
+
+	return tc, nil
+}
+
+type Range struct {
+	whole  bool
+	max    int
+	ranges [][2]int
+}
+
+func (r *Range) Contains(idx int) bool {
+	if r.whole {
+		return true
+	}
+	for _, rg := range r.ranges {
+		if idx >= rg[0] && idx <= rg[1] {
+			return true
+		}
+	}
+	return false
+}
+
+func ParseRange(expr string, max int) (*Range, error) {
+	r := &Range{max: max}
+
+	if expr == "" || expr == "-" {
+		r.whole = true
+		return r, nil
+	}
+
+	parts := strings.Split(expr, ",")
+	for _, part := range parts {
+		var start, end int
+		var startNegative bool
+		if strings.HasPrefix(part, "-") {
+			startNegative = true
+			part = part[1:]
+		}
+		rangeParts := strings.SplitN(part, "-", 2)
+		switch len(rangeParts) {
+		case 1:
+			idx, err := strconv.Atoi(rangeParts[0])
+			if err != nil {
+				return nil, fmt.Errorf("invalid range: %s", part)
+			}
+			if startNegative && idx < 0 {
+				return nil, fmt.Errorf("invalid range: %s", part)
+			}
+			if startNegative {
+				idx = -idx
+			}
+			start, end = idx, idx
+		case 2:
+			var err error
+			start, err = strconv.Atoi(rangeParts[0])
+			if err != nil {
+				return nil, fmt.Errorf("invalid range: %s", part)
+			}
+			if startNegative && start < 0 {
+				return nil, fmt.Errorf("invalid range: %s", part)
+			}
+			if startNegative {
+				start = -start
+			}
+			endStr := rangeParts[1]
+			if endStr == "" {
+				end = -1
+			} else {
+				end, err = strconv.Atoi(rangeParts[1])
+				if err != nil {
+					return nil, fmt.Errorf("invalid range: %s", part)
+				}
+			}
+		default:
+			return nil, fmt.Errorf("invalid range: %s", part)
+		}
+
+		if start < 0 {
+			start = max + start + 1
+		}
+		if end < 0 {
+			end = max + end + 1
+		}
+		if start <= 0 || start > max {
+			return nil, fmt.Errorf("invalid range: %s", part)
+		}
+		if end <= 0 || end > max {
+			return nil, fmt.Errorf("invalid range: %s", part)
+		}
+		if start > end {
+			return nil, fmt.Errorf("invalid range: %s", part)
+		}
+		r.ranges = append(r.ranges, [2]int{start, end})
+	}
+
+	return r, nil
+}