diff --git a/.github/templates/docker/action.yml b/.github/templates/docker/action.yml new file mode 100644 index 0000000..a2bfd0c --- /dev/null +++ b/.github/templates/docker/action.yml @@ -0,0 +1,33 @@ +name: Docker build +description: "Build the specified docker image and publishes it for the workflow" +inputs: + path: + description: "The path of the dockerfile to be built" + required: true + filepath: + description: "If the dockerfile is in a different directory then the context it needs to be specified here" + required: false + tags: + description: "The tag(s) for building the image" + required: true +runs: + using: composite + steps: + - name: Build the container + id: img + shell: bash + env: + CONTEXT_PATH: ${{ inputs.path }} + FILE_PATH: ${{ inputs.filepath }} + TAGS: ${{ inputs.tags }} + run: | + bash $GITHUB_ACTION_PATH/build.sh + IMAGE_NAME=$(echo "${TAGS}" | cut -d : -f1) + echo ${IMAGE_NAME} + echo "name=${IMAGE_NAME}" >> $GITHUB_ENV + + - name: Publish image + uses: actions/upload-artifact@v4 + with: + name: ${{ env.name }} + path: ${{ inputs.path }}/images \ No newline at end of file diff --git a/.github/templates/docker/build.sh b/.github/templates/docker/build.sh new file mode 100644 index 0000000..b680aad --- /dev/null +++ b/.github/templates/docker/build.sh @@ -0,0 +1,9 @@ +cd "${CONTEXT_PATH}" || exit 1 +if [[ -z "${FILE_PATH}" ]]; then + docker build -t "${TAGS}" . +else + docker build -f "${FILE_PATH}" -t "${TAGS}" . +fi +IMAGE_NAME=$(echo "${TAGS}" | cut -d : -f1) +mkdir images +docker image save -o "images/${IMAGE_NAME}" "${TAGS}" diff --git a/.github/templates/go/action.yml b/.github/templates/go/action.yml new file mode 100644 index 0000000..005bf39 --- /dev/null +++ b/.github/templates/go/action.yml @@ -0,0 +1,16 @@ +name: Test go +description: "Tests the go files with the given arguments" +inputs: + tests: + description: "The project(s) that should be tested (kubernetes tests only), if you want to run multiple, provide their name separated with spaces" + required: true + default: 'all' +runs: + using: composite + steps: + - name: Test go program + shell: bash + env: + PROJECT_PATH: ${{ github.workspace }} + TESTS: ${{ inputs.tests }} + run: bash ${{ github.action_path }}/main.sh diff --git a/.github/templates/go/main.sh b/.github/templates/go/main.sh new file mode 100644 index 0000000..82878bb --- /dev/null +++ b/.github/templates/go/main.sh @@ -0,0 +1,17 @@ +cd "${PROJECT_PATH}"/99-labs/code || exit 1 +if [ "${TESTS}" = "all" ]; then + TESTS=("helloworld" "splitdim" "kvstore") +fi + +for TEST in ${TESTS[@]}; do + pushd . > /dev/null + cd ${TEST} || exit 1 + go mod download + go test ./... --tags=kubernetes --count 1 + ret=$? + if [[ $ret -ne 0 ]]; then + exit $ret + fi + popd > /dev/null +done + diff --git a/.github/templates/kind-cluster-setup/action.yml b/.github/templates/kind-cluster-setup/action.yml new file mode 100644 index 0000000..bcc3238 --- /dev/null +++ b/.github/templates/kind-cluster-setup/action.yml @@ -0,0 +1,44 @@ +name: "Kind setup" +description: "Installs and sets up a running kind cluster, including the cloud-provider-kind and istio for service mesh" +inputs: + istio: + description: "A flag weather to start the istio service mesh" + default: 'false' + required: false +runs: + using: composite + steps: + - name: Install kind and start cluster + uses: helm/kind-action@v1 + with: + cluster_name: test + kubectl_version: 'v1.30.4' + + - name: Load images to node(s) + shell: bash + run: | + for i in $(docker images --format "{{.Repository}}:{{.Tag}}" | grep ':test'); do + kind load docker-image -n test ${i} + done + + - name: Install cloud-provider-kind and start in background + shell: bash + env: + VERSION: 0.6.0 + run: | + curl -L https://github.com/kubernetes-sigs/cloud-provider-kind/releases/download/v${VERSION}/cloud-provider-kind_${VERSION}_linux_amd64.tar.gz \ + | tar -xz + sudo mv cloud-provider-kind /usr/local/bin/ + sudo chmod +x /usr/local/bin/cloud-provider-kind + cloud-provider-kind & + + - name: Start istio service mesh + if: inputs.istio == 'true' + shell: bash + run: | + curl -L https://istio.io/downloadIstio | sh - + cd istio* + kubectl kustomize "github.com/kubernetes-sigs/gateway-api/config/crd?ref=v1.0.0" | kubectl apply -f - + bin/istioctl install --set profile=minimal -y + kubectl label namespace default istio-injection=enabled --overwrite + diff --git a/.github/templates/load-images/action.yml b/.github/templates/load-images/action.yml new file mode 100644 index 0000000..1ed7521 --- /dev/null +++ b/.github/templates/load-images/action.yml @@ -0,0 +1,18 @@ +name: Load built images +description: "Loads the previously built images and deletes the artifacts after" +runs: + using: composite + steps: + - name: Load images + shell: bash + run: | + for i in $(ls images); do + docker image load < "images/$i" + done + - name: Delete artifacts + uses: geekyeggo/delete-artifact@v5 + with: + useGlob: 'true' + name: '*' + failOnError: false + diff --git a/.github/templates/minikube-cluster-setup/action.yml b/.github/templates/minikube-cluster-setup/action.yml new file mode 100644 index 0000000..cbaac31 --- /dev/null +++ b/.github/templates/minikube-cluster-setup/action.yml @@ -0,0 +1,36 @@ +name: Minikube cluster setup +description: "Sets up a minikube cluster, starts the minikube tunnel and loads the necessery images" +inputs: + istio: + description: "Flag if istio should be installed" + default: 'false' +runs: + using: composite + steps: + - name: Start minikube + uses: medyagh/setup-minikube@latest + with: + driver: docker + container-runtime: containerd + wait: all + cache: true + + - name: Start minikube tunnel + shell: bash + run: minikube tunnel &> /dev/null & + + - name: Load images + shell: bash + run: | + for i in $(docker images --format "{{.Repository}}:{{.Tag}}" | grep ':test'); do + minikube image load ${i} + done + - name: Start istio service mesh + if: inputs.istio == 'true' + shell: bash + run: | + curl -L https://istio.io/downloadIstio | sh - + cd istio* + kubectl kustomize "github.com/kubernetes-sigs/gateway-api/config/crd?ref=v1.0.0" | kubectl apply -f - + bin/istioctl install --set profile=minimal -y + kubectl label namespace default istio-injection=enabled --overwrite \ No newline at end of file diff --git a/.github/templates/test-istio/action.yml b/.github/templates/test-istio/action.yml new file mode 100644 index 0000000..7a658bd --- /dev/null +++ b/.github/templates/test-istio/action.yml @@ -0,0 +1,11 @@ +name: Test istio +description: "Tests the istio service mesh" +runs: + using: composite + steps: + - name: Test istio service mesh program + shell: bash + run: bash ${{ github.action_path }}/main.sh + env: + PROJECT_PATH: ${{ github.workspace }} + ACTION_PATH: ${{ github.action_path }} diff --git a/.github/templates/test-istio/main.sh b/.github/templates/test-istio/main.sh new file mode 100644 index 0000000..6290a01 --- /dev/null +++ b/.github/templates/test-istio/main.sh @@ -0,0 +1,7 @@ +cd ${PROJECT_PATH}/99-labs/code/splitdim/deploy || exit 1 +kubectl apply -f kubernetes-istio.yaml +bash ${ACTION_PATH}/wait.sh +export EXTERNAL_IP=$(kubectl get service splitdim-istio -o jsonpath='{.status.loadBalancer.ingress[0].ip}') +export EXTERNAL_PORT=80 +cd .. +go test ./... --tags=httphandler,api,localconstructor,reset,transfer,accounts,clear -v -count 1 \ No newline at end of file diff --git a/.github/templates/test-istio/wait.sh b/.github/templates/test-istio/wait.sh new file mode 100644 index 0000000..9e8ed70 --- /dev/null +++ b/.github/templates/test-istio/wait.sh @@ -0,0 +1,14 @@ +SECONDS=0 +while true; do + kubectl get svc #for debug + IP=$(kubectl get service splitdim-istio -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + if [[ ! -n "$IP" ]]; then + if (( SECONDS == 15));then + exit 1 + fi + SECONDS=$(( SECONDS + 1 )) + sleep 1 + else + exit 0 + fi +done \ No newline at end of file diff --git a/.github/workflows/test-homeworks.yaml b/.github/workflows/test-homeworks.yaml new file mode 100644 index 0000000..c34b4b6 --- /dev/null +++ b/.github/workflows/test-homeworks.yaml @@ -0,0 +1,46 @@ +name: Test Homeworks +on: + push: + branches: + - master + paths-ignore: + - 99-labs + - env + - internals + - .gitignore + workflow_dispatch: + inputs: + report: + type: boolean + description: If true then the pipeline creates a report json used for final evaluation and doesn't execute regular tests + default: false +jobs: + test-homeworks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: '1' + - name: Setup go + uses: actions/setup-go@v5 + with: + go-version: '^1.24.0' + - name: Test homeworks + if: ! inputs.report || github.event_name == 'push' + run: | + export STUDENT_ID="$(grep -v -e '^$' STUDENT_ID)" # set the student_id used for test generation + make generate # generate the tests + make test + - name: Generate report + if: inputs.report + run: | + export STUDENT_ID="$(grep -v -e '^$' STUDENT_ID)" # set the student_id used for test generation + make generate + make report + - name: Artifact report + if: inputs.report + uses: actions/upload-artifact@v4 + with: + name: 'report' + path: '*-report.json' + diff --git a/.github/workflows/test-labs.yaml b/.github/workflows/test-labs.yaml new file mode 100644 index 0000000..606f417 --- /dev/null +++ b/.github/workflows/test-labs.yaml @@ -0,0 +1,78 @@ +name: Test +on: + workflow_dispatch: + inputs: + kube-distro: + type: choice + description: "The type of kubernetes distribution to use, i.e minikube, kind" + options: + - minikube + - kind + default: kind + required: false + + istio-ready: + type: boolean + description: "Set to true if istio can be tested, leave false otherwise" + required: false + + kubernetes-tests: + type: string + description: "Kubernetes tests to be run. Options: 'helloworld' 'splitdim', 'kvstore', 'all'. if you want to run multiple, provide them separated with spaces (e.g., 'kvstore splitdim'); 'all' means execute all tests" + default: 'all' + required: false + + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + image-root: ['helloworld','kvstore', 'splitdim'] + steps: + - uses: actions/checkout@v4 + - uses: ./.github/templates/docker + with: + path: ${{ github.workspace }}/99-labs/code/${{ matrix.image-root }} + filepath: ${{ github.workspace }}/99-labs/code/${{ matrix.image-root }}/deploy/Dockerfile + tags: ${{ matrix.image-root }}:test + test: + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: '1' + - uses: actions/download-artifact@v4 + with: + path: images + merge-multiple: 'true' + - name: Load all docker images + uses: ./.github/templates/load-images + + - name: Setup go + uses: actions/setup-go@v5 + with: + go-version: '^1.23.0' + + - name: Start kind cluster + if: github.event.inputs.kube-distro == 'kind' + uses: ./.github/templates/kind-cluster-setup + with: + istio: 'true' + + - name: Start minikube cluster + if: github.event.inputs.kube-distro == 'minikube' + uses: ./.github/templates/minikube-cluster-setup + with: + istio: 'true' + + + - name: Test istio + if: github.event.inputs.istio-ready == true + uses: ./.github/templates/test-istio + + - name: Test go project + uses: ./.github/templates/go + with: + tests: ${{ github.event.inputs.kubernetes-tests }} diff --git a/.gitignore b/.gitignore index 817930f..4954d1d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ *.out .vscode/* + +.idea diff --git a/24-generics/01-sorting/.README.md b/24-generics/01-sorting/.README.md index 1649edd..51fe5ea 100644 --- a/24-generics/01-sorting/.README.md +++ b/24-generics/01-sorting/.README.md @@ -8,4 +8,4 @@ The sorting should satisfy the following requirements: An example for the usage: {{ index . "example" }} -Place your code into the file `exercise.go` near the placeholder `// INSERT YOUR CODE HERE`. \ No newline at end of file +Place your code into the file `exercise.go` near the placeholder `// INSERT YOUR CODE HERE`. diff --git a/99-labs/code/helloworld/kubernetes_test.go b/99-labs/code/helloworld/kubernetes_test.go index 1ba77cd..ab4ed4d 100644 --- a/99-labs/code/helloworld/kubernetes_test.go +++ b/99-labs/code/helloworld/kubernetes_test.go @@ -3,7 +3,6 @@ package main import ( - "context" "fmt" "io" "net" @@ -17,17 +16,10 @@ import ( ) func TestHelloWorldKubernetes(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - // clean up cluster execCmd(t, "kubectl", "delete", "-f", "deploy/kubernetes-deployment.yaml") execCmd(t, "kubectl", "delete", "-f", "deploy/kubernetes-service.yaml") - // build the container image - execCmd(t, "minikube", "image", "build", "-t", "helloworld", "-f", "deploy/Dockerfile", ".") - go execCmd(t, "minikube", "tunnel") - // redeploy execCmd(t, "kubectl", "apply", "-f", "deploy/kubernetes-deployment.yaml") execCmd(t, "kubectl", "apply", "-f", "deploy/kubernetes-service.yaml") @@ -37,8 +29,6 @@ func TestHelloWorldKubernetes(t *testing.T) { ip, _ := execCmd(t, "kubectl", "get", "service", "helloworld", "-o", `jsonpath="{.status.loadBalancer.ingress[0].ip}"`) if ip == "" { - // make sure minikube tunnel is running if no public IP exists - execCmdContext(ctx, t, "minikube", "tunnel") // may take a while time.Sleep(10 * time.Second) ip, _ = execCmd(t, "kubectl", "get", "service", "helloworld", "-o", `jsonpath="{.status.loadBalancer.ingress[0].ip}"`) diff --git a/99-labs/code/splitdim/kubernetes_test.go b/99-labs/code/splitdim/kubernetes_test.go index 28bd9b6..2b9cf7c 100644 --- a/99-labs/code/splitdim/kubernetes_test.go +++ b/99-labs/code/splitdim/kubernetes_test.go @@ -4,7 +4,6 @@ package main import ( "bytes" - "context" "fmt" "io" "net" @@ -17,18 +16,11 @@ import ( ) func TestSplitDimKubernetes(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - // clean up cluster execCmd(t, "kubectl", "delete", "-f", "deploy/kubernetes-local-db.yaml") - // build the container image - _, _, err := execCmd(t, "minikube", "image", "build", "-t", "splitdim", "-f", "deploy/Dockerfile", ".") - assert.NoError(t, err, "kubectl delete") - // redeploy - _, _, err = execCmd(t, "kubectl", "apply", "-f", "deploy/kubernetes-local-db.yaml") + _, _, err := execCmd(t, "kubectl", "apply", "-f", "deploy/kubernetes-local-db.yaml") assert.NoError(t, err, "kubectl apply") // may take a while @@ -40,8 +32,6 @@ func TestSplitDimKubernetes(t *testing.T) { ip, _, err := execCmd(t, "kubectl", "get", "service", "splitdim", "-o", `jsonpath="{.status.loadBalancer.ingress[0].ip}"`) if ip == "" { - // make sure minikube tunnel is running if no public IP exists - execCmdContext(ctx, t, "minikube", "tunnel") // may take a while time.Sleep(10 * time.Second) ip, _, err = execCmd(t, "kubectl", "get", "service", "splitdim", "-o", `jsonpath="{.status.loadBalancer.ingress[0].ip}"`) diff --git a/99-labs/code/splitdim/testutils.go b/99-labs/code/splitdim/testutils.go index 2d4093a..994d130 100644 --- a/99-labs/code/splitdim/testutils.go +++ b/99-labs/code/splitdim/testutils.go @@ -63,6 +63,7 @@ func execCmdContext(ctx context.Context, t *testing.T, cmd string, args ...strin var outb, errb bytes.Buffer e.Stdout = &outb e.Stderr = &errb + assert.NoError(t, e.Run(), fmt.Sprintf("run command %q", cmd)) log.Print("StdOut:\t", outb.String()) log.Print("StdErr:\t ", errb.String()) diff --git a/Makefile b/Makefile index a11c9d1..4016aaa 100644 --- a/Makefile +++ b/Makefile @@ -49,6 +49,19 @@ test: export STUDENT_ID=$(STUDENT_ID) go test ./... -v -count 1 +# generate reports +report: + export STUDENT_ID=$(STUDENT_ID) + if [ ! -f report_tmp ]; then \ + go test ./... -v -count 1 -parallel 1 > report_tmp; \ + fi + go run exercises-cli.go -verbose report + +# generate the hmtl report +report-html: + export STUDENT_ID=$(STUDENT_ID) + go run exercises-cli.go -report-dir=$(REPORT_DIR) -verbose html > report.html + # clean up generated files clean: for dir in $(EXERCISE_DIRS); do \ @@ -59,3 +72,4 @@ clean: # also wipe student id realclean: clean echo "PLEASE SET STUDENT ID" > $(STUDENT_ID_FILE) + diff --git a/README.md b/README.md index 673c992..954bbc9 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,31 @@ You can divide your code into as many files as you like but don't forget to add ### Test +#### Manual testing At any point in time you can test your solutions as follows. ``` console make test ``` +#### Automated testing remotely +You have the option to test your **homeworks** using GitHub Actions. +To do so, you just have to commit your local changes and push them to your master branch on your forked repository. + +```console +git add . +git commit -m '' +git push +``` +This will start an automatic testing pipeline that you can check in the GitHub GUI. + +You can also check your **labs**, to do so, navigate to your GitHub repository, click `Actions`, click `Run workflow`, set parameters and click `Run workflow`. + +#### Automated testing locally +Alternatively, if you want to you can execute both of the testing pipelines locally using nektos/act. +Installation and usage guide can be found here: https://github.com/nektos/act + + ### Keep track of repo updates Sometimes we update the main git repo to fix bugs or add new exercises. The below workflow shows how to update your local working copy from the master without overwriting your solutions already written. diff --git a/exercises-cli.go b/exercises-cli.go index 72394c6..41bcf05 100644 --- a/exercises-cli.go +++ b/exercises-cli.go @@ -12,7 +12,7 @@ import ( ) func Usage() { - fmt.Fprintf(os.Stderr, "exercises-cli \n") + fmt.Fprintf(os.Stderr, "exercises-cli \n") fmt.Fprintf(os.Stderr, "Flags:\n") flag.PrintDefaults() } @@ -20,6 +20,7 @@ func Usage() { var ( studentId = flag.String("student-id", "", "student id; optional, default is to read from file STUDENT_ID") verbose = flag.Bool("verbose", false, "verbose mode; default is off") + reportDir = flag.String("report-dir", "", "path to directory where the report files are stored") ) func main() { @@ -50,6 +51,15 @@ func main() { } case "check": fmt.Println("Would check stuff") + case "report": + if err := lib.CreateStudentReport(id, *verbose); err != nil { + log.Fatalf("Cannot create report for student id %q: %s", id, err) + } + case "html": + log.Println(*reportDir) + if err := lib.CreateTeacherReport(*reportDir, *verbose); err != nil { + log.Fatalf("Cannot create teacher report %s", err) + } default: flag.Usage() os.Exit(1) diff --git a/internals/lib/lib.go b/internals/lib/lib.go index 4ef2b14..2cece27 100644 --- a/internals/lib/lib.go +++ b/internals/lib/lib.go @@ -28,6 +28,10 @@ const ( SolutionTemplateFile = ".exercise.go" // SolutionFile is the name of the generated solution SolutionFile = "exercise.go" + // ReportFile is the name of the test outputs generated by make report + ReportFile = "report_tmp" + // ReportOutDir is the directory in which the report will be put + ReportOutDir = "reports" ) // Input is a type alias to the input field of an exercise. diff --git a/internals/lib/report.go b/internals/lib/report.go new file mode 100644 index 0000000..c4f874f --- /dev/null +++ b/internals/lib/report.go @@ -0,0 +1,212 @@ +package lib + +import ( + "bufio" + "encoding/json" + "fmt" + "html/template" + "math" + "os" + "path/filepath" + "regexp" + "strings" +) + +type reportCard struct { + StudentID string `json:"student_id"` + ExerciseScores map[string]exerciseStats `json:"exercise_scores"` + Summary string `json:"report"` +} +type exerciseStats struct { + _owner string + Score float64 `json:"score"` + TestCaseSuccess map[string]bool `json:"testCaseSuccess"` +} + +// getTestCaseName takes a line and returns the name of the test case if the line is a go test output +func getTestCaseName(line string) string { + re := regexp.MustCompile(`^=== RUN\s+(\w+)`) + matches := re.FindStringSubmatch(line) + if len(matches) > 1 { + testName := matches[1] + return testName + } + return "" +} + +// extractExerciseName takes a string and returns the exercise name and the concrete exercise name (e.g 01-getting-started/01-hello-world) +func extractExerciseName(line string) (string, bool) { + re := regexp.MustCompile(`github\.com(?:/[^/\s]+)*/([^/\s]+)/([^/\s]+)`) + match := re.FindStringSubmatch(line) + if match == nil { + return "", false + } + return fmt.Sprintf("%s/%s", match[1], match[2]), true +} + +// newPrefilledReport takes a slice of string and returns a reportCard that has keys matching the names of the tested exercises (e.g 01-hello-world) +func newPrefilledReport(id string, lines []string) reportCard { + ret := reportCard{StudentID: id, ExerciseScores: make(map[string]exerciseStats)} + for _, line := range lines { + if match, ok := extractExerciseName(line); ok { + ret.ExerciseScores[match] = exerciseStats{} + } + } + return ret +} + +func generateReportSummary(id string, failed int, passed int) string { + if failed != 0 { + return fmt.Sprintf("%s didn't pass all the homework tests, %d test cases have passed out of %d test cases", id, passed, passed+failed) + } else { + return fmt.Sprintf("%s did pass all the homework tests, %d/%d test cases passed", id, passed, passed) + } +} + +// extractExerciseScores takes in a string slice (test output line by line) and returns the exerciseStats representation alongside the total of failed and successful test cases +func extractExerciseScores(lines []string, verbose bool) (exStats []exerciseStats, failTotal int, succTotal int) { + exStats = make([]exerciseStats, 0) + exStat := exerciseStats{Score: 0.0, TestCaseSuccess: make(map[string]bool)} + var testCaseName string + failTotal = 0 + succTotal = 0 + var succ, fail float64 + if verbose { + fmt.Println("Parsing test outputs") + } + for _, line := range lines { + if strings.Contains(line, "RUN") { + testCaseName = getTestCaseName(line) + } else if strings.Contains(line, "PASS:") { + succTotal++ + succ += 1.0 + exStat.TestCaseSuccess[testCaseName] = true + } else if strings.Contains(line, "FAIL:") { + failTotal++ + fail += 1.0 + exStat.TestCaseSuccess[testCaseName] = false + } else if strings.Contains(line, "github.com/") { + owner, _ := extractExerciseName(line) + exStat.Score = succ / (fail + succ) * 100 + exStat._owner = owner + exStats = append(exStats, exStat) + exStat = exerciseStats{_owner: "", Score: 0, TestCaseSuccess: make(map[string]bool)} + fail, succ = 0.0, 0.0 + } + } + if verbose { + fmt.Println("Finished parsing test outputs") + } + return +} + +// CreateStudentReport takes the output of the tests and generates a report card for the student +func CreateStudentReport(id string, verbose bool) error { + f, err := os.Open(ReportFile) + if err != nil { + return fmt.Errorf("error opening %q: %w", ReportFile, err) + } + if verbose { + defer func() { + if err != nil { + fmt.Printf("the program exited with error %s", err.Error()) + } else { + fmt.Printf("the program finished, successfully") + } + }() + } + var lines []string + scanner := bufio.NewScanner(f) + if verbose { + fmt.Println("Reading test outputs...") + } + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if verbose { + fmt.Println("Read test outputs") + } + report := newPrefilledReport(id, lines) + exStats, failTotal, succTotal := extractExerciseScores(lines, verbose) + + for k := range report.ExerciseScores { + Inner: + for i, exStat := range exStats { + if exStat._owner == k { + if math.IsNaN(exStats[i].Score) { + delete(report.ExerciseScores, k) + break Inner + } + report.ExerciseScores[k] = exStat + break Inner + } + } + } + report.Summary = generateReportSummary(id, failTotal, succTotal) + outName := fmt.Sprintf("/%s-report.json", report.StudentID) + output, err := os.Create(ReportOutDir + outName) + if err != nil { + return err + } + defer output.Close() + encoder := json.NewEncoder(output) + encoder.SetIndent("", " ") + if err := encoder.Encode(report); err != nil { + return err + } + return nil +} + +// createVisualReport takes a report card as argument and templates the html accordingly +func createVisualReport(rp []reportCard, file *os.File) error { + temp, err := template.New("report_template.html").ParseFiles("internals/static/report_template.html") + if err != nil { + return err + } + err = temp.Execute(file, rp) + return nil +} + +// CreateTeacherReport templates the html file generating templated reports (uses reports defined in reportDir) +func CreateTeacherReport(reportDir string, verbose bool) error { + dir, err := os.ReadDir(reportDir) + if err != nil { + return err + } + if verbose { + fmt.Println("Reading student reports...") + } + rpCards := make([]reportCard, len(dir)) + for i, file := range dir { + if !strings.Contains(file.Name(), "json") { + continue + } + filePath := filepath.Join(reportDir, file.Name()) + report, err := os.Open(filePath) + if err != nil { + if verbose { + fmt.Printf("error opening %q for reading: %s", file.Name(), err) + } + continue + } + var rep reportCard + if err = json.NewDecoder(report).Decode(&rep); err != nil { + fmt.Println(err.Error()) + if verbose { + fmt.Printf("error encoding %q: %s", file.Name(), err) + } + } + rpCards[i] = rep + report.Close() + } + if verbose { + fmt.Println("Finished reading student reports") + } + if err = createVisualReport(rpCards, os.Stdout); err != nil { + if verbose { + fmt.Printf("error creating visual report: %s", err) + } + return err + } + return nil +} diff --git a/internals/static/report_template.html b/internals/static/report_template.html new file mode 100644 index 0000000..e5998bb --- /dev/null +++ b/internals/static/report_template.html @@ -0,0 +1,157 @@ + + + + + + Student Exercise Statistics + + + +
+

Student Exercise Statistics

+ +
+ {{ range . }} + + {{ end }} +
+ + +
+ + + +