From f92a7823582eea3554a5a93d5f716be68ead13d0 Mon Sep 17 00:00:00 2001 From: bLue Date: Tue, 19 May 2026 22:14:04 +0800 Subject: [PATCH 1/2] refactor: add python and go package based on js --- .github/workflows/test.yml | 60 ++ .gitignore | 12 +- README.md | 82 +- go/README.md | 62 ++ go/api_test.go | 88 ++ go/constants.go | 9 + go/contract_test.go | 378 +++++++ go/doc.go | 2 + go/formatters.go | 215 ++++ go/go.mod | 3 + go/ranklist.go | 741 ++++++++++++++ go/resolvers.go | 203 ++++ go/testdata/fixtures/contract-fixtures.json | 948 ++++++++++++++++++ go/types.go | 173 ++++ js/README.md | 51 + js/package.json | 67 ++ js/scripts/generate-fixtures.ts | 392 ++++++++ {src => js/src}/constants.ts | 0 {src => js/src}/enums.ts | 0 {src => js/src}/formatters.ts | 0 {src => js/src}/index.ts | 0 {src => js/src}/ranklist.ts | 0 {src => js/src}/resolvers.ts | 5 +- {src => js/src}/types.ts | 0 {tests => js/tests}/constants.test.ts | 0 {tests => js/tests}/enums.test.ts | 0 {tests => js/tests}/formatters.test.ts | 0 {tests => js/tests}/index.test.ts | 0 {tests => js/tests}/ranklist.test.ts | 0 {tests => js/tests}/resolvers.test.ts | 5 + {tests => js/tests}/types.test.ts | 0 tsconfig.json => js/tsconfig.json | 0 tsconfig.test.json => js/tsconfig.test.json | 0 package.json | 73 +- pnpm-lock.yaml | 115 ++- pnpm-workspace.yaml | 2 + python/README.md | 47 + python/pyproject.toml | 53 + .../src/standard_ranklist_utils/__init__.py | 72 ++ .../src/standard_ranklist_utils/constants.py | 2 + python/src/standard_ranklist_utils/enums.py | 6 + .../src/standard_ranklist_utils/formatters.py | 99 ++ python/src/standard_ranklist_utils/py.typed | 1 + .../src/standard_ranklist_utils/ranklist.py | 501 +++++++++ .../src/standard_ranklist_utils/resolvers.py | 132 +++ python/src/standard_ranklist_utils/types.py | 29 + python/tests/fixtures/contract-fixtures.json | 948 ++++++++++++++++++ python/tests/test_contract.py | 406 ++++++++ scripts/check-fixtures.mjs | 18 + scripts/prevent-root-pack.mjs | 2 + scripts/run-go-tests.mjs | 19 + scripts/run-python-tests.mjs | 15 + scripts/sync-fixtures.mjs | 11 + testdata/contract-fixtures.json | 948 ++++++++++++++++++ 54 files changed, 6852 insertions(+), 143 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 go/README.md create mode 100644 go/api_test.go create mode 100644 go/constants.go create mode 100644 go/contract_test.go create mode 100644 go/doc.go create mode 100644 go/formatters.go create mode 100644 go/go.mod create mode 100644 go/ranklist.go create mode 100644 go/resolvers.go create mode 100644 go/testdata/fixtures/contract-fixtures.json create mode 100644 go/types.go create mode 100644 js/README.md create mode 100644 js/package.json create mode 100644 js/scripts/generate-fixtures.ts rename {src => js/src}/constants.ts (100%) rename {src => js/src}/enums.ts (100%) rename {src => js/src}/formatters.ts (100%) rename {src => js/src}/index.ts (100%) rename {src => js/src}/ranklist.ts (100%) rename {src => js/src}/resolvers.ts (94%) rename {src => js/src}/types.ts (100%) rename {tests => js/tests}/constants.test.ts (100%) rename {tests => js/tests}/enums.test.ts (100%) rename {tests => js/tests}/formatters.test.ts (100%) rename {tests => js/tests}/index.test.ts (100%) rename {tests => js/tests}/ranklist.test.ts (100%) rename {tests => js/tests}/resolvers.test.ts (94%) rename {tests => js/tests}/types.test.ts (100%) rename tsconfig.json => js/tsconfig.json (100%) rename tsconfig.test.json => js/tsconfig.test.json (100%) create mode 100644 pnpm-workspace.yaml create mode 100644 python/README.md create mode 100644 python/pyproject.toml create mode 100644 python/src/standard_ranklist_utils/__init__.py create mode 100644 python/src/standard_ranklist_utils/constants.py create mode 100644 python/src/standard_ranklist_utils/enums.py create mode 100644 python/src/standard_ranklist_utils/formatters.py create mode 100644 python/src/standard_ranklist_utils/py.typed create mode 100644 python/src/standard_ranklist_utils/ranklist.py create mode 100644 python/src/standard_ranklist_utils/resolvers.py create mode 100644 python/src/standard_ranklist_utils/types.py create mode 100644 python/tests/fixtures/contract-fixtures.json create mode 100644 python/tests/test_contract.py create mode 100644 scripts/check-fixtures.mjs create mode 100644 scripts/prevent-root-pack.mjs create mode 100644 scripts/run-go-tests.mjs create mode 100644 scripts/run-python-tests.mjs create mode 100644 scripts/sync-fixtures.mjs create mode 100644 testdata/contract-fixtures.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2746874 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,60 @@ +name: Test + +on: + push: + pull_request: + +jobs: + js: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 8 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run sync:fixtures + - run: git diff --exit-code -- testdata/contract-fixtures.json python/tests/fixtures/contract-fixtures.json go/testdata/fixtures/contract-fixtures.json + - run: pnpm -C js test + - run: pnpm -C js build + - run: npm pack --dry-run + working-directory: js + + python: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: python -m pip install -e 'python[dev]' + - run: python -m pytest python/tests + - run: python -m ruff check python/src python/tests + - run: python -m build python + if: matrix.python-version == '3.12' + - run: python -m twine check python/dist/* + if: matrix.python-version == '3.12' + + go: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + cache-dependency-path: go/go.mod + - run: go test ./... + working-directory: go + - run: go vet ./... + working-directory: go + - run: go mod tidy + working-directory: go + - run: git diff --exit-code -- go/go.mod go/go.sum + - run: test -z "$(git status --porcelain -- go/go.mod go/go.sum)" diff --git a/.gitignore b/.gitignore index b5c9f0b..f273b88 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,16 @@ .DS_Store .idea node_modules -/dist +dist .pnpm-debug.log npm-debug.log* +.pytest_cache +.ruff_cache +.mypy_cache +__pycache__ +*.egg-info +python/dist +python/build +python/.venv +go/testdata/*.tmp +go/.gocache diff --git a/README.md b/README.md index 58d37a0..0b35351 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,69 @@ # standard-ranklist-utils -Utilities for standard ranklist. +Utilities for [Standard Ranklist (srk)](https://github.com/algoux/standard-ranklist), packaged for JavaScript, +Python, and Go. -## Usage +The JavaScript package is the behavior baseline. Python and Go are tested against shared JSON fixtures generated from +that baseline so ranklist regeneration and rendering helpers stay aligned across languages. -Make sure you have installed the `@algoux/standard-ranklist` package, then install this package: +## Packages + +| Language | Directory | Package | +| --- | --- | --- | +| JavaScript/TypeScript | `js/` | `@algoux/standard-ranklist-utils` | +| Python | `python/` | `algoux-standard-ranklist-utils` (`standard_ranklist_utils`) | +| Go | `go/` | `github.com/algoux/standard-ranklist-utils/go` | + +All packages support srk `>=0.3.0 <0.4.0`. Regeneration helpers require srk `0.3.0` or later and the ICPC sorter. + +## Repository Layout + +- `js/`: npm package and JS baseline tests. +- `python/`: PyPI package using a `src/` layout and typed exports. +- `go/`: Go module with table-driven contract tests. +- `testdata/contract-fixtures.json`: canonical behavior fixture generated from JS. +- `python/tests/fixtures/` and `go/testdata/fixtures/`: package-local copies of the contract fixture. + +## Development + +Install JS dependencies: + +```shell +pnpm install +``` + +Set up Python dev tools: ```shell -npm i -S @algoux/standard-ranklist-utils +python3 -m venv python/.venv +python/.venv/bin/python -m pip install -e 'python[dev]' ``` -## Utilities +Run tests: -### formatters +```shell +pnpm run test:js +pnpm run test:python +pnpm run test:go +``` -- `formatTimeDuration`: Convert an srk `TimeDuration` between `ms`, `s`, `min`, `h`, and `d`. -- `preZeroFill`: Left-pad a number with zeroes for fixed-width display. -- `secToTimeStr`: Format elapsed seconds as a ranklist time string such as `1:02:03` or `1D 1:02:03`. -- `numberToAlphabet`: Convert a zero-based problem index to an alphabetic alias such as `A`, `Z`, or `AA`. -- `alphabetToNumber`: Convert an alphabetic problem alias back to a zero-based index. +Regenerate shared fixtures after intentional JS behavior changes: -### resolvers +```shell +pnpm run sync:fixtures +pnpm run check:fixtures +pnpm run verify:fixtures +``` -- `resolveText`: Resolve plain or i18n srk text using browser language preferences and fallback text. -- `resolveContributor`: Parse a contributor string into `name`, optional `email`, and optional `url`. -- `resolveColor`: Normalize an srk color value to a CSS color string. -- `resolveThemeColor`: Expand a single color or theme color object into explicit light and dark colors. -- `resolveStyle`: Resolve text/background style colors and auto-pick readable text color when needed. -- `resolveUserMarkers`: Resolve a user's marker IDs to marker definitions from the ranklist config. +## Release Checks -### ranklist +```shell +pnpm -C js build +(cd js && npm pack --dry-run) +python/.venv/bin/python -m build python +python/.venv/bin/python -m twine check python/dist/* +go -C go test ./... +go -C go vet ./... +``` -- `canRegenerateRanklist`: Check whether a ranklist version and sorter support ICPC regeneration. -- `getSortedCalculatedRawSolutions`: Extract and sort a submission timeline from ranklist rows. -- `filterSolutionsUntil`: Keep only solutions submitted at or before a given contest time. -- `sortRows`: Sort rows by ICPC solved count descending and penalty time ascending, with optional ranking-time precision. -- `calculateProblemStatistics`: Recalculate accepted/submitted totals for each problem, using full solution histories when present. -- `regenerateRanklistBySolutions`: Rebuild rows, scores, sorting, and problem statistics from solution tetrads. -- `regenerateRowsByIncrementalSolutions`: Apply incremental solution tetrads to existing rows and re-sort them. -- `convertToStaticRanklist`: Add precomputed per-series rank values and segment indexes to each row. +For Go subdirectory releases, tag with the module prefix, for example `go/v0.3.0`. diff --git a/go/README.md b/go/README.md new file mode 100644 index 0000000..75ffab9 --- /dev/null +++ b/go/README.md @@ -0,0 +1,62 @@ +# srkutils + +Go utilities for Standard Ranklist (srk). + +Supported srk versions: `>=0.3.0 <0.4.0`. + +## Install + +```shell +go get github.com/algoux/standard-ranklist-utils/go +``` + +## Usage Sample + +```go +package main + +import srkutils "github.com/algoux/standard-ranklist-utils/go" + +func main() { + _ = srkutils.FormatTimeDuration([]any{1.5, "h"}, "min", nil) + _ = srkutils.ResolveText(map[string]any{"fallback": "English", "zh-CN": "中文"}, []string{"zh-CN"}) + _ = srkutils.SortRows(srkutils.RanklistRowsToMaps([]srkutils.RanklistRow{}), nil) +} +``` + +The map-based helpers mirror srk JSON closely. When using exported Go structs such as `RanklistRow`, convert them with +`RanklistRowToMap` or `RanklistRowsToMaps` before passing them to ranklist helpers. + +## Utilities + +### formatters + +- `FormatTimeDuration`: Convert an srk `TimeDuration` between `ms`, `s`, `min`, `h`, and `d`. +- `FormatTimeDurationChecked`: Convert an srk `TimeDuration` and return an error for invalid values. +- `PreZeroFill`: Left-pad a number with zeroes for fixed-width display. +- `SecToTimeStr`: Format elapsed seconds as a ranklist time string such as `1:02:03` or `1D 1:02:03`. +- `NumberToAlphabet`: Convert a zero-based problem index to an alphabetic alias such as `A`, `Z`, or `AA`. +- `AlphabetToNumber`: Convert an alphabetic problem alias back to a zero-based index. + +### resolvers + +- `ResolveText`: Resolve plain or i18n srk text using explicit language preferences and fallback text. +- `ResolveContributor`: Parse a contributor string into `name`, optional `email`, and optional `url`. +- `ResolveColor`: Normalize an srk color value to a CSS color string. +- `ResolveThemeColor`: Expand a single color or theme color object into explicit light and dark colors. +- `ResolveStyle`: Resolve text/background style colors and auto-pick readable text color when needed. +- `ResolveUserMarkers`: Resolve a user's marker IDs to marker definitions from the ranklist config. + +### ranklist + +- `SortRows`: Sort rows by ICPC solved count descending and penalty time ascending, with optional ranking-time precision. +- `RegenerateRanklistBySolutions`: Rebuild rows, scores, sorting, and problem statistics from solution tetrads. +- `RegenerateRowsByIncrementalSolutions`: Apply incremental solution tetrads to existing rows and re-sort them. +- `ConvertToStaticRanklist`: Add precomputed per-series rank values and segment indexes to each row. + +### typed models + +- `TimeDuration`: Structured representation of an srk time duration with JSON marshal/unmarshal support. +- `RanklistRow`: Structured row model for Go callers that prefer typed values. +- `RanklistRowToMap`: Convert one typed row into the map shape accepted by ranklist helpers. +- `RanklistRowsToMaps`: Convert typed rows into the map shape accepted by ranklist helpers. diff --git a/go/api_test.go b/go/api_test.go new file mode 100644 index 0000000..91856e6 --- /dev/null +++ b/go/api_test.go @@ -0,0 +1,88 @@ +package srkutils + +import "testing" + +func TestTypedPublicAPIAcceptsExportedTypes(t *testing.T) { + minutes := FormatTimeDuration(TimeDuration{Value: 1.5, Unit: TimeUnitHours}, "min", nil) + if minutes != 90 { + t.Fatalf("typed TimeDuration converted to %v, want 90", minutes) + } +} + +func TestResolveColorAcceptsNumericSlices(t *testing.T) { + if ResolveColor([]int{1, 2, 3, 1}) != "rgba(1,2,3,1)" { + t.Fatal("[]int RGBA color was not resolved") + } + if ResolveColor([]float64{1, 2, 3, 0.5}) != "rgba(1,2,3,0.5)" { + t.Fatal("[]float64 RGBA color was not resolved") + } + if ResolveColor([]int{1, 2, 3}) != nil { + t.Fatal("short native color slices should not panic or resolve") + } +} + +func TestNativeMarkersPreserveModernPrecedence(t *testing.T) { + markers := []map[string]any{ + {"id": "official", "label": "Official"}, + {"id": "girls", "label": "Girls"}, + } + resolved := ResolveUserMarkers(map[string]any{"marker": "official", "markers": []string{}}, markers) + if len(resolved) != 0 { + t.Fatal("empty native markers slice should suppress legacy marker fallback") + } + resolved = ResolveUserMarkers(map[string]any{"marker": "official", "markers": []string{"girls"}}, markers) + if len(resolved) != 1 || resolved[0]["id"] != "girls" { + t.Fatal("native []string markers should resolve in modern marker order") + } +} + +func TestTypedRowConversionHelpers(t *testing.T) { + rows := RanklistRowsToMaps([]RanklistRow{ + { + User: User{ID: "slow", Name: "Slow", Markers: []string{}}, + Score: RankScore{ + Value: 1, + Time: TimeDuration{Value: 30, Unit: TimeUnitMinutes}, + }, + Statuses: []RankProblemStatus{{Result: nil, Solutions: []Solution{}}}, + }, + { + User: User{ID: "fast", Name: "Fast"}, + Score: RankScore{ + Value: 1, + Time: TimeDuration{Value: 20, Unit: TimeUnitMinutes}, + }, + Statuses: []RankProblemStatus{{Result: nil, Solutions: []Solution{}}}, + }, + }) + if _, ok := rows[0]["user"].(map[string]any)["markers"]; !ok { + t.Fatal("typed row conversion should preserve explicit empty markers") + } + sorted := SortRows(rows, nil) + if sorted[0]["user"].(map[string]any)["id"] != "fast" { + t.Fatal("typed row conversion helpers should produce rows accepted by SortRows") + } +} + +func TestNativeUserFieldFilterValues(t *testing.T) { + ranklist := makeRanklist(map[string]any{ + "series": array(object(map[string]any{ + "title": "Native", + "segments": array(object(map[string]any{"title": "Only"})), + "rule": object(map[string]any{"preset": "ICPC", "options": object(map[string]any{ + "filter": object(map[string]any{ + "byUserFields": array(object(map[string]any{"field": "organization", "rule": "Team B"})), + }), + "count": object(map[string]any{"value": array(1)}), + })}), + })), + "rows": array( + makeRow("u1", object(map[string]any{"value": 2, "time": array(10, "min")}), nil, map[string]any{"organization": []string{"Team A", "Team B"}}), + makeRow("u2", object(map[string]any{"value": 1, "time": array(20, "min")}), nil, map[string]any{"organization": map[string]string{"school": "Team C"}}), + ), + }) + rows := ConvertToStaticRanklist(ranklist)["rows"].([]map[string]any) + if rows[0]["rankValues"].([]any)[0].(map[string]any)["segmentIndex"] != 0 { + t.Fatal("native []string user field values should be filterable") + } +} diff --git a/go/constants.go b/go/constants.go new file mode 100644 index 0000000..08eae1d --- /dev/null +++ b/go/constants.go @@ -0,0 +1,9 @@ +package srkutils + +const MinRegenSupportedVersion = "0.3.0" +const SrkSupportedVersions = ">=0.3.0 <0.4.0" + +var EnumTheme = map[string]string{ + "light": "light", + "dark": "dark", +} diff --git a/go/contract_test.go b/go/contract_test.go new file mode 100644 index 0000000..e2fc12f --- /dev/null +++ b/go/contract_test.go @@ -0,0 +1,378 @@ +package srkutils + +import ( + "encoding/json" + "fmt" + "math" + "os" + "reflect" + "testing" +) + +func loadFixtures(t *testing.T) map[string]any { + t.Helper() + data, err := os.ReadFile("testdata/fixtures/contract-fixtures.json") + if err != nil { + t.Fatal(err) + } + var fixtures map[string]any + if err := json.Unmarshal(data, &fixtures); err != nil { + t.Fatal(err) + } + return fixtures +} + +func object(values map[string]any) map[string]any { + return values +} + +func array(values ...any) []any { + return values +} + +func makeRanklist(overrides map[string]any) map[string]any { + ranklist := map[string]any{ + "type": "general", + "version": "0.3.9", + "contest": map[string]any{ + "title": "Contest", + "startAt": "2026-01-01T00:00:00+08:00", + "duration": array(5, "h"), + }, + "problems": array(object(map[string]any{"alias": "A"}), object(map[string]any{"alias": "B"})), + "series": array(object(map[string]any{"title": "Rank", "rule": object(map[string]any{"preset": "Normal"})})), + "rows": array(), + "sorter": object(map[string]any{"algorithm": "ICPC", "config": object(map[string]any{})}), + } + for key, value := range overrides { + ranklist[key] = value + } + return ranklist +} + +func makeRow(userID string, score any, statuses any, user map[string]any) map[string]any { + if score == nil { + score = object(map[string]any{"value": 0, "time": array(0, "ms")}) + } + if statuses == nil { + statuses = array( + object(map[string]any{"result": nil, "solutions": array()}), + object(map[string]any{"result": nil, "solutions": array()}), + ) + } + mergedUser := map[string]any{"id": userID, "name": userID} + for key, value := range user { + mergedUser[key] = value + } + return object(map[string]any{"user": mergedUser, "score": score, "statuses": statuses}) +} + +func fixture(fixtures map[string]any, path ...string) any { + var current any = fixtures + for _, key := range path { + current = current.(map[string]any)[key] + } + return current +} + +func assertJSONEqual(t *testing.T, actual any, expected any) { + t.Helper() + actualJSON, err := json.Marshal(actual) + if err != nil { + t.Fatal(err) + } + expectedJSON, err := json.Marshal(expected) + if err != nil { + t.Fatal(err) + } + var actualValue any + var expectedValue any + if err := json.Unmarshal(actualJSON, &actualValue); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(expectedJSON, &expectedValue); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(actualValue, expectedValue) { + t.Fatalf("actual JSON %s\nexpected JSON %s", actualJSON, expectedJSON) + } +} + +func TestConstantsFormattersAndResolversMatchContract(t *testing.T) { + fixtures := loadFixtures(t) + + if MinRegenSupportedVersion != fixture(fixtures, "constants").(map[string]any)["minRegenSupportedVersion"] { + t.Fatal("unexpected min regeneration version") + } + if SrkSupportedVersions != fixture(fixtures, "constants").(map[string]any)["srkSupportedVersions"] { + t.Fatal("unexpected supported version range") + } + assertJSONEqual(t, EnumTheme, fixture(fixtures, "constants").(map[string]any)["enumTheme"]) + + formatters := fixture(fixtures, "formatters").(map[string]any) + if FormatTimeDuration(array(1.5, "h"), "min", nil) != formatters["formatTimeDuration"].(map[string]any)["hoursToMinutes"] { + t.Fatal("hours to minutes mismatch") + } + if FormatTimeDuration(array(61, "s"), "min", math.Ceil) != formatters["formatTimeDuration"].(map[string]any)["secondsToMinutesCeil"] { + t.Fatal("seconds ceil mismatch") + } + if FormatTimeDuration(array(2, "s"), "ms", func(float64) float64 { return 0 }) != formatters["formatTimeDuration"].(map[string]any)["secondsToMillisecondsIgnoresFormatter"] { + t.Fatal("milliseconds conversion mismatch") + } + if _, err := FormatTimeDurationChecked(array(-1, "s"), "ms", nil); err == nil { + t.Fatal("expected invalid negative time error") + } + if _, err := FormatTimeDurationChecked(array(1, "week"), "ms", nil); err == nil { + t.Fatal("expected invalid source unit error") + } + if _, err := FormatTimeDurationChecked(array(1, "s"), "week", nil); err == nil { + t.Fatal("expected invalid target unit error") + } + + assertJSONEqual(t, PreZeroFill(7, 3), formatters["preZeroFill"].(map[string]any)["short"]) + assertJSONEqual(t, PreZeroFill(1234, 3), formatters["preZeroFill"].(map[string]any)["long"]) + assertJSONEqual(t, SecToTimeStr(3661, SecToTimeStrOptions{FillHour: true}), formatters["secToTimeStr"].(map[string]any)["fillHour"]) + assertJSONEqual(t, SecToTimeStr(90061, SecToTimeStrOptions{ShowDay: true}), formatters["secToTimeStr"].(map[string]any)["showDay"]) + assertJSONEqual(t, SecToTimeStr(-1, SecToTimeStrOptions{}), formatters["secToTimeStr"].(map[string]any)["negative"]) + assertJSONEqual(t, NumberToAlphabet(702), formatters["alphabet"].(map[string]any)["aaa"]) + assertJSONEqual(t, AlphabetToNumber("ac"), formatters["alphabet"].(map[string]any)["numberLowerAc"]) + + resolvers := fixture(fixtures, "resolvers").(map[string]any) + assertJSONEqual(t, ResolveText(nil, nil), resolvers["text"].(map[string]any)["undefined"]) + assertJSONEqual(t, ResolveText("plain", nil), resolvers["text"].(map[string]any)["plain"]) + assertJSONEqual(t, ResolveText(object(map[string]any{"fallback": "Fallback", "en-US": "English", "zh-CN": "中文"}), []string{"en-GB"}), resolvers["text"].(map[string]any)["enGB"]) + assertJSONEqual(t, ResolveText(object(map[string]any{"fallback": "Fallback", "zh-CN": "中文"}), []string{"zh-Hans-CN"}), resolvers["text"].(map[string]any)["zhHansCN"]) + assertJSONEqual(t, ResolveText(object(map[string]any{"fallback": "Fallback", "en-US": ""}), []string{"en-US"}), resolvers["text"].(map[string]any)["emptyMatch"]) + assertJSONEqual(t, ResolveContributor("bLue (https://example.com/)"), resolvers["contributor"].(map[string]any)["full"]) + assertJSONEqual(t, ResolveColor(array(1, 2, 3, 0.5)), resolvers["color"].(map[string]any)["rgbaTuple"]) + assertJSONEqual(t, ResolveThemeColor(object(map[string]any{"light": "#ffffff", "dark": "#000000"})), resolvers["themeColor"].(map[string]any)["pair"]) + assertJSONEqual(t, ResolveStyle(object(map[string]any{"backgroundColor": object(map[string]any{"light": "#ffffff", "dark": "#000000"})})), resolvers["style"].(map[string]any)["auto"]) + assertJSONEqual(t, ResolveStyle(object(map[string]any{"backgroundColor": "#00c000"})), resolvers["style"].(map[string]any)["autoGreen"]) + assertJSONEqual(t, ResolveStyle(object(map[string]any{"backgroundColor": "#0c0"})), resolvers["style"].(map[string]any)["autoShortHex"]) + assertJSONEqual(t, ResolveUserMarkers( + object(map[string]any{"id": "u1", "name": "U1", "marker": "official", "markers": array("girls", "none")}), + []map[string]any{{"id": "official", "label": "Official", "style": "blue"}, {"id": "girls", "label": "Girls", "style": "pink"}}, + ), resolvers["markers"].(map[string]any)["modernPrecedence"]) +} + +func TestRanklistHelpersMatchContract(t *testing.T) { + fixtures := loadFixtures(t) + expected := fixture(fixtures, "ranklist").(map[string]any) + + sortedRows := SortRows([]map[string]any{ + makeRow("slow", object(map[string]any{"value": 1, "time": array(30, "min")}), nil, nil), + makeRow("fast", object(map[string]any{"value": 1, "time": array(20, "min")}), nil, nil), + makeRow("solved-more", object(map[string]any{"value": 2, "time": array(90, "min")}), nil, nil), + }, nil) + ids := []string{} + for _, row := range sortedRows { + ids = append(ids, row["user"].(map[string]any)["id"].(string)) + } + assertJSONEqual(t, ids, expected["sortedRows"]) +} + +func TestRanklistRegenerationAndStaticRanksMatchContract(t *testing.T) { + fixtures := loadFixtures(t) + expected := fixture(fixtures, "ranklist").(map[string]any) + + original := makeRanklist(map[string]any{ + "rows": array( + makeRow("u1", nil, nil, nil), + makeRow("u2", nil, nil, nil), + makeRow("u3", object(map[string]any{"value": 0, "time": array(0, "ms")}), nil, map[string]any{"official": false}), + ), + "problems": array(object(map[string]any{"alias": "A", "statistics": object(map[string]any{"accepted": 0, "submitted": 0})}), object(map[string]any{"alias": "B"})), + }) + assertJSONEqual(t, RegenerateRanklistBySolutions(original, [][]any{ + array("u1", 0, "WA", array(10, "min")), + array("u1", 0, "CE", array(15, "min")), + array("u3", 0, "AC", array(20, "min")), + array("u2", 0, "AC", array(30, "min")), + array("u1", 0, "AC", array(50, "min")), + array("u2", 1, "WA", array(100, "min")), + array("u1", 1, "AC", array(120, "min")), + }), expected["regenerated"]) + + defaultNoPenalty := makeRanklist(map[string]any{ + "problems": array(object(map[string]any{"alias": "A"})), + "rows": array(makeRow("u1", object(map[string]any{"value": 0, "time": array(0, "ms")}), array(object(map[string]any{"result": nil, "solutions": array()})), nil)), + }) + assertJSONEqual(t, RegenerateRanklistBySolutions(defaultNoPenalty, [][]any{ + array("u1", 0, "WA", array(10, "min")), + array("u1", 0, "CE", array(15, "min")), + array("u1", 0, "WA", array(20, "min")), + array("u1", 0, "?", array(25, "min")), + array("u1", 0, "AC", array(30, "min")), + }), expected["defaultNoPenalty"]) + + customNoPenalty := makeRanklist(map[string]any{ + "problems": array(object(map[string]any{"alias": "A"})), + "rows": array(makeRow("u1", object(map[string]any{"value": 0, "time": array(0, "ms")}), array(object(map[string]any{"result": nil, "solutions": array()})), nil)), + "sorter": object(map[string]any{"algorithm": "ICPC", "config": object(map[string]any{"noPenaltyResults": array("FB", "AC", "?", "NOUT", "UKE", nil)})}), + }) + assertJSONEqual(t, RegenerateRanklistBySolutions(customNoPenalty, [][]any{ + array("u1", 0, "CE", array(10, "min")), + array("u1", 0, "AC", array(30, "min")), + }), expected["customNoPenalty"]) + + postAC := makeRanklist(map[string]any{ + "problems": array(object(map[string]any{"alias": "A"})), + "rows": array(makeRow("u1", object(map[string]any{"value": 0, "time": array(0, "ms")}), array(object(map[string]any{"result": nil, "solutions": array()})), nil)), + }) + assertJSONEqual(t, RegenerateRanklistBySolutions(postAC, [][]any{ + array("u1", 0, "WA", array(10, "min")), + array("u1", 0, "AC", array(20, "min")), + array("u1", 0, "WA", array(30, "min")), + array("u1", 0, "FB", array(40, "min")), + }), expected["postAc"]) + + assertJSONEqual(t, RegenerateRanklistBySolutions(makeRanklist(map[string]any{ + "problems": array(object(map[string]any{"alias": "A"})), + "rows": array(makeRow("u1", object(map[string]any{"value": 0, "time": array(0, "ms")}), array(object(map[string]any{"result": nil, "solutions": array()})), nil)), + "sorter": object(map[string]any{"algorithm": "ICPC", "config": object(map[string]any{"timePrecision": "min", "timeRounding": "ceil"})}), + }), [][]any{array("u1", 0, "AC", array(125, "s"))}), expected["timePrecision"]) + + rankingPrecision := RegenerateRanklistBySolutions(makeRanklist(map[string]any{ + "problems": array(object(map[string]any{"alias": "A"})), + "rows": array( + makeRow("slow-original-first", object(map[string]any{"value": 0, "time": array(0, "ms")}), array(object(map[string]any{"result": nil, "solutions": array()})), nil), + makeRow("fast-original-second", object(map[string]any{"value": 0, "time": array(0, "ms")}), array(object(map[string]any{"result": nil, "solutions": array()})), nil), + ), + "sorter": object(map[string]any{"algorithm": "ICPC", "config": object(map[string]any{"rankingTimePrecision": "h", "rankingTimeRounding": "floor"})}), + }), [][]any{ + array("slow-original-first", 0, "AC", array(359, "min")), + array("fast-original-second", 0, "AC", array(301, "min")), + }) + precisionIDs := []string{} + for _, row := range rankingPrecision["rows"].([]map[string]any) { + precisionIDs = append(precisionIDs, row["user"].(map[string]any)["id"].(string)) + } + assertJSONEqual(t, precisionIDs, expected["rankingPrecisionOrder"]) + + incremental := makeRanklist(map[string]any{ + "problems": array(object(map[string]any{"alias": "A"})), + "rows": array( + makeRow("u1", object(map[string]any{"value": 0, "time": array(0, "ms")}), array(object(map[string]any{"result": nil, "solutions": array()})), nil), + makeRow("u2", object(map[string]any{"value": 0, "time": array(0, "ms")}), array(object(map[string]any{"result": nil, "solutions": array()})), nil), + ), + }) + assertJSONEqual(t, RegenerateRowsByIncrementalSolutions(incremental, [][]any{ + array("u1", 0, "WA", array(10, "min")), + array("u1", 0, "CE", array(15, "min")), + array("u2", 0, "AC", array(20, "min")), + array("u1", 0, "AC", array(35, "min")), + }), expected["incrementalRows"]) + + incrementalPostAC := makeRanklist(map[string]any{ + "problems": array(object(map[string]any{"alias": "A"})), + "rows": array(makeRow("u1", object(map[string]any{"value": 1, "time": array(20, "min")}), array(object(map[string]any{ + "result": "AC", + "time": array(20, "min"), + "tries": 1, + "solutions": array( + object(map[string]any{"result": "AC", "time": array(20, "min")}), + ), + })), nil)), + }) + assertJSONEqual(t, RegenerateRowsByIncrementalSolutions(incrementalPostAC, [][]any{ + array("u1", 0, "WA", array(30, "min")), + array("u1", 0, "AC", array(40, "min")), + }), expected["incrementalPostAcRows"]) + + staticRanklist := makeRanklist(map[string]any{ + "series": array( + object(map[string]any{"title": "Overall", "rule": object(map[string]any{"preset": "Normal"})}), + object(map[string]any{"title": "Official", "rule": object(map[string]any{"preset": "Normal", "options": object(map[string]any{"includeOfficialOnly": true})})}), + object(map[string]any{"title": "School", "rule": object(map[string]any{"preset": "UniqByUserField", "options": object(map[string]any{"field": "organization"})})}), + object(map[string]any{"title": "Medals", "segments": array(object(map[string]any{"title": "Gold"}), object(map[string]any{"title": "Silver"})), "rule": object(map[string]any{"preset": "ICPC", "options": object(map[string]any{"count": object(map[string]any{"value": array(1, 1), "noTied": true})})})}), + ), + "rows": array( + makeRow("u1", object(map[string]any{"value": 2, "time": array(100, "min")}), nil, map[string]any{"organization": "School A"}), + makeRow("u2", object(map[string]any{"value": 2, "time": array(100, "min")}), nil, map[string]any{"organization": "School A"}), + makeRow("u3", object(map[string]any{"value": 1, "time": array(50, "min")}), nil, map[string]any{"organization": "School B", "official": false}), + makeRow("u4", object(map[string]any{"value": 1, "time": array(60, "min")}), nil, map[string]any{"organization": "School B"}), + ), + }) + rankValues := []any{} + for _, row := range ConvertToStaticRanklist(staticRanklist)["rows"].([]map[string]any) { + rankValues = append(rankValues, row["rankValues"]) + } + assertJSONEqual(t, rankValues, expected["staticRankValues"]) + + markerRanklist := makeRanklist(map[string]any{ + "series": array(object(map[string]any{ + "title": "Girls", + "segments": array(object(map[string]any{"title": "Gold"}), object(map[string]any{"title": "Silver"})), + "rule": object(map[string]any{"preset": "ICPC", "options": object(map[string]any{"filter": object(map[string]any{"byMarker": "girls"}), "count": object(map[string]any{"value": array(1, 1)})})}), + })), + "rows": array( + makeRow("modern-marker", object(map[string]any{"value": 3, "time": array(10, "min")}), nil, map[string]any{"markers": array("girls")}), + makeRow("empty-modern-marker", object(map[string]any{"value": 2, "time": array(20, "min")}), nil, map[string]any{"marker": "girls", "markers": array()}), + makeRow("legacy-marker", object(map[string]any{"value": 1, "time": array(30, "min")}), nil, map[string]any{"marker": "girls"}), + ), + }) + markerValues := []any{} + for _, row := range ConvertToStaticRanklist(markerRanklist)["rows"].([]map[string]any) { + markerValues = append(markerValues, row["rankValues"].([]any)[0]) + } + assertJSONEqual(t, markerValues, expected["markerRankValues"]) + + invalidFilter := makeRanklist(map[string]any{ + "series": array(object(map[string]any{ + "title": "Invalid filter", + "segments": array(object(map[string]any{"title": "Gold"})), + "rule": object(map[string]any{"preset": "ICPC", "options": object(map[string]any{ + "filter": object(map[string]any{"byUserFields": array(object(map[string]any{"field": "organization", "rule": "("}))}), + "count": object(map[string]any{"value": array(1)}), + })}), + })), + "rows": array(makeRow("u1", object(map[string]any{"value": 1, "time": array(10, "min")}), nil, map[string]any{"organization": "SDUT"})), + }) + invalidValue := ConvertToStaticRanklist(invalidFilter)["rows"].([]map[string]any)[0]["rankValues"].([]any)[0] + assertJSONEqual(t, invalidValue, expected["invalidFilterRankValue"]) + + ratioRows := []any{} + for index := 0; index < 10; index++ { + ratioRows = append(ratioRows, makeRow( + fmt.Sprintf("ratio-u%d", index+1), + object(map[string]any{"value": 10 - index, "time": array(index, "min")}), + nil, + nil, + )) + } + ratioRanklist := makeRanklist(map[string]any{ + "series": array(object(map[string]any{ + "title": "Ratio", + "segments": array(object(map[string]any{"title": "A"}), object(map[string]any{"title": "B"})), + "rule": object(map[string]any{"preset": "ICPC", "options": object(map[string]any{ + "ratio": object(map[string]any{"value": array(0.1, 0.2), "rounding": "ceil"}), + })}), + })), + "rows": ratioRows, + }) + ratioValues := []any{} + for _, row := range ConvertToStaticRanklist(ratioRanklist)["rows"].([]map[string]any) { + ratioValues = append(ratioValues, row["rankValues"].([]any)[0]) + } + assertJSONEqual(t, ratioValues, expected["ratioRankValues"]) + + strictIDRanklist := makeRanklist(map[string]any{ + "series": array(object(map[string]any{ + "title": "Strict ID", + "segments": array(object(map[string]any{"title": "Only"})), + "rule": object(map[string]any{"preset": "ICPC", "options": object(map[string]any{ + "filter": object(map[string]any{"byMarker": "girls"}), + "count": object(map[string]any{"value": array(2)}), + })}), + })), + "rows": array( + makeRow("fallback-a", object(map[string]any{"value": 2, "time": array(10, "min")}), nil, map[string]any{"id": nil, "name": "No ID A", "marker": "girls"}), + makeRow("fallback-b", object(map[string]any{"value": 1, "time": array(20, "min")}), nil, map[string]any{"id": nil, "name": "No ID B"}), + ), + }) + strictIDValues := []any{} + for _, row := range ConvertToStaticRanklist(strictIDRanklist)["rows"].([]map[string]any) { + strictIDValues = append(strictIDValues, row["rankValues"].([]any)[0]) + } + assertJSONEqual(t, strictIDValues, expected["strictIdRankValues"]) +} diff --git a/go/doc.go b/go/doc.go new file mode 100644 index 0000000..0bd58b4 --- /dev/null +++ b/go/doc.go @@ -0,0 +1,2 @@ +// Package srkutils provides utilities for Standard Ranklist (srk) data. +package srkutils diff --git a/go/formatters.go b/go/formatters.go new file mode 100644 index 0000000..8793438 --- /dev/null +++ b/go/formatters.go @@ -0,0 +1,215 @@ +package srkutils + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" +) + +func numberValue(value any) float64 { + switch v := value.(type) { + case int: + return float64(v) + case int64: + return float64(v) + case float64: + return v + case float32: + return float64(v) + case json.Number: + f, _ := v.Float64() + return f + default: + f, _ := strconv.ParseFloat(fmt.Sprint(v), 64) + return f + } +} + +func stringValue(value any) string { + if value == nil { + return "" + } + return fmt.Sprint(value) +} + +func asSlice(value any) []any { + switch v := value.(type) { + case []any: + return v + case TimeDuration: + return []any{v.Value, string(v.Unit)} + case []float64: + result := make([]any, len(v)) + for i, item := range v { + result[i] = item + } + return result + case []int: + result := make([]any, len(v)) + for i, item := range v { + result[i] = item + } + return result + case []int64: + result := make([]any, len(v)) + for i, item := range v { + result[i] = item + } + return result + case []map[string]any: + result := make([]any, len(v)) + for i, item := range v { + result[i] = item + } + return result + case []string: + result := make([]any, len(v)) + for i, item := range v { + result[i] = item + } + return result + case nil: + return []any{} + default: + return []any{} + } +} + +func asMap(value any) map[string]any { + if value == nil { + return map[string]any{} + } + if typed, ok := value.(map[string]any); ok { + return typed + } + return map[string]any{} +} + +func FormatTimeDurationChecked(time any, targetUnit string, fmtFn func(float64) float64) (float64, error) { + raw := asSlice(time) + if len(raw) < 2 { + return 0, fmt.Errorf("invalid source time duration") + } + value := numberValue(raw[0]) + unit := stringValue(raw[1]) + if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 { + return 0, fmt.Errorf("invalid source time value %v", raw[0]) + } + ms := -1.0 + switch unit { + case "ms": + ms = value + case "s": + ms = value * 1000 + case "min": + ms = value * 1000 * 60 + case "h": + ms = value * 1000 * 60 * 60 + case "d": + ms = value * 1000 * 60 * 60 * 24 + default: + return 0, fmt.Errorf("invalid source time unit %s", unit) + } + if fmtFn == nil { + fmtFn = func(number float64) float64 { return number } + } + switch targetUnit { + case "", "ms": + return ms, nil + case "s": + return fmtFn(ms / 1000), nil + case "min": + return fmtFn(ms / 1000 / 60), nil + case "h": + return fmtFn(ms / 1000 / 60 / 60), nil + case "d": + return fmtFn(ms / 1000 / 60 / 60 / 24), nil + default: + return 0, fmt.Errorf("invalid target time unit %s", targetUnit) + } +} + +func FormatTimeDuration(time any, targetUnit string, fmtFn func(float64) float64) float64 { + value, err := FormatTimeDurationChecked(time, targetUnit, fmtFn) + if err != nil { + panic(err) + } + return value +} + +func PreZeroFill(num int, size int) string { + if float64(num) >= math.Pow(10, float64(size)) { + return strconv.Itoa(num) + } + text := strings.Repeat("0", size) + strconv.Itoa(num) + return text[len(text)-size:] +} + +type SecToTimeStrOptions struct { + FillHour bool + ShowDay bool +} + +func SecToTimeStr(second float64, options SecToTimeStrOptions) string { + if second < 0 { + return "--" + } + sec := second + days := 0 + if options.ShowDay { + days = int(math.Floor(sec / 86400)) + sec = math.Mod(sec, 86400) + } + hours := int(math.Floor(sec / 3600)) + sec = math.Mod(sec, 3600) + minutes := int(math.Floor(sec / 60)) + sec = math.Mod(sec, 60) + seconds := int(math.Floor(sec)) + dayText := "" + if options.ShowDay && days >= 1 { + dayText = fmt.Sprintf("%dD ", days) + } + hourText := strconv.Itoa(hours) + if options.FillHour { + hourText = PreZeroFill(hours, 2) + } + return fmt.Sprintf("%s%s:%s:%s", dayText, hourText, PreZeroFill(minutes, 2), PreZeroFill(seconds, 2)) +} + +func NumberToAlphabet(number any) string { + n := int(math.Trunc(numberValue(number))) + radix := 26 + count := 1 + power := radix + for n >= power { + n -= power + count++ + power *= radix + } + result := []byte{} + for ; count > 0; count-- { + result = append(result, byte((n%radix)+65)) + n = int(math.Trunc(float64(n) / float64(radix))) + } + for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { + result[i], result[j] = result[j], result[i] + } + return string(result) +} + +func AlphabetToNumber(alphabet string) int { + if alphabet == "" { + return -1 + } + upper := strings.ToUpper(alphabet) + radix := 26 + power := 1 + result := -1 + for i := len(upper) - 1; i >= 0; i-- { + result += (int(upper[i])-65)*power + power + power *= radix + } + return result +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..2870658 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,3 @@ +module github.com/algoux/standard-ranklist-utils/go + +go 1.22 diff --git a/go/ranklist.go b/go/ranklist.go new file mode 100644 index 0000000..46e8db3 --- /dev/null +++ b/go/ranklist.go @@ -0,0 +1,741 @@ +package srkutils + +import ( + "encoding/json" + "fmt" + "math" + "math/big" + "regexp" + "sort" +) + +var defaultNoPenaltyResults = []any{"FB", "AC", "?", "NOUT", "CE", "UKE", nil} +var filterableUserFields = map[string]bool{"id": true, "name": true, "organization": true} +var groupableUserFields = map[string]bool{"id": true, "name": true, "organization": true} + +func jsRound(value float64) float64 { + return math.Floor(value + 0.5) +} + +func roundingFn(name string) func(float64) float64 { + switch name { + case "ceil": + return math.Ceil + case "round": + return jsRound + default: + return math.Floor + } +} + +func deepCopyMap(value map[string]any) map[string]any { + data, _ := json.Marshal(value) + var copied map[string]any + _ = json.Unmarshal(data, &copied) + return copied +} + +func deepCopyAny(value any) any { + data, _ := json.Marshal(value) + var copied any + _ = json.Unmarshal(data, &copied) + return copied +} + +var semverRe = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`) + +type parsedSemver struct { + core [3]int + prerelease bool +} + +func parseSemver(version string) (parsedSemver, bool) { + match := semverRe.FindStringSubmatch(version) + if len(match) != 5 { + return parsedSemver{}, false + } + return parsedSemver{ + core: [3]int{int(numberValue(match[1])), int(numberValue(match[2])), int(numberValue(match[3]))}, + prerelease: match[4] != "", + }, true +} + +func semverGTE(version string, minimum string) bool { + v, ok := parseSemver(version) + if !ok { + return false + } + m, ok := parseSemver(minimum) + if !ok { + return false + } + for i := 0; i < 3; i++ { + if v.core[i] > m.core[i] { + return true + } + if v.core[i] < m.core[i] { + return false + } + } + if v.prerelease && !m.prerelease { + return false + } + return true +} + +func userID(user map[string]any) string { + if id := stringValue(user["id"]); id != "" { + return id + } + if name, ok := user["name"].(string); ok { + return name + } + data, _ := json.Marshal(user["name"]) + return string(data) +} + +func sorterConfig(ranklist map[string]any) map[string]any { + config := map[string]any{ + "penalty": []any{20, "min"}, + "noPenaltyResults": append([]any{}, defaultNoPenaltyResults...), + "timeRounding": "floor", + } + sorter := asMap(ranklist["sorter"]) + for key, value := range asMap(sorter["config"]) { + config[key] = deepCopyAny(value) + } + return config +} + +func containsValue(values []any, target any) bool { + for _, value := range values { + if value == target || fmt.Sprint(value) == fmt.Sprint(target) { + return true + } + } + return false +} + +func ratFromAny(value any) *big.Rat { + result := new(big.Rat) + if _, ok := result.SetString(fmt.Sprint(value)); ok { + return result + } + return new(big.Rat) +} + +func floorRat(value *big.Rat) int { + quotient := new(big.Int) + remainder := new(big.Int) + quotient.QuoRem(value.Num(), value.Denom(), remainder) + if value.Sign() < 0 && remainder.Sign() != 0 { + quotient.Sub(quotient, big.NewInt(1)) + } + return int(quotient.Int64()) +} + +func ceilRat(value *big.Rat) int { + quotient := new(big.Int) + remainder := new(big.Int) + quotient.QuoRem(value.Num(), value.Denom(), remainder) + if value.Sign() > 0 && remainder.Sign() != 0 { + quotient.Add(quotient, big.NewInt(1)) + } + return int(quotient.Int64()) +} + +func roundRat(value *big.Rat, rounding string) int { + switch rounding { + case "floor": + return floorRat(value) + case "round": + return floorRat(new(big.Rat).Add(value, big.NewRat(1, 2))) + default: + return ceilRat(value) + } +} + +func supportsRegeneration(ranklist map[string]any) bool { + if !semverGTE(stringValue(ranklist["version"]), MinRegenSupportedVersion) { + return false + } + return stringValue(asMap(ranklist["sorter"])["algorithm"]) == "ICPC" +} + +func SortRows(rows []map[string]any, options map[string]any) []map[string]any { + if options == nil { + options = map[string]any{} + } + rankingTimePrecision := stringValue(options["rankingTimePrecision"]) + if rankingTimePrecision == "" { + rankingTimePrecision = "ms" + } + rounding := roundingFn(stringValue(options["rankingTimeRounding"])) + sort.SliceStable(rows, func(i, j int) bool { + a := asMap(rows[i]["score"]) + b := asMap(rows[j]["score"]) + if numberValue(a["value"]) != numberValue(b["value"]) { + return numberValue(a["value"]) > numberValue(b["value"]) + } + timeA := 0.0 + if a["time"] != nil { + timeA = FormatTimeDuration(a["time"], rankingTimePrecision, rounding) + } + timeB := 0.0 + if b["time"] != nil { + timeB = FormatTimeDuration(b["time"], rankingTimePrecision, rounding) + } + return timeA < timeB + }) + return rows +} + +func RegenerateRanklistBySolutions(originalRanklist map[string]any, solutions [][]any) map[string]any { + if !supportsRegeneration(originalRanklist) { + panic("The ranklist is not supported to regenerate") + } + config := sorterConfig(originalRanklist) + ranklist := map[string]any{} + for key, value := range originalRanklist { + if key != "rows" { + ranklist[key] = deepCopyAny(value) + } + } + problems := asSlice(ranklist["problems"]) + problemCount := len(problems) + userRowMap := map[string]map[string]any{} + for _, rowAny := range asSlice(originalRanklist["rows"]) { + row := asMap(rowAny) + statuses := []any{} + for i := 0; i < problemCount; i++ { + statuses = append(statuses, map[string]any{"result": nil, "solutions": []any{}}) + } + userRowMap[userID(asMap(row["user"]))] = map[string]any{ + "user": deepCopyAny(row["user"]), + "score": map[string]any{"value": 0}, + "statuses": statuses, + } + } + for _, tetrad := range solutions { + id := stringValue(tetrad[0]) + problemIndex := int(numberValue(tetrad[1])) + row := userRowMap[id] + if row == nil { + break + } + status := asMap(asSlice(row["statuses"])[problemIndex]) + status["solutions"] = append(asSlice(status["solutions"]), map[string]any{"result": tetrad[2], "time": tetrad[3]}) + } + accepted := make([]int, problemCount) + submitted := make([]int, problemCount) + rows := []map[string]any{} + noPenalty := asSlice(config["noPenaltyResults"]) + for _, originalRowAny := range asSlice(originalRanklist["rows"]) { + row := userRowMap[userID(asMap(asMap(originalRowAny)["user"]))] + scoreValue := 0 + totalTimeMS := 0.0 + for i, statusAny := range asSlice(row["statuses"]) { + status := asMap(statusAny) + for _, solutionAny := range asSlice(status["solutions"]) { + solution := asMap(solutionAny) + result := solution["result"] + if result == nil || stringValue(result) == "" { + continue + } + isNoPenalty := containsValue(noPenalty, result) + if result == "?" { + status["result"] = result + if !isNoPenalty { + status["tries"] = numberValue(status["tries"]) + 1 + submitted[i]++ + } + continue + } + if result == "AC" || result == "FB" { + status["result"] = result + status["time"] = solution["time"] + status["tries"] = numberValue(status["tries"]) + 1 + accepted[i]++ + submitted[i]++ + break + } + if isNoPenalty { + continue + } + status["result"] = "RJ" + status["tries"] = numberValue(status["tries"]) + 1 + submitted[i]++ + } + if status["result"] == "AC" || status["result"] == "FB" { + precision := stringValue(config["timePrecision"]) + if precision == "" { + precision = "ms" + } + targetTime := []any{ + FormatTimeDuration(status["time"], precision, roundingFn(stringValue(config["timeRounding"]))), + precision, + } + scoreValue++ + totalTimeMS += FormatTimeDuration(targetTime, "ms", nil) + (numberValue(status["tries"])-1)*FormatTimeDuration(config["penalty"], "ms", nil) + } + } + row["score"] = map[string]any{"value": scoreValue, "time": []any{totalTimeMS, "ms"}} + rows = append(rows, row) + } + ranklist["rows"] = SortRows(rows, map[string]any{ + "rankingTimePrecision": config["rankingTimePrecision"], + "rankingTimeRounding": config["rankingTimeRounding"], + }) + for i, problemAny := range problems { + problem := asMap(problemAny) + if problem["statistics"] == nil { + problem["statistics"] = map[string]any{"accepted": 0, "submitted": 0} + } + stats := asMap(problem["statistics"]) + stats["accepted"] = accepted[i] + stats["submitted"] = submitted[i] + } + return ranklist +} + +func RegenerateRowsByIncrementalSolutions(originalRanklist map[string]any, solutions [][]any) []map[string]any { + if !supportsRegeneration(originalRanklist) { + panic("The ranklist is not supported to regenerate") + } + config := sorterConfig(originalRanklist) + rows := []map[string]any{} + userRowIndex := map[string]int{} + for index, rowAny := range asSlice(originalRanklist["rows"]) { + row := deepCopyMap(asMap(rowAny)) + userRowIndex[userID(asMap(row["user"]))] = index + rows = append(rows, row) + } + noPenalty := asSlice(config["noPenaltyResults"]) + for _, tetrad := range solutions { + id := stringValue(tetrad[0]) + rowIndex, ok := userRowIndex[id] + if !ok { + break + } + row := rows[rowIndex] + problemIndex := int(numberValue(tetrad[1])) + status := asMap(asSlice(row["statuses"])[problemIndex]) + status["solutions"] = append(asSlice(status["solutions"]), map[string]any{"result": tetrad[2], "time": tetrad[3]}) + if status["result"] == "AC" || status["result"] == "FB" { + continue + } + result := tetrad[2] + isNoPenalty := containsValue(noPenalty, result) + if result == "?" { + status["result"] = result + if !isNoPenalty { + status["tries"] = numberValue(status["tries"]) + 1 + } + continue + } + if result == "AC" || result == "FB" { + status["result"] = result + status["time"] = tetrad[3] + status["tries"] = numberValue(status["tries"]) + 1 + score := asMap(row["score"]) + score["value"] = numberValue(score["value"]) + 1 + precision := stringValue(config["timePrecision"]) + if precision == "" { + precision = "ms" + } + targetTime := []any{FormatTimeDuration(status["time"], precision, roundingFn(stringValue(config["timeRounding"]))), precision} + totalTime := 0.0 + if score["time"] != nil { + totalTime = FormatTimeDuration(score["time"], "ms", nil) + } + score["time"] = []any{totalTime + FormatTimeDuration(targetTime, "ms", nil) + (numberValue(status["tries"])-1)*FormatTimeDuration(config["penalty"], "ms", nil), "ms"} + continue + } + if isNoPenalty { + continue + } + status["result"] = "RJ" + status["tries"] = numberValue(status["tries"]) + 1 + } + return SortRows(rows, map[string]any{ + "rankingTimePrecision": config["rankingTimePrecision"], + "rankingTimeRounding": config["rankingTimeRounding"], + }) +} + +func compareScoreEqual(a map[string]any, b map[string]any, options map[string]any) bool { + if numberValue(a["value"]) != numberValue(b["value"]) { + return false + } + precision := stringValue(options["rankingTimePrecision"]) + if precision == "" { + precision = "ms" + } + rounding := roundingFn(stringValue(options["rankingTimeRounding"])) + da := 0.0 + if a["time"] != nil { + da = FormatTimeDuration(a["time"], precision, rounding) + } + db := 0.0 + if b["time"] != nil { + db = FormatTimeDuration(b["time"], precision, rounding) + } + return da == db +} + +func genRowRanks(rows []map[string]any, options map[string]any) map[string][]any { + genRanks := func(current []map[string]any) []any { + ranks := make([]any, len(current)) + for i := range current { + if i == 0 { + ranks[i] = 1 + } else if compareScoreEqual(asMap(current[i]["score"]), asMap(current[i-1]["score"]), options) { + ranks[i] = ranks[i-1] + } else { + ranks[i] = i + 1 + } + } + return ranks + } + ranks := genRanks(rows) + officialRows := []map[string]any{} + indexBack := map[int]int{} + for index, row := range rows { + if asMap(row["user"])["official"] != false { + indexBack[index] = len(officialRows) + officialRows = append(officialRows, row) + } + } + officialPartialRanks := genRanks(officialRows) + officialRanks := make([]any, len(rows)) + for index := range rows { + if back, ok := indexBack[index]; ok { + officialRanks[index] = officialPartialRanks[back] + } else { + officialRanks[index] = nil + } + } + return map[string][]any{"ranks": ranks, "officialRanks": officialRanks} +} + +func stringify(value any) string { + switch value.(type) { + case map[string]any, []any: + data, _ := json.Marshal(value) + return string(data) + default: + return fmt.Sprint(value) + } +} + +func objectValues(value any) []any { + switch typed := value.(type) { + case map[string]any: + result := []any{} + for _, item := range typed { + result = append(result, item) + } + return result + case map[string]string: + result := make([]any, 0, len(typed)) + for _, item := range typed { + result = append(result, item) + } + return result + case []any: + return typed + case []string: + result := make([]any, len(typed)) + for index, item := range typed { + result[index] = item + } + return result + default: + if value == nil { + return []any{} + } + return []any{value} + } +} + +type seriesCalcFn func(row map[string]any, index int) map[string]any + +func genSeriesCalcFns(series []any, rows []map[string]any, ranks []any, officialRanks []any) []seriesCalcFn { + fallback := func(_ map[string]any, _ int) map[string]any { + return map[string]any{"rank": nil, "segmentIndex": nil} + } + fns := []seriesCalcFn{} + for _, seriesAny := range series { + seriesConfig := asMap(seriesAny) + rule := asMap(seriesConfig["rule"]) + if len(rule) == 0 { + fns = append(fns, fallback) + continue + } + switch stringValue(rule["preset"]) { + case "Normal": + options := asMap(rule["options"]) + fns = append(fns, func(row map[string]any, index int) map[string]any { + if options["includeOfficialOnly"] == true && asMap(row["user"])["official"] == false { + return map[string]any{"rank": nil, "segmentIndex": nil} + } + rank := ranks[index] + if options["includeOfficialOnly"] == true { + rank = officialRanks[index] + } + return map[string]any{"rank": rank, "segmentIndex": nil} + }) + case "UniqByUserField": + options := asMap(rule["options"]) + field := stringValue(options["field"]) + assigned := map[int]any{} + values := map[string]bool{} + lastOuterRank := any(0) + lastRank := 0 + for index, row := range rows { + if options["includeOfficialOnly"] == true && asMap(row["user"])["official"] == false { + continue + } + valid := groupableUserFields[field] + value := stringify(asMap(row["user"])[field]) + if !valid || (value != "" && !values[value]) { + outerRank := ranks[index] + if options["includeOfficialOnly"] == true { + outerRank = officialRanks[index] + } + if valid { + values[value] = true + } + if fmt.Sprint(outerRank) != fmt.Sprint(lastOuterRank) { + lastOuterRank = outerRank + lastRank = len(assigned) + 1 + assigned[index] = lastRank + } + assigned[index] = lastRank + } + } + fns = append(fns, func(_ map[string]any, index int) map[string]any { + return map[string]any{"rank": assigned[index], "segmentIndex": nil} + }) + case "ICPC": + options := asMap(rule["options"]) + filteredRows := []map[string]any{} + for _, row := range rows { + if asMap(row["user"])["official"] != false { + filteredRows = append(filteredRows, row) + } + } + filteredOfficialRanks := append([]any{}, officialRanks...) + filterTests := []func(map[string]any) bool{} + filter := asMap(options["filter"]) + if len(filter) > 0 { + for _, filterAny := range asSlice(filter["byUserFields"]) { + filterConfig := asMap(filterAny) + field := stringValue(filterConfig["field"]) + if !filterableUserFields[field] { + continue + } + regexpValue, err := regexp.Compile(stringValue(filterConfig["rule"])) + if err != nil { + filterTests = append(filterTests, func(_ map[string]any) bool { return false }) + continue + } + filterTests = append(filterTests, func(row map[string]any) bool { + value := asMap(row["user"])[field] + for _, item := range objectValues(value) { + if regexpValue.MatchString(fmt.Sprint(item)) { + return true + } + } + return false + }) + } + if filter["byMarker"] != nil { + marker := stringValue(filter["byMarker"]) + filterTests = append(filterTests, func(row map[string]any) bool { + user := asMap(row["user"]) + if markers, ok := user["markers"]; ok { + for _, current := range asSlice(markers) { + if current == marker { + return true + } + } + return false + } + return user["marker"] == marker + }) + } + if len(filterTests) > 0 { + currentFilteredRows := []map[string]any{} + filteredOfficialRanks = make([]any, len(filteredOfficialRanks)) + currentRank := 0 + currentOfficialRank := 0 + currentOfficialRankOld := any(0) + for index, row := range rows { + shouldInclude := true + for _, test := range filterTests { + if !test(row) { + shouldInclude = false + break + } + } + if shouldInclude { + currentFilteredRows = append(currentFilteredRows, row) + oldRank := officialRanks[index] + if oldRank != nil { + currentRank++ + if fmt.Sprint(currentOfficialRankOld) != fmt.Sprint(oldRank) { + currentOfficialRank = currentRank + currentOfficialRankOld = oldRank + } + filteredOfficialRanks[index] = currentOfficialRank + } + } + } + filteredRows = []map[string]any{} + for _, row := range currentFilteredRows { + if asMap(row["user"])["official"] != false { + filteredRows = append(filteredRows, row) + } + } + } + } + endpointRules := [][]int{} + noTied := false + ratio := asMap(options["ratio"]) + if len(ratio) > 0 { + denominator := stringValue(ratio["denominator"]) + total := len(filteredRows) + if denominator == "submitted" { + total = 0 + for _, row := range filteredRows { + allEmpty := true + for _, statusAny := range asSlice(row["statuses"]) { + if asMap(statusAny)["result"] != nil { + allEmpty = false + break + } + } + if !allEmpty { + total++ + } + } + } else if denominator == "scored" { + total = 0 + for _, row := range filteredRows { + if numberValue(asMap(row["score"])["value"]) > 0 { + total++ + } + } + } + acc := []*big.Rat{} + currentAcc := new(big.Rat) + for index, value := range asSlice(ratio["value"]) { + current := ratFromAny(value) + if index == 0 { + currentAcc = current + } else { + currentAcc = new(big.Rat).Add(currentAcc, current) + } + acc = append(acc, new(big.Rat).Set(currentAcc)) + } + rounding := stringValue(ratio["rounding"]) + rule := []int{} + for _, value := range acc { + raw := new(big.Rat).Mul(value, big.NewRat(int64(total), 1)) + rule = append(rule, roundRat(raw, rounding)) + } + endpointRules = append(endpointRules, rule) + if ratio["noTied"] == true { + noTied = true + } + } + count := asMap(options["count"]) + if len(count) > 0 { + acc := []int{} + for index, value := range asSlice(count["value"]) { + current := int(numberValue(value)) + if index > 0 { + current += acc[index-1] + } + acc = append(acc, current) + } + endpointRules = append(endpointRules, acc) + if count["noTied"] == true { + noTied = true + } + } + officialRanksNoTied := []any{} + currentOfficialRank := 0 + for _, rank := range filteredOfficialRanks { + if rank == nil { + officialRanksNoTied = append(officialRanksNoTied, nil) + } else { + currentOfficialRank++ + officialRanksNoTied = append(officialRanksNoTied, currentOfficialRank) + } + } + filteredIDs := map[string]bool{} + for _, row := range filteredRows { + filteredIDs[stringValue(asMap(row["user"])["id"])] = true + } + currentSeries := seriesConfig + fns = append(fns, func(row map[string]any, index int) map[string]any { + if asMap(row["user"])["official"] == false || !filteredIDs[stringValue(asMap(row["user"])["id"])] { + return map[string]any{"rank": nil, "segmentIndex": nil} + } + usingRanks := filteredOfficialRanks + if noTied { + usingRanks = officialRanksNoTied + } + var segmentIndex any = nil + for segIndex := range asSlice(currentSeries["segments"]) { + matches := true + for _, endpoints := range endpointRules { + if segIndex >= len(endpoints) || numberValue(usingRanks[index]) > float64(endpoints[segIndex]) { + matches = false + break + } + } + if matches { + segmentIndex = segIndex + break + } + } + return map[string]any{"rank": filteredOfficialRanks[index], "segmentIndex": segmentIndex} + }) + default: + fns = append(fns, fallback) + } + } + return fns +} + +func ConvertToStaticRanklist(ranklist map[string]any) map[string]any { + if ranklist == nil { + return nil + } + rows := []map[string]any{} + for _, rowAny := range asSlice(ranklist["rows"]) { + rows = append(rows, asMap(rowAny)) + } + config := asMap(asMap(ranklist["sorter"])["config"]) + rowRanks := genRowRanks(rows, map[string]any{ + "rankingTimePrecision": config["rankingTimePrecision"], + "rankingTimeRounding": config["rankingTimeRounding"], + }) + fns := genSeriesCalcFns(asSlice(ranklist["series"]), rows, rowRanks["ranks"], rowRanks["officialRanks"]) + result := deepCopyMap(ranklist) + resultRows := []map[string]any{} + for index, row := range rows { + copied := deepCopyMap(row) + rankValues := []any{} + for _, fn := range fns { + rankValues = append(rankValues, fn(row, index)) + } + copied["rankValues"] = rankValues + resultRows = append(resultRows, copied) + } + result["rows"] = resultRows + return result +} diff --git a/go/resolvers.go b/go/resolvers.go new file mode 100644 index 0000000..3bd9e31 --- /dev/null +++ b/go/resolvers.go @@ -0,0 +1,203 @@ +package srkutils + +import ( + "fmt" + "regexp" + "sort" + "strings" +) + +func ResolveText(text any, languages []string) string { + if text == nil { + return "" + } + if value, ok := text.(string); ok { + return value + } + values := asMap(text) + langs := []string{} + for key := range values { + if key != "" && key != "fallback" { + langs = append(langs, key) + } + } + sort.Sort(sort.Reverse(sort.StringSlice(langs))) + usingLang := "" + for _, lang := range languages { + for _, candidate := range langs { + if candidate == lang { + usingLang = candidate + break + } + } + if usingLang != "" { + break + } + } + if usingLang == "" { + for _, lang := range languages { + primary := strings.Split(lang, "-")[0] + for _, candidate := range langs { + if candidate == primary || strings.HasPrefix(candidate, primary+"-") { + usingLang = candidate + break + } + } + if usingLang != "" { + break + } + } + } + if value, ok := values[usingLang].(string); ok { + return value + } + if value, ok := values["fallback"].(string); ok { + return value + } + return "" +} + +func ResolveContributor(contributor string) map[string]any { + if contributor == "" { + return nil + } + words := strings.Fields(contributor) + index := len(words) - 1 + email := "" + url := "" + for index > 0 { + word := words[index] + if strings.HasPrefix(word, "<") && strings.HasSuffix(word, ">") { + email = word[1 : len(word)-1] + index-- + continue + } + if strings.HasPrefix(word, "(") && strings.HasSuffix(word, ")") { + url = word[1 : len(word)-1] + index-- + continue + } + break + } + result := map[string]any{"name": strings.Join(words[:index+1], " ")} + if email != "" { + result["email"] = email + } + if url != "" { + result["url"] = url + } + return result +} + +func ResolveColor(color any) any { + if raw := asSlice(color); len(raw) >= 4 { + return fmt.Sprintf("rgba(%v,%v,%v,%v)", raw[0], raw[1], raw[2], raw[3]) + } + if value, ok := color.(string); ok { + if value != "" { + return value + } + } + return nil +} + +func ResolveThemeColor(themeColor any) map[string]any { + if value, ok := themeColor.(string); ok { + color := ResolveColor(value) + return map[string]any{"light": color, "dark": color} + } + values := asMap(themeColor) + return map[string]any{"light": ResolveColor(values["light"]), "dark": ResolveColor(values["dark"])} +} + +func parseColorRGB(color string) (float64, float64, float64) { + if strings.HasPrefix(color, "#") && len(color) == 4 { + r, _ := strconvParseHex(strings.Repeat(color[1:2], 2)) + g, _ := strconvParseHex(strings.Repeat(color[2:3], 2)) + b, _ := strconvParseHex(strings.Repeat(color[3:4], 2)) + return r, g, b + } + if strings.HasPrefix(color, "#") && len(color) == 7 { + r, _ := strconvParseHex(color[1:3]) + g, _ := strconvParseHex(color[3:5]) + b, _ := strconvParseHex(color[5:7]) + return r, g, b + } + re := regexp.MustCompile(`rgba?\(([^,]+),([^,]+),([^,\)]+)`) + match := re.FindStringSubmatch(color) + if len(match) == 4 { + return numberValue(match[1]), numberValue(match[2]), numberValue(match[3]) + } + return 255, 255, 255 +} + +func strconvParseHex(value string) (float64, error) { + var parsed int64 + _, err := fmt.Sscanf(value, "%x", &parsed) + return float64(parsed), err +} + +func autoTextColor(backgroundColor string) string { + red, green, blue := parseColorRGB(backgroundColor) + if 0.213*red+0.715*green+0.072*blue > 255/2 { + return "#000000" + } + return "#ffffff" +} + +func ResolveStyle(style map[string]any) map[string]any { + textColor := style["textColor"] + backgroundColor := style["backgroundColor"] + usingTextColor := textColor + if backgroundColor != nil && textColor == nil { + if value, ok := backgroundColor.(string); ok { + usingTextColor = autoTextColor(value) + } else { + theme := asMap(backgroundColor) + result := map[string]any{} + if theme["light"] != nil { + result["light"] = autoTextColor(stringValue(theme["light"])) + } + if theme["dark"] != nil { + result["dark"] = autoTextColor(stringValue(theme["dark"])) + } + usingTextColor = result + } + } + return map[string]any{ + "textColor": ResolveThemeColor(firstNonNil(usingTextColor, "")), + "backgroundColor": ResolveThemeColor(firstNonNil(backgroundColor, "")), + } +} + +func firstNonNil(value any, fallback any) any { + if value == nil { + return fallback + } + return value +} + +func ResolveUserMarkers(user map[string]any, markersConfig []map[string]any) []map[string]any { + if user == nil { + return []map[string]any{} + } + var userMarkers []any + if markers, ok := user["markers"]; ok { + userMarkers = asSlice(markers) + } else { + userMarkers = []any{user["marker"]} + } + result := []map[string]any{} + for _, markerID := range userMarkers { + if markerID == nil { + continue + } + for _, marker := range markersConfig { + if marker["id"] == markerID { + result = append(result, marker) + break + } + } + } + return result +} diff --git a/go/testdata/fixtures/contract-fixtures.json b/go/testdata/fixtures/contract-fixtures.json new file mode 100644 index 0000000..9f962dd --- /dev/null +++ b/go/testdata/fixtures/contract-fixtures.json @@ -0,0 +1,948 @@ +{ + "constants": { + "minRegenSupportedVersion": "0.3.0", + "srkSupportedVersions": ">=0.3.0 <0.4.0", + "enumTheme": { + "light": "light", + "dark": "dark" + } + }, + "formatters": { + "formatTimeDuration": { + "hoursToMinutes": 90, + "secondsToMinutesCeil": 2, + "secondsToMillisecondsIgnoresFormatter": 2000 + }, + "preZeroFill": { + "short": "007", + "long": "1234" + }, + "secToTimeStr": { + "fillHour": "01:01:01", + "showDay": "1D 1:01:01", + "negative": "--" + }, + "alphabet": { + "zero": "A", + "z": "Z", + "aa": "AA", + "acFromString": "AC", + "zz": "ZZ", + "aaa": "AAA", + "numberA": 0, + "numberAA": 26, + "numberLowerAc": 28, + "numberEmpty": -1 + } + }, + "resolvers": { + "text": { + "undefined": "", + "plain": "plain", + "zhCN": "中文", + "enGB": "English", + "zhHansCN": "中文", + "fallback": "Fallback", + "emptyMatch": "" + }, + "contributor": { + "missing": null, + "nameOnly": { + "name": "Alice" + }, + "nameEmail": { + "name": "Bob", + "email": "bob@example.com" + }, + "full": { + "name": "bLue", + "email": "mail@example.com", + "url": "https://example.com/" + }, + "nameUrl": { + "name": "John Smith", + "url": "https://example.com/" + } + }, + "color": { + "string": "#123456", + "rgbaTuple": "rgba(1,2,3,0.5)" + }, + "themeColor": { + "single": { + "light": "#abcdef", + "dark": "#abcdef" + }, + "pair": { + "light": "#ffffff", + "dark": "#000000" + } + }, + "style": { + "explicit": { + "textColor": { + "light": "#111111", + "dark": "#111111" + }, + "backgroundColor": { + "light": "#eeeeee", + "dark": "#eeeeee" + } + }, + "auto": { + "textColor": { + "light": "#000000", + "dark": "#ffffff" + }, + "backgroundColor": { + "light": "#ffffff", + "dark": "#000000" + } + }, + "autoGreen": { + "textColor": { + "light": "#000000", + "dark": "#000000" + }, + "backgroundColor": { + "light": "#00c000", + "dark": "#00c000" + } + }, + "autoShortHex": { + "textColor": { + "light": "#000000", + "dark": "#000000" + }, + "backgroundColor": { + "light": "#0c0", + "dark": "#0c0" + } + } + }, + "markers": { + "modernPrecedence": [ + { + "id": "girls", + "label": "Girls", + "style": "pink" + } + ], + "emptyModern": [], + "legacy": [ + { + "id": "official", + "label": "Official", + "style": "blue" + } + ], + "missingConfig": [] + } + }, + "ranklist": { + "sortedRows": [ + "solved-more", + "fast", + "slow" + ], + "regenerated": { + "type": "general", + "version": "0.3.9", + "contest": { + "title": "Contest", + "startAt": "2026-01-01T00:00:00+08:00", + "duration": [ + 5, + "h" + ] + }, + "problems": [ + { + "alias": "A", + "statistics": { + "accepted": 3, + "submitted": 4 + } + }, + { + "alias": "B", + "statistics": { + "accepted": 1, + "submitted": 2 + } + } + ], + "series": [ + { + "title": "Rank", + "rule": { + "preset": "Normal" + } + } + ], + "sorter": { + "algorithm": "ICPC", + "config": {} + }, + "rows": [ + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 2, + "time": [ + 11400000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "WA", + "time": [ + 10, + "min" + ] + }, + { + "result": "CE", + "time": [ + 15, + "min" + ] + }, + { + "result": "AC", + "time": [ + 50, + "min" + ] + } + ], + "tries": 2, + "time": [ + 50, + "min" + ] + }, + { + "result": "AC", + "solutions": [ + { + "result": "AC", + "time": [ + 120, + "min" + ] + } + ], + "time": [ + 120, + "min" + ], + "tries": 1 + } + ] + }, + { + "user": { + "id": "u3", + "name": "u3", + "official": false + }, + "score": { + "value": 1, + "time": [ + 1200000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "AC", + "time": [ + 20, + "min" + ] + } + ], + "time": [ + 20, + "min" + ], + "tries": 1 + }, + { + "result": null, + "solutions": [] + } + ] + }, + { + "user": { + "id": "u2", + "name": "u2" + }, + "score": { + "value": 1, + "time": [ + 1800000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "AC", + "time": [ + 30, + "min" + ] + } + ], + "time": [ + 30, + "min" + ], + "tries": 1 + }, + { + "result": "RJ", + "solutions": [ + { + "result": "WA", + "time": [ + 100, + "min" + ] + } + ], + "tries": 1 + } + ] + } + ] + }, + "defaultNoPenalty": { + "type": "general", + "version": "0.3.9", + "contest": { + "title": "Contest", + "startAt": "2026-01-01T00:00:00+08:00", + "duration": [ + 5, + "h" + ] + }, + "problems": [ + { + "alias": "A", + "statistics": { + "accepted": 1, + "submitted": 3 + } + } + ], + "series": [ + { + "title": "Rank", + "rule": { + "preset": "Normal" + } + } + ], + "sorter": { + "algorithm": "ICPC", + "config": {} + }, + "rows": [ + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 1, + "time": [ + 4200000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "WA", + "time": [ + 10, + "min" + ] + }, + { + "result": "CE", + "time": [ + 15, + "min" + ] + }, + { + "result": "WA", + "time": [ + 20, + "min" + ] + }, + { + "result": "?", + "time": [ + 25, + "min" + ] + }, + { + "result": "AC", + "time": [ + 30, + "min" + ] + } + ], + "tries": 3, + "time": [ + 30, + "min" + ] + } + ] + } + ] + }, + "customNoPenalty": { + "type": "general", + "version": "0.3.9", + "contest": { + "title": "Contest", + "startAt": "2026-01-01T00:00:00+08:00", + "duration": [ + 5, + "h" + ] + }, + "problems": [ + { + "alias": "A", + "statistics": { + "accepted": 1, + "submitted": 2 + } + } + ], + "series": [ + { + "title": "Rank", + "rule": { + "preset": "Normal" + } + } + ], + "sorter": { + "algorithm": "ICPC", + "config": { + "noPenaltyResults": [ + "FB", + "AC", + "?", + "NOUT", + "UKE", + null + ] + } + }, + "rows": [ + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 1, + "time": [ + 3000000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "CE", + "time": [ + 10, + "min" + ] + }, + { + "result": "AC", + "time": [ + 30, + "min" + ] + } + ], + "tries": 2, + "time": [ + 30, + "min" + ] + } + ] + } + ] + }, + "postAc": { + "type": "general", + "version": "0.3.9", + "contest": { + "title": "Contest", + "startAt": "2026-01-01T00:00:00+08:00", + "duration": [ + 5, + "h" + ] + }, + "problems": [ + { + "alias": "A", + "statistics": { + "accepted": 1, + "submitted": 2 + } + } + ], + "series": [ + { + "title": "Rank", + "rule": { + "preset": "Normal" + } + } + ], + "sorter": { + "algorithm": "ICPC", + "config": {} + }, + "rows": [ + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 1, + "time": [ + 2400000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "WA", + "time": [ + 10, + "min" + ] + }, + { + "result": "AC", + "time": [ + 20, + "min" + ] + }, + { + "result": "WA", + "time": [ + 30, + "min" + ] + }, + { + "result": "FB", + "time": [ + 40, + "min" + ] + } + ], + "tries": 2, + "time": [ + 20, + "min" + ] + } + ] + } + ] + }, + "timePrecision": { + "type": "general", + "version": "0.3.9", + "contest": { + "title": "Contest", + "startAt": "2026-01-01T00:00:00+08:00", + "duration": [ + 5, + "h" + ] + }, + "problems": [ + { + "alias": "A", + "statistics": { + "accepted": 1, + "submitted": 1 + } + } + ], + "series": [ + { + "title": "Rank", + "rule": { + "preset": "Normal" + } + } + ], + "sorter": { + "algorithm": "ICPC", + "config": { + "timePrecision": "min", + "timeRounding": "ceil" + } + }, + "rows": [ + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 1, + "time": [ + 180000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "AC", + "time": [ + 125, + "s" + ] + } + ], + "time": [ + 125, + "s" + ], + "tries": 1 + } + ] + } + ] + }, + "rankingPrecisionOrder": [ + "slow-original-first", + "fast-original-second" + ], + "incrementalRows": [ + { + "user": { + "id": "u2", + "name": "u2" + }, + "score": { + "value": 1, + "time": [ + 1200000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "AC", + "time": [ + 20, + "min" + ] + } + ], + "time": [ + 20, + "min" + ], + "tries": 1 + } + ] + }, + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 1, + "time": [ + 3300000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "WA", + "time": [ + 10, + "min" + ] + }, + { + "result": "CE", + "time": [ + 15, + "min" + ] + }, + { + "result": "AC", + "time": [ + 35, + "min" + ] + } + ], + "tries": 2, + "time": [ + 35, + "min" + ] + } + ] + } + ], + "incrementalPostAcRows": [ + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 1, + "time": [ + 20, + "min" + ] + }, + "statuses": [ + { + "result": "AC", + "time": [ + 20, + "min" + ], + "tries": 1, + "solutions": [ + { + "result": "AC", + "time": [ + 20, + "min" + ] + }, + { + "result": "WA", + "time": [ + 30, + "min" + ] + }, + { + "result": "AC", + "time": [ + 40, + "min" + ] + } + ] + } + ] + } + ], + "staticRankValues": [ + [ + { + "rank": 1, + "segmentIndex": null + }, + { + "rank": 1, + "segmentIndex": null + }, + { + "rank": 1, + "segmentIndex": null + }, + { + "rank": 1, + "segmentIndex": 0 + } + ], + [ + { + "rank": 1, + "segmentIndex": null + }, + { + "rank": 1, + "segmentIndex": null + }, + { + "rank": null, + "segmentIndex": null + }, + { + "rank": 1, + "segmentIndex": 1 + } + ], + [ + { + "rank": 3, + "segmentIndex": null + }, + { + "rank": null, + "segmentIndex": null + }, + { + "rank": 2, + "segmentIndex": null + }, + { + "rank": null, + "segmentIndex": null + } + ], + [ + { + "rank": 4, + "segmentIndex": null + }, + { + "rank": 3, + "segmentIndex": null + }, + { + "rank": null, + "segmentIndex": null + }, + { + "rank": 3, + "segmentIndex": null + } + ] + ], + "markerRankValues": [ + { + "rank": 1, + "segmentIndex": 0 + }, + { + "rank": null, + "segmentIndex": null + }, + { + "rank": 2, + "segmentIndex": 1 + } + ], + "invalidFilterRankValue": { + "rank": null, + "segmentIndex": null + }, + "ratioRankValues": [ + { + "rank": 1, + "segmentIndex": 0 + }, + { + "rank": 2, + "segmentIndex": 1 + }, + { + "rank": 3, + "segmentIndex": 1 + }, + { + "rank": 4, + "segmentIndex": null + }, + { + "rank": 5, + "segmentIndex": null + }, + { + "rank": 6, + "segmentIndex": null + }, + { + "rank": 7, + "segmentIndex": null + }, + { + "rank": 8, + "segmentIndex": null + }, + { + "rank": 9, + "segmentIndex": null + }, + { + "rank": 10, + "segmentIndex": null + } + ], + "strictIdRankValues": [ + { + "rank": 1, + "segmentIndex": 0 + }, + { + "rank": null, + "segmentIndex": 0 + } + ] + } +} diff --git a/go/types.go b/go/types.go new file mode 100644 index 0000000..14a450b --- /dev/null +++ b/go/types.go @@ -0,0 +1,173 @@ +package srkutils + +import ( + "encoding/json" + "fmt" +) + +type TimeUnit string + +const ( + TimeUnitMilliseconds TimeUnit = "ms" + TimeUnitSeconds TimeUnit = "s" + TimeUnitMinutes TimeUnit = "min" + TimeUnitHours TimeUnit = "h" + TimeUnitDays TimeUnit = "d" +) + +type TimeDuration struct { + Value float64 + Unit TimeUnit +} + +func (t TimeDuration) MarshalJSON() ([]byte, error) { + return json.Marshal([]any{t.Value, t.Unit}) +} + +func (t *TimeDuration) UnmarshalJSON(data []byte) error { + var raw []any + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if len(raw) != 2 { + return fmt.Errorf("time duration must have two elements") + } + value, ok := raw[0].(float64) + if !ok { + return fmt.Errorf("time duration value must be numeric") + } + unit, ok := raw[1].(string) + if !ok { + return fmt.Errorf("time duration unit must be a string") + } + t.Value = value + t.Unit = TimeUnit(unit) + return nil +} + +type RankValue struct { + Rank *int `json:"rank"` + SegmentIndex *int `json:"segmentIndex"` +} + +type Solution struct { + Result string `json:"result"` + Time any `json:"time"` + Score *float64 `json:"score,omitempty"` + Link string `json:"link,omitempty"` +} + +type RankScore struct { + Value float64 `json:"value"` + Time any `json:"time,omitempty"` +} + +type RankProblemStatus struct { + Result any `json:"result"` + Score *float64 `json:"score,omitempty"` + Time any `json:"time,omitempty"` + Tries *int `json:"tries,omitempty"` + Solutions []Solution `json:"solutions,omitempty"` +} + +type User struct { + ID string `json:"id"` + Name any `json:"name"` + Official *bool `json:"official,omitempty"` + Organization any `json:"organization,omitempty"` + Marker string `json:"marker,omitempty"` + Markers []string `json:"markers,omitempty"` +} + +type RanklistRow struct { + User User `json:"user"` + Score RankScore `json:"score"` + Statuses []RankProblemStatus `json:"statuses"` +} + +type Ranklist map[string]any + +func UserToMap(user User) map[string]any { + result := map[string]any{ + "id": user.ID, + "name": user.Name, + } + if user.Official != nil { + result["official"] = *user.Official + } + if user.Organization != nil { + result["organization"] = user.Organization + } + if user.Marker != "" { + result["marker"] = user.Marker + } + if user.Markers != nil { + result["markers"] = user.Markers + } + return result +} + +func RankScoreToMap(score RankScore) map[string]any { + result := map[string]any{"value": score.Value} + if score.Time != nil { + result["time"] = score.Time + } + return result +} + +func SolutionToMap(solution Solution) map[string]any { + result := map[string]any{ + "result": solution.Result, + } + if solution.Time != nil { + result["time"] = solution.Time + } + if solution.Score != nil { + result["score"] = *solution.Score + } + if solution.Link != "" { + result["link"] = solution.Link + } + return result +} + +func RankProblemStatusToMap(status RankProblemStatus) map[string]any { + result := map[string]any{"result": status.Result} + if status.Score != nil { + result["score"] = *status.Score + } + if status.Time != nil { + result["time"] = status.Time + } + if status.Tries != nil { + result["tries"] = *status.Tries + } + if status.Solutions != nil { + solutions := make([]any, len(status.Solutions)) + for index, solution := range status.Solutions { + solutions[index] = SolutionToMap(solution) + } + result["solutions"] = solutions + } + return result +} + +func RanklistRowToMap(row RanklistRow) map[string]any { + statuses := make([]any, len(row.Statuses)) + for index, status := range row.Statuses { + statuses[index] = RankProblemStatusToMap(status) + } + return map[string]any{ + "user": UserToMap(row.User), + "score": RankScoreToMap(row.Score), + "statuses": statuses, + } +} + +func RanklistRowsToMaps(rows []RanklistRow) []map[string]any { + result := make([]map[string]any, len(rows)) + for index, row := range rows { + result[index] = RanklistRowToMap(row) + } + return result +} diff --git a/js/README.md b/js/README.md new file mode 100644 index 0000000..f059d5a --- /dev/null +++ b/js/README.md @@ -0,0 +1,51 @@ +# @algoux/standard-ranklist-utils + +JavaScript and TypeScript utilities for Standard Ranklist (srk). + +Supported srk versions: `>=0.3.0 <0.4.0`. + +## Install + +```shell +npm i -S @algoux/standard-ranklist @algoux/standard-ranklist-utils +``` + +## Usage Sample + +```ts +import { formatTimeDuration, resolveText, sortRows } from '@algoux/standard-ranklist-utils'; + +formatTimeDuration([1.5, 'h'], 'min'); // 90 +resolveText({ fallback: 'English', 'zh-CN': '中文' }, ['zh-CN']); // 中文 +sortRows(ranklist.rows, ranklist.sorter?.config); +``` + +## Utilities + +### formatters + +- `formatTimeDuration`: Convert an srk `TimeDuration` between `ms`, `s`, `min`, `h`, and `d`. +- `preZeroFill`: Left-pad a number with zeroes for fixed-width display. +- `secToTimeStr`: Format elapsed seconds as a ranklist time string such as `1:02:03` or `1D 1:02:03`. +- `numberToAlphabet`: Convert a zero-based problem index to an alphabetic alias such as `A`, `Z`, or `AA`. +- `alphabetToNumber`: Convert an alphabetic problem alias back to a zero-based index. + +### resolvers + +- `resolveText`: Resolve plain or i18n srk text using browser language preferences and fallback text. +- `resolveContributor`: Parse a contributor string into `name`, optional `email`, and optional `url`. +- `resolveColor`: Normalize an srk color value to a CSS color string. +- `resolveThemeColor`: Expand a single color or theme color object into explicit light and dark colors. +- `resolveStyle`: Resolve text/background style colors and auto-pick readable text color when needed. +- `resolveUserMarkers`: Resolve a user's marker IDs to marker definitions from the ranklist config. + +### ranklist + +- `canRegenerateRanklist`: Check whether a ranklist version and sorter support ICPC regeneration. +- `getSortedCalculatedRawSolutions`: Extract and sort a submission timeline from ranklist rows. +- `filterSolutionsUntil`: Keep only solutions submitted at or before a given contest time. +- `sortRows`: Sort rows by ICPC solved count descending and penalty time ascending, with optional ranking-time precision. +- `calculateProblemStatistics`: Recalculate accepted/submitted totals for each problem, using full solution histories when present. +- `regenerateRanklistBySolutions`: Rebuild rows, scores, sorting, and problem statistics from solution tetrads. +- `regenerateRowsByIncrementalSolutions`: Apply incremental solution tetrads to existing rows and re-sort them. +- `convertToStaticRanklist`: Add precomputed per-series rank values and segment indexes to each row. diff --git a/js/package.json b/js/package.json new file mode 100644 index 0000000..acc2d63 --- /dev/null +++ b/js/package.json @@ -0,0 +1,67 @@ +{ + "name": "@algoux/standard-ranklist-utils", + "version": "0.3.0", + "author": "bLue", + "keywords": [ + "standard ranklist", + "srk", + "utils" + ], + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.cts", + "exports": { + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + }, + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "dev": "tsx src/index.ts", + "generate:fixtures": "tsx scripts/generate-fixtures.ts", + "test": "tsc --noEmit -p tsconfig.test.json && node --import tsx --test tests/*.test.ts", + "build": "pkgroll", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@types/semver": "^7.5.0", + "bcp-47-match": "^2.0.2", + "bignumber.js": "^9.1.1", + "semver": "^7.5.4", + "textcolor": "^1.0.2" + }, + "devDependencies": { + "@algoux/standard-ranklist": "^0.3.9", + "@types/node": "^16.18.38", + "@typescript-eslint/eslint-plugin": "^5.61.0", + "@typescript-eslint/parser": "^5.61.0", + "eslint": "^8.44.0", + "eslint-config-alloy": "^4.0.0", + "pkgroll": "~2.3.0", + "prettier": "^2.7.1", + "rimraf": "^5.0.1", + "tsx": "^4.19.4", + "typescript": "^4.9.5" + }, + "peerDependencies": { + "@algoux/standard-ranklist": "*" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/algoux/standard-ranklist-utils.git", + "directory": "js" + }, + "bugs": { + "url": "https://github.com/algoux/standard-ranklist-utils/issues" + }, + "homepage": "https://github.com/algoux/standard-ranklist-utils#readme", + "srkSupportedVersions": ">=0.3.0 <0.4.0" +} diff --git a/js/scripts/generate-fixtures.ts b/js/scripts/generate-fixtures.ts new file mode 100644 index 0000000..2e546a0 --- /dev/null +++ b/js/scripts/generate-fixtures.ts @@ -0,0 +1,392 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type * as srk from '@algoux/standard-ranklist'; +import { + EnumTheme, + MIN_REGEN_SUPPORTED_VERSION, + alphabetToNumber, + convertToStaticRanklist, + formatTimeDuration, + numberToAlphabet, + preZeroFill, + regenerateRanklistBySolutions, + regenerateRowsByIncrementalSolutions, + resolveColor, + resolveContributor, + resolveStyle, + resolveText, + resolveThemeColor, + resolveUserMarkers, + secToTimeStr, + sortRows, +} from '../src'; +import type { CalculatedSolutionTetrad } from '../src'; +import packageJson from '../package.json'; + +function makeRanklist(overrides: Partial = {}): srk.Ranklist { + return { + type: 'general', + version: '0.3.9', + contest: { + title: 'Contest', + startAt: '2026-01-01T00:00:00+08:00', + duration: [5, 'h'], + }, + problems: [{ alias: 'A' }, { alias: 'B' }], + series: [{ title: 'Rank', rule: { preset: 'Normal' } }], + rows: [], + sorter: { + algorithm: 'ICPC', + config: {}, + }, + ...overrides, + }; +} + +function makeRow( + id: string, + score: srk.RankScore = { value: 0, time: [0, 'ms'] }, + statuses: srk.RankProblemStatus[] = [ + { result: null, solutions: [] }, + { result: null, solutions: [] }, + ], + user: Partial = {}, +): srk.RanklistRow { + return { + user: { + id, + name: id, + ...user, + }, + score, + statuses, + }; +} + +const regenerationInput = makeRanklist({ + rows: [ + makeRow('u1'), + makeRow('u2'), + makeRow('u3', { value: 0, time: [0, 'ms'] }, undefined, { official: false }), + ], + problems: [{ alias: 'A', statistics: { accepted: 0, submitted: 0 } }, { alias: 'B' }], +}); +const regenerationSolutions: CalculatedSolutionTetrad[] = [ + ['u1', 0, 'WA', [10, 'min']], + ['u1', 0, 'CE', [15, 'min']], + ['u3', 0, 'AC', [20, 'min']], + ['u2', 0, 'AC', [30, 'min']], + ['u1', 0, 'AC', [50, 'min']], + ['u2', 1, 'WA', [100, 'min']], + ['u1', 1, 'AC', [120, 'min']], +]; + +const defaultNoPenaltyInput = makeRanklist({ + problems: [{ alias: 'A' }], + rows: [makeRow('u1', { value: 0, time: [0, 'ms'] }, [{ result: null, solutions: [] }])], +}); +const defaultNoPenaltySolutions: CalculatedSolutionTetrad[] = [ + ['u1', 0, 'WA', [10, 'min']], + ['u1', 0, 'CE', [15, 'min']], + ['u1', 0, 'WA', [20, 'min']], + ['u1', 0, '?', [25, 'min']], + ['u1', 0, 'AC', [30, 'min']], +]; + +const customNoPenaltyInput = makeRanklist({ + problems: [{ alias: 'A' }], + rows: [makeRow('u1', { value: 0, time: [0, 'ms'] }, [{ result: null, solutions: [] }])], + sorter: { + algorithm: 'ICPC', + config: { + noPenaltyResults: ['FB', 'AC', '?', 'NOUT', 'UKE', null], + }, + }, +}); + +const postAcInput = makeRanklist({ + problems: [{ alias: 'A' }], + rows: [makeRow('u1', { value: 0, time: [0, 'ms'] }, [{ result: null, solutions: [] }])], +}); +const postAcSolutions: CalculatedSolutionTetrad[] = [ + ['u1', 0, 'WA', [10, 'min']], + ['u1', 0, 'AC', [20, 'min']], + ['u1', 0, 'WA', [30, 'min']], + ['u1', 0, 'FB', [40, 'min']], +]; + +const incrementalInput = makeRanklist({ + problems: [{ alias: 'A' }], + rows: [ + makeRow('u1', { value: 0, time: [0, 'ms'] }, [{ result: null, solutions: [] }]), + makeRow('u2', { value: 0, time: [0, 'ms'] }, [{ result: null, solutions: [] }]), + ], +}); +const incrementalSolutions: CalculatedSolutionTetrad[] = [ + ['u1', 0, 'WA', [10, 'min']], + ['u1', 0, 'CE', [15, 'min']], + ['u2', 0, 'AC', [20, 'min']], + ['u1', 0, 'AC', [35, 'min']], +]; + +const staticRanklist = makeRanklist({ + series: [ + { title: 'Overall', rule: { preset: 'Normal' } }, + { title: 'Official', rule: { preset: 'Normal', options: { includeOfficialOnly: true } } }, + { title: 'School', rule: { preset: 'UniqByUserField', options: { field: 'organization' } } }, + { + title: 'Medals', + segments: [{ title: 'Gold' }, { title: 'Silver' }], + rule: { preset: 'ICPC', options: { count: { value: [1, 1], noTied: true } } }, + }, + ], + rows: [ + makeRow('u1', { value: 2, time: [100, 'min'] }, undefined, { organization: 'School A' }), + makeRow('u2', { value: 2, time: [100, 'min'] }, undefined, { organization: 'School A' }), + makeRow('u3', { value: 1, time: [50, 'min'] }, undefined, { + organization: 'School B', + official: false, + }), + makeRow('u4', { value: 1, time: [60, 'min'] }, undefined, { organization: 'School B' }), + ], +}); + +const markerRanklist = makeRanklist({ + series: [ + { + title: 'Girls', + segments: [{ title: 'Gold' }, { title: 'Silver' }], + rule: { preset: 'ICPC', options: { filter: { byMarker: 'girls' }, count: { value: [1, 1] } } }, + }, + ], + rows: [ + makeRow('modern-marker', { value: 3, time: [10, 'min'] }, undefined, { markers: ['girls'] }), + makeRow('empty-modern-marker', { value: 2, time: [20, 'min'] }, undefined, { + marker: 'girls', + markers: [], + }), + makeRow('legacy-marker', { value: 1, time: [30, 'min'] }, undefined, { marker: 'girls' }), + ], +}); + +const invalidFilterRanklist = makeRanklist({ + series: [ + { + title: 'Invalid filter', + segments: [{ title: 'Gold' }], + rule: { + preset: 'ICPC', + options: { + filter: { byUserFields: [{ field: 'organization', rule: '(' }] }, + count: { value: [1] }, + }, + }, + }, + ], + rows: [makeRow('u1', { value: 1, time: [10, 'min'] }, undefined, { organization: 'SDUT' })], +}); + +const ratioRanklist = makeRanklist({ + series: [ + { + title: 'Ratio', + segments: [{ title: 'A' }, { title: 'B' }], + rule: { preset: 'ICPC', options: { ratio: { value: [0.1, 0.2], rounding: 'ceil' } } }, + }, + ], + rows: new Array(10) + .fill(null) + .map((_, index) => makeRow(`ratio-u${index + 1}`, { value: 10 - index, time: [index, 'min'] })), +}); + +const strictIdRanklist = makeRanklist({ + series: [ + { + title: 'Strict ID', + segments: [{ title: 'Only' }], + rule: { preset: 'ICPC', options: { filter: { byMarker: 'girls' }, count: { value: [2] } } }, + }, + ], + rows: [ + makeRow('fallback-a', { value: 2, time: [10, 'min'] }, undefined, { + id: undefined, + name: 'No ID A', + marker: 'girls', + } as unknown as srk.User), + makeRow('fallback-b', { value: 1, time: [20, 'min'] }, undefined, { + id: undefined, + name: 'No ID B', + } as unknown as srk.User), + ], +}); + +const originalWarn = console.warn; +console.warn = () => {}; + +const fixtures = { + constants: { + minRegenSupportedVersion: MIN_REGEN_SUPPORTED_VERSION, + srkSupportedVersions: packageJson.srkSupportedVersions, + enumTheme: EnumTheme, + }, + formatters: { + formatTimeDuration: { + hoursToMinutes: formatTimeDuration([1.5, 'h'], 'min'), + secondsToMinutesCeil: formatTimeDuration([61, 's'], 'min', Math.ceil), + secondsToMillisecondsIgnoresFormatter: formatTimeDuration([2, 's'], 'ms', () => 0), + }, + preZeroFill: { + short: preZeroFill(7, 3), + long: preZeroFill(1234, 3), + }, + secToTimeStr: { + fillHour: secToTimeStr(3661, { fillHour: true }), + showDay: secToTimeStr(90061, { showDay: true }), + negative: secToTimeStr(-1), + }, + alphabet: { + zero: numberToAlphabet(0), + z: numberToAlphabet(25), + aa: numberToAlphabet(26), + acFromString: numberToAlphabet('28'), + zz: numberToAlphabet(701), + aaa: numberToAlphabet(702), + numberA: alphabetToNumber('A'), + numberAA: alphabetToNumber('AA'), + numberLowerAc: alphabetToNumber('ac'), + numberEmpty: alphabetToNumber(''), + }, + }, + resolvers: { + text: { + undefined: resolveText(undefined), + plain: resolveText('plain'), + zhCN: resolveText({ fallback: 'Fallback', 'en-US': 'English', 'zh-CN': '中文' }, ['zh-CN']), + enGB: resolveText({ fallback: 'Fallback', 'en-US': 'English', 'zh-CN': '中文' }, ['en-GB']), + zhHansCN: resolveText({ fallback: 'Fallback', 'zh-CN': '中文' }, ['zh-Hans-CN']), + fallback: resolveText({ fallback: 'Fallback', 'en-US': 'English' }, ['fr-FR']), + emptyMatch: resolveText({ fallback: 'Fallback', 'en-US': '' }, ['en-US']), + }, + contributor: { + missing: resolveContributor(undefined), + nameOnly: resolveContributor('Alice'), + nameEmail: resolveContributor('Bob '), + full: resolveContributor('bLue (https://example.com/)'), + nameUrl: resolveContributor('John Smith (https://example.com/)'), + }, + color: { + string: resolveColor('#123456'), + empty: resolveColor('' as srk.Color), + rgbaTuple: resolveColor([1, 2, 3, 0.5] as unknown as srk.Color), + }, + themeColor: { + single: resolveThemeColor('#abcdef'), + pair: resolveThemeColor({ light: '#ffffff', dark: '#000000' }), + }, + style: { + explicit: resolveStyle({ textColor: '#111111', backgroundColor: '#eeeeee' }), + auto: resolveStyle({ backgroundColor: { light: '#ffffff', dark: '#000000' } }), + autoGreen: resolveStyle({ backgroundColor: '#00c000' }), + autoShortHex: resolveStyle({ backgroundColor: '#0c0' }), + }, + markers: { + modernPrecedence: resolveUserMarkers( + { id: 'u1', name: 'U1', marker: 'official', markers: ['girls', 'none'] }, + [ + { id: 'official', label: 'Official', style: 'blue' }, + { id: 'girls', label: 'Girls', style: 'pink' }, + ], + ), + emptyModern: resolveUserMarkers( + { id: 'u2', name: 'U2', marker: 'official', markers: [] }, + [ + { id: 'official', label: 'Official', style: 'blue' }, + { id: 'girls', label: 'Girls', style: 'pink' }, + ], + ), + legacy: resolveUserMarkers( + { id: 'u2', name: 'U2', marker: 'official' }, + [ + { id: 'official', label: 'Official', style: 'blue' }, + { id: 'girls', label: 'Girls', style: 'pink' }, + ], + ), + missingConfig: resolveUserMarkers({ id: 'u3', name: 'U3', markers: ['girls'] }, undefined), + }, + }, + ranklist: { + sortedRows: sortRows([ + makeRow('slow', { value: 1, time: [30, 'min'] }), + makeRow('fast', { value: 1, time: [20, 'min'] }), + makeRow('solved-more', { value: 2, time: [90, 'min'] }), + ]).map((row) => row.user.id), + regenerated: regenerateRanklistBySolutions(regenerationInput, regenerationSolutions), + defaultNoPenalty: regenerateRanklistBySolutions(defaultNoPenaltyInput, defaultNoPenaltySolutions), + customNoPenalty: regenerateRanklistBySolutions(customNoPenaltyInput, [ + ['u1', 0, 'CE', [10, 'min']], + ['u1', 0, 'AC', [30, 'min']], + ]), + postAc: regenerateRanklistBySolutions(postAcInput, postAcSolutions), + timePrecision: regenerateRanklistBySolutions( + makeRanklist({ + problems: [{ alias: 'A' }], + rows: [makeRow('u1', { value: 0, time: [0, 'ms'] }, [{ result: null, solutions: [] }])], + sorter: { + algorithm: 'ICPC', + config: { + timePrecision: 'min', + timeRounding: 'ceil', + }, + }, + }), + [['u1', 0, 'AC', [125, 's']]], + ), + rankingPrecisionOrder: regenerateRanklistBySolutions( + makeRanklist({ + problems: [{ alias: 'A' }], + rows: [ + makeRow('slow-original-first', { value: 0, time: [0, 'ms'] }, [{ result: null, solutions: [] }]), + makeRow('fast-original-second', { value: 0, time: [0, 'ms'] }, [{ result: null, solutions: [] }]), + ], + sorter: { + algorithm: 'ICPC', + config: { + rankingTimePrecision: 'h', + rankingTimeRounding: 'floor', + }, + }, + }), + [ + ['slow-original-first', 0, 'AC', [359, 'min']], + ['fast-original-second', 0, 'AC', [301, 'min']], + ], + ).rows.map((row) => row.user.id), + incrementalRows: regenerateRowsByIncrementalSolutions(incrementalInput, incrementalSolutions), + incrementalPostAcRows: regenerateRowsByIncrementalSolutions( + makeRanklist({ + problems: [{ alias: 'A' }], + rows: [ + makeRow('u1', { value: 1, time: [20, 'min'] }, [ + { result: 'AC', time: [20, 'min'], tries: 1, solutions: [{ result: 'AC', time: [20, 'min'] }] }, + ]), + ], + }), + [ + ['u1', 0, 'WA', [30, 'min']], + ['u1', 0, 'AC', [40, 'min']], + ], + ), + staticRankValues: convertToStaticRanklist(staticRanklist).rows.map((row) => row.rankValues), + markerRankValues: convertToStaticRanklist(markerRanklist).rows.map((row) => row.rankValues[0]), + invalidFilterRankValue: convertToStaticRanklist(invalidFilterRanklist).rows[0].rankValues[0], + ratioRankValues: convertToStaticRanklist(ratioRanklist).rows.map((row) => row.rankValues[0]), + strictIdRankValues: convertToStaticRanklist(strictIdRanklist).rows.map((row) => row.rankValues[0]), + }, +}; + +console.warn = originalWarn; + +const outputPath = fileURLToPath(new URL('../../testdata/contract-fixtures.json', import.meta.url)); +mkdirSync(dirname(outputPath), { recursive: true }); +writeFileSync(outputPath, `${JSON.stringify(fixtures, null, 2)}\n`); diff --git a/src/constants.ts b/js/src/constants.ts similarity index 100% rename from src/constants.ts rename to js/src/constants.ts diff --git a/src/enums.ts b/js/src/enums.ts similarity index 100% rename from src/enums.ts rename to js/src/enums.ts diff --git a/src/formatters.ts b/js/src/formatters.ts similarity index 100% rename from src/formatters.ts rename to js/src/formatters.ts diff --git a/src/index.ts b/js/src/index.ts similarity index 100% rename from src/index.ts rename to js/src/index.ts diff --git a/src/ranklist.ts b/js/src/ranklist.ts similarity index 100% rename from src/ranklist.ts rename to js/src/ranklist.ts diff --git a/src/resolvers.ts b/js/src/resolvers.ts similarity index 94% rename from src/resolvers.ts rename to js/src/resolvers.ts index 61cb1f5..9121036 100644 --- a/src/resolvers.ts +++ b/js/src/resolvers.ts @@ -12,9 +12,10 @@ import { ThemeColor } from './types'; * If no exact lookup result is found, a primary-language match is attempted before using the `fallback` field. * * @param text - Plain text or i18n text object. + * @param languages - Optional preferred BCP 47 language tags. Browser `navigator.languages` is used when omitted. * @returns Resolved display string, or an empty string for missing text. */ -export function resolveText(text: srk.Text | undefined): string { +export function resolveText(text: srk.Text | undefined, languages?: readonly string[]): string { if (text === undefined) { return ''; } @@ -25,7 +26,7 @@ export function resolveText(text: srk.Text | undefined): string { .filter((k) => k && k !== 'fallback') .sort() .reverse(); - const userLangs = (typeof navigator !== 'undefined' && [...navigator.languages]) || []; + const userLangs = languages ? [...languages] : (typeof navigator !== 'undefined' && [...navigator.languages]) || []; const usingLang = langLookup(userLangs, langs) || userLangs diff --git a/src/types.ts b/js/src/types.ts similarity index 100% rename from src/types.ts rename to js/src/types.ts diff --git a/tests/constants.test.ts b/js/tests/constants.test.ts similarity index 100% rename from tests/constants.test.ts rename to js/tests/constants.test.ts diff --git a/tests/enums.test.ts b/js/tests/enums.test.ts similarity index 100% rename from tests/enums.test.ts rename to js/tests/enums.test.ts diff --git a/tests/formatters.test.ts b/js/tests/formatters.test.ts similarity index 100% rename from tests/formatters.test.ts rename to js/tests/formatters.test.ts diff --git a/tests/index.test.ts b/js/tests/index.test.ts similarity index 100% rename from tests/index.test.ts rename to js/tests/index.test.ts diff --git a/tests/ranklist.test.ts b/js/tests/ranklist.test.ts similarity index 100% rename from tests/ranklist.test.ts rename to js/tests/ranklist.test.ts diff --git a/tests/resolvers.test.ts b/js/tests/resolvers.test.ts similarity index 94% rename from tests/resolvers.test.ts rename to js/tests/resolvers.test.ts index 5266e9a..5ae5f9f 100644 --- a/tests/resolvers.test.ts +++ b/js/tests/resolvers.test.ts @@ -55,6 +55,11 @@ describe('resolvers', () => { } }); + test('resolveText accepts explicit language preferences for non-browser callers', () => { + assert.equal(resolveText({ fallback: 'Fallback', 'en-US': 'English', 'zh-CN': '中文' }, ['en-GB']), 'English'); + assert.equal(resolveText({ fallback: 'Fallback', 'zh-CN': '中文' }, ['fr-FR']), 'Fallback'); + }); + test('resolveContributor parses contributor metadata from package-style strings', () => { assert.equal(resolveContributor(undefined), null); assert.deepEqual(resolveContributor('Alice'), { diff --git a/tests/types.test.ts b/js/tests/types.test.ts similarity index 100% rename from tests/types.test.ts rename to js/tests/types.test.ts diff --git a/tsconfig.json b/js/tsconfig.json similarity index 100% rename from tsconfig.json rename to js/tsconfig.json diff --git a/tsconfig.test.json b/js/tsconfig.test.json similarity index 100% rename from tsconfig.test.json rename to js/tsconfig.test.json diff --git a/package.json b/package.json index 88ea9de..43002c0 100644 --- a/package.json +++ b/package.json @@ -1,67 +1,18 @@ { - "name": "@algoux/standard-ranklist-utils", - "version": "0.2.13", - "author": "bLue", - "keywords": [ - "standard ranklist", - "srk", - "utils" - ], - "main": "./dist/index.cjs", - "module": "./dist/index.mjs", - "types": "./dist/index.d.cts", - "exports": { - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - }, - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - } - }, - "files": [ - "dist" - ], + "name": "standard-ranklist-utils-monorepo", + "private": true, + "version": "0.3.0", "scripts": { - "dev": "tsx src/index.ts", - "test": "tsc --noEmit -p tsconfig.test.json && node --import tsx --test tests/*.test.ts", - "build": "pkgroll", - "prepublishOnly": "npm run build" - }, - "dependencies": { - "@types/semver": "^7.5.0", - "bcp-47-match": "^2.0.2", - "bignumber.js": "^9.1.1", - "semver": "^7.5.4", - "textcolor": "^1.0.2" - }, - "devDependencies": { - "@algoux/standard-ranklist": "^0.3.9", - "@types/node": "^16.18.38", - "@typescript-eslint/eslint-plugin": "^5.61.0", - "@typescript-eslint/parser": "^5.61.0", - "eslint": "^8.44.0", - "eslint-config-alloy": "^4.0.0", - "pkgroll": "~2.3.0", - "prettier": "^2.7.1", - "rimraf": "^5.0.1", - "tsx": "^4.19.4", - "typescript": "^4.9.5" - }, - "peerDependencies": { - "@algoux/standard-ranklist": "*" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/algoux/standard-ranklist-utils.git" - }, - "bugs": { - "url": "https://github.com/algoux/standard-ranklist-utils/issues" + "prepack": "node scripts/prevent-root-pack.mjs", + "sync:fixtures": "pnpm -C js generate:fixtures && node scripts/sync-fixtures.mjs", + "check:fixtures": "node scripts/check-fixtures.mjs", + "verify:fixtures": "pnpm run sync:fixtures && git diff --exit-code -- testdata/contract-fixtures.json python/tests/fixtures/contract-fixtures.json go/testdata/fixtures/contract-fixtures.json", + "test:js": "pnpm -C js test", + "build:js": "pnpm -C js build", + "test:python": "node scripts/run-python-tests.mjs", + "test:go": "node scripts/run-go-tests.mjs", + "test": "pnpm run verify:fixtures && pnpm run test:js && pnpm run test:python && pnpm run test:go" }, - "homepage": "https://github.com/algoux/standard-ranklist-utils#readme", - "srkSupportedVersions": ">=0.3.0 <0.4.0", "pnpm": { "overrides": { "esbuild": "0.23.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2651138..9a38300 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,57 +7,61 @@ settings: overrides: esbuild: 0.23.1 -dependencies: - '@types/semver': - specifier: ^7.5.0 - version: 7.5.0 - bcp-47-match: - specifier: ^2.0.2 - version: 2.0.2 - bignumber.js: - specifier: ^9.1.1 - version: 9.1.1 - semver: - specifier: ^7.5.4 - version: 7.5.4 - textcolor: - specifier: ^1.0.2 - version: 1.0.2 - -devDependencies: - '@algoux/standard-ranklist': - specifier: ^0.3.9 - version: 0.3.9 - '@types/node': - specifier: ^16.18.38 - version: 16.18.38 - '@typescript-eslint/eslint-plugin': - specifier: ^5.61.0 - version: 5.61.0(@typescript-eslint/parser@5.61.0)(eslint@8.44.0)(typescript@4.9.5) - '@typescript-eslint/parser': - specifier: ^5.61.0 - version: 5.61.0(eslint@8.44.0)(typescript@4.9.5) - eslint: - specifier: ^8.44.0 - version: 8.44.0 - eslint-config-alloy: - specifier: ^4.0.0 - version: 4.0.0 - pkgroll: - specifier: ~2.3.0 - version: 2.3.0(typescript@4.9.5) - prettier: - specifier: ^2.7.1 - version: 2.7.1 - rimraf: - specifier: ^5.0.1 - version: 5.0.1 - tsx: - specifier: ^4.19.4 - version: 4.19.4 - typescript: - specifier: ^4.9.5 - version: 4.9.5 +importers: + + .: {} + + js: + dependencies: + '@types/semver': + specifier: ^7.5.0 + version: 7.5.0 + bcp-47-match: + specifier: ^2.0.2 + version: 2.0.2 + bignumber.js: + specifier: ^9.1.1 + version: 9.1.1 + semver: + specifier: ^7.5.4 + version: 7.5.4 + textcolor: + specifier: ^1.0.2 + version: 1.0.2 + devDependencies: + '@algoux/standard-ranklist': + specifier: ^0.3.9 + version: 0.3.9 + '@types/node': + specifier: ^16.18.38 + version: 16.18.38 + '@typescript-eslint/eslint-plugin': + specifier: ^5.61.0 + version: 5.61.0(@typescript-eslint/parser@5.61.0)(eslint@8.44.0)(typescript@4.9.5) + '@typescript-eslint/parser': + specifier: ^5.61.0 + version: 5.61.0(eslint@8.44.0)(typescript@4.9.5) + eslint: + specifier: ^8.44.0 + version: 8.44.0 + eslint-config-alloy: + specifier: ^4.0.0 + version: 4.0.0 + pkgroll: + specifier: ~2.3.0 + version: 2.3.0(typescript@4.9.5) + prettier: + specifier: ^2.7.1 + version: 2.7.1 + rimraf: + specifier: ^5.0.1 + version: 5.0.1 + tsx: + specifier: ^4.19.4 + version: 4.19.4 + typescript: + specifier: ^4.9.5 + version: 4.9.5 packages: @@ -326,6 +330,7 @@ packages: /@humanwhocodes/config-array@0.11.10: resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==} engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead dependencies: '@humanwhocodes/object-schema': 1.2.1 debug: 4.3.4 @@ -341,6 +346,7 @@ packages: /@humanwhocodes/object-schema@1.2.1: resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} + deprecated: Use @eslint/object-schema instead dev: true /@isaacs/cliui@8.0.2: @@ -1039,6 +1045,7 @@ packages: /eslint@8.44.0: resolution: {integrity: sha512-0wpHoUbDUHgNCyvFB5aXLiQVfK9B0at6gUvzy83k4kAsQ/u769TQDX6iKC+aO4upIHO9WSaA3QoXYQDHbNwf1A==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@8.44.0) @@ -1236,6 +1243,7 @@ packages: /glob@10.3.3: resolution: {integrity: sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw==} engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true dependencies: foreground-child: 3.1.1 @@ -1247,6 +1255,7 @@ packages: /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -1259,7 +1268,7 @@ packages: /glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -1323,6 +1332,7 @@ packages: /inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. dependencies: once: 1.4.0 wrappy: 1.0.2 @@ -1640,6 +1650,7 @@ packages: /rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true dependencies: glob: 7.2.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..eaddc21 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - js diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..54f99f4 --- /dev/null +++ b/python/README.md @@ -0,0 +1,47 @@ +# algoux-standard-ranklist-utils + +Python utilities for Standard Ranklist (srk). + +Supported srk versions: `>=0.3.0 <0.4.0`. + +## Install + +```shell +pip install algoux-standard-ranklist-utils +``` + +## Usage Sample + +```python +from standard_ranklist_utils import format_time_duration, resolve_text, sort_rows + +format_time_duration([1.5, "h"], "min") # 90 +resolve_text({"fallback": "English", "zh-CN": "中文"}, ["zh-CN"]) # 中文 +sort_rows(ranklist["rows"], ranklist.get("sorter", {}).get("config")) +``` + +## Utilities + +### formatters + +- `format_time_duration`: Convert an srk `TimeDuration` between `ms`, `s`, `min`, `h`, and `d`. +- `pre_zero_fill`: Left-pad a number with zeroes for fixed-width display. +- `sec_to_time_str`: Format elapsed seconds as a ranklist time string such as `1:02:03` or `1D 1:02:03`. +- `number_to_alphabet`: Convert a zero-based problem index to an alphabetic alias such as `A`, `Z`, or `AA`. +- `alphabet_to_number`: Convert an alphabetic problem alias back to a zero-based index. + +### resolvers + +- `resolve_text`: Resolve plain or i18n srk text using explicit language preferences and fallback text. +- `resolve_contributor`: Parse a contributor string into `name`, optional `email`, and optional `url`. +- `resolve_color`: Normalize an srk color value to a CSS color string. +- `resolve_theme_color`: Expand a single color or theme color object into explicit light and dark colors. +- `resolve_style`: Resolve text/background style colors and auto-pick readable text color when needed. +- `resolve_user_markers`: Resolve a user's marker IDs to marker definitions from the ranklist config. + +### ranklist + +- `sort_rows`: Sort rows by ICPC solved count descending and penalty time ascending, with optional ranking-time precision. +- `regenerate_ranklist_by_solutions`: Rebuild rows, scores, sorting, and problem statistics from solution tetrads. +- `regenerate_rows_by_incremental_solutions`: Apply incremental solution tetrads to existing rows and re-sort them. +- `convert_to_static_ranklist`: Add precomputed per-series rank values and segment indexes to each row. diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..911cd34 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,53 @@ +[build-system] +requires = ["hatchling>=1.25"] +build-backend = "hatchling.build" + +[project] +name = "algoux-standard-ranklist-utils" +version = "0.3.0" +description = "Utilities for Standard Ranklist (srk)." +readme = "README.md" +requires-python = ">=3.9" +license = "MIT" +authors = [{ name = "bLue" }] +keywords = ["standard ranklist", "srk", "ranklist", "icpc"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] + +[project.urls] +Homepage = "https://github.com/algoux/standard-ranklist-utils" +Repository = "https://github.com/algoux/standard-ranklist-utils" +Issues = "https://github.com/algoux/standard-ranklist-utils/issues" + +[project.optional-dependencies] +dev = [ + "build>=1.2", + "pytest>=8.0", + "ruff>=0.5", + "twine>=5.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/standard_ranklist_utils"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] + +[tool.ruff] +line-length = 120 +target-version = "py39" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] diff --git a/python/src/standard_ranklist_utils/__init__.py b/python/src/standard_ranklist_utils/__init__.py new file mode 100644 index 0000000..5ca1803 --- /dev/null +++ b/python/src/standard_ranklist_utils/__init__.py @@ -0,0 +1,72 @@ +from .constants import MIN_REGEN_SUPPORTED_VERSION, SRK_SUPPORTED_VERSIONS +from .enums import EnumTheme +from .formatters import ( + alphabet_to_number, + format_time_duration, + number_to_alphabet, + pre_zero_fill, + sec_to_time_str, +) +from .ranklist import ( + convert_to_static_ranklist, + regenerate_ranklist_by_solutions, + regenerate_rows_by_incremental_solutions, + sort_rows, +) +from .resolvers import ( + resolve_color, + resolve_contributor, + resolve_style, + resolve_text, + resolve_theme_color, + resolve_user_markers, +) +from .types import ( + CalculatedSolutionTetrad, + Color, + I18NStringSet, + Marker, + Ranklist, + RanklistRow, + RankProblemStatus, + RankValue, + Text, + ThemeColor, + ThemeColorInput, + TimeDuration, + TimeUnit, +) + +__all__ = [ + "MIN_REGEN_SUPPORTED_VERSION", + "SRK_SUPPORTED_VERSIONS", + "EnumTheme", + "TimeUnit", + "TimeDuration", + "I18NStringSet", + "Text", + "Color", + "ThemeColorInput", + "ThemeColor", + "RankValue", + "CalculatedSolutionTetrad", + "Ranklist", + "RanklistRow", + "RankProblemStatus", + "Marker", + "format_time_duration", + "pre_zero_fill", + "sec_to_time_str", + "number_to_alphabet", + "alphabet_to_number", + "resolve_text", + "resolve_contributor", + "resolve_color", + "resolve_theme_color", + "resolve_style", + "resolve_user_markers", + "sort_rows", + "regenerate_ranklist_by_solutions", + "regenerate_rows_by_incremental_solutions", + "convert_to_static_ranklist", +] diff --git a/python/src/standard_ranklist_utils/constants.py b/python/src/standard_ranklist_utils/constants.py new file mode 100644 index 0000000..e585884 --- /dev/null +++ b/python/src/standard_ranklist_utils/constants.py @@ -0,0 +1,2 @@ +MIN_REGEN_SUPPORTED_VERSION = "0.3.0" +SRK_SUPPORTED_VERSIONS = ">=0.3.0 <0.4.0" diff --git a/python/src/standard_ranklist_utils/enums.py b/python/src/standard_ranklist_utils/enums.py new file mode 100644 index 0000000..db27a8c --- /dev/null +++ b/python/src/standard_ranklist_utils/enums.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class EnumTheme(str, Enum): + light = "light" + dark = "dark" diff --git a/python/src/standard_ranklist_utils/formatters.py b/python/src/standard_ranklist_utils/formatters.py new file mode 100644 index 0000000..fac34ef --- /dev/null +++ b/python/src/standard_ranklist_utils/formatters.py @@ -0,0 +1,99 @@ +import math +from collections.abc import Sequence +from typing import Callable, Union + +from .types import TimeUnit + +NumberLike = Union[int, float, str] + + +def _to_milliseconds(time: Sequence[Union[int, float, str]]) -> float: + value = time[0] + unit = time[1] + if not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0: + raise ValueError(f"Invalid source time value {value}") + if unit == "ms": + return value + if unit == "s": + return value * 1000 + if unit == "min": + return value * 1000 * 60 + if unit == "h": + return value * 1000 * 60 * 60 + if unit == "d": + return value * 1000 * 60 * 60 * 24 + raise ValueError(f"Invalid source time unit {unit}") + + +def format_time_duration( + time: Sequence[Union[int, float, str]], + target_unit: TimeUnit = "ms", + fmt: Callable[[float], float] = lambda number: number, +) -> float: + ms = _to_milliseconds(time) + if target_unit == "ms": + return ms + if target_unit == "s": + return fmt(ms / 1000) + if target_unit == "min": + return fmt(ms / 1000 / 60) + if target_unit == "h": + return fmt(ms / 1000 / 60 / 60) + if target_unit == "d": + return fmt(ms / 1000 / 60 / 60 / 24) + raise ValueError(f"Invalid target time unit {target_unit}") + + +def pre_zero_fill(num: int, size: int) -> str: + if num >= 10**size: + return str(num) + text = ("0" * size) + str(num) + return text[len(text) - size :] + + +def sec_to_time_str(second: float, *, fill_hour: bool = False, show_day: bool = False) -> str: + if second < 0: + return "--" + sec = second + days = 0 + if show_day: + days = math.floor(sec / 86400) + sec %= 86400 + hours = math.floor(sec / 3600) + sec %= 3600 + minutes = math.floor(sec / 60) + sec %= 60 + seconds = math.floor(sec) + day_text = f"{days}D " if show_day and days >= 1 else "" + hour_text = pre_zero_fill(hours, 2) if fill_hour else str(hours) + return f"{day_text}{hour_text}:{pre_zero_fill(minutes, 2)}:{pre_zero_fill(seconds, 2)}" + + +def number_to_alphabet(number: NumberLike) -> str: + n = int(float(number)) + radix = 26 + count = 1 + power = radix + while n >= power: + n -= power + count += 1 + power *= radix + result = [] + while count > 0: + result.append(chr((n % radix) + 65)) + n = math.trunc(n / radix) + count -= 1 + return "".join(reversed(result)) + + +def alphabet_to_number(alphabet: str) -> int: + if not isinstance(alphabet, str) or not alphabet: + return -1 + chars = list(reversed(alphabet.upper())) + radix = 26 + power = 1 + result = -1 + for char in chars: + result += (ord(char) - 65) * power + power + power *= radix + return result diff --git a/python/src/standard_ranklist_utils/py.typed b/python/src/standard_ranklist_utils/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/python/src/standard_ranklist_utils/py.typed @@ -0,0 +1 @@ + diff --git a/python/src/standard_ranklist_utils/ranklist.py b/python/src/standard_ranklist_utils/ranklist.py new file mode 100644 index 0000000..e1d8f87 --- /dev/null +++ b/python/src/standard_ranklist_utils/ranklist.py @@ -0,0 +1,501 @@ +import copy +import math +import re +from decimal import Decimal +from typing import Any, Callable, Optional + +from .constants import MIN_REGEN_SUPPORTED_VERSION +from .formatters import format_time_duration + +DEFAULT_NO_PENALTY_RESULTS = ["FB", "AC", "?", "NOUT", "CE", "UKE", None] +FILTERABLE_USER_FIELDS = ["id", "name", "organization"] +GROUPABLE_USER_FIELDS = ["id", "name", "organization"] + + +def _js_round(value: float) -> int: + return math.floor(value + 0.5) + + +def _rounding_fn(name: Optional[str]) -> Callable[[float], float]: + if name == "ceil": + return math.ceil + if name == "round": + return _js_round + return math.floor + + +SEMVER_RE = re.compile( + r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" +) + + +def _parse_semver(version: str) -> Optional[tuple[int, int, int, bool]]: + match = SEMVER_RE.match(version or "") + if not match: + return None + return (int(match.group(1)), int(match.group(2)), int(match.group(3)), bool(match.group(4))) + + +def _semver_gte(version: str, minimum: str) -> bool: + parsed_version = _parse_semver(version) + parsed_minimum = _parse_semver(minimum) + if parsed_version is None or parsed_minimum is None: + return False + version_core = parsed_version[:3] + minimum_core = parsed_minimum[:3] + if version_core != minimum_core: + return version_core > minimum_core + if parsed_version[3] and not parsed_minimum[3]: + return False + return True + + +def _user_id(user: dict[str, Any]) -> str: + if user.get("id"): + return str(user["id"]) + name = user.get("name") + if isinstance(name, str): + return name + import json + + return json.dumps(name, ensure_ascii=False, separators=(",", ":")) + + +def _sorter_config(ranklist: dict[str, Any]) -> dict[str, Any]: + config = { + "penalty": [20, "min"], + "noPenaltyResults": DEFAULT_NO_PENALTY_RESULTS.copy(), + "timeRounding": "floor", + } + config.update(copy.deepcopy(ranklist.get("sorter", {}).get("config", {}) or {})) + return config + + +def _supports_regeneration(ranklist: dict[str, Any]) -> bool: + if not _semver_gte(ranklist.get("version", ""), MIN_REGEN_SUPPORTED_VERSION): + return False + return ranklist.get("sorter", {}).get("algorithm") == "ICPC" + + +def sort_rows(rows: list[dict[str, Any]], options: Optional[dict[str, Any]] = None) -> list[dict[str, Any]]: + options = options or {} + ranking_time_precision = options.get("rankingTimePrecision") or "ms" + rounding = _rounding_fn(options.get("rankingTimeRounding")) + + def key(row: dict[str, Any]) -> tuple[float, float]: + score = row["score"] + time = format_time_duration(score["time"], ranking_time_precision, rounding) if score.get("time") else 0 + return (-score["value"], time) + + rows.sort(key=key) + return rows + + +def regenerate_ranklist_by_solutions(original_ranklist: dict[str, Any], solutions: list[list[Any]]) -> dict[str, Any]: + if not _supports_regeneration(original_ranklist): + raise ValueError("The ranklist is not supported to regenerate") + sorter_config = _sorter_config(original_ranklist) + ranklist = {key: copy.deepcopy(value) for key, value in original_ranklist.items() if key != "rows"} + ranklist["rows"] = [] + rows = [] + user_row_map: dict[str, dict[str, Any]] = {} + problem_count = len(ranklist["problems"]) + for row in original_ranklist["rows"]: + user_id = _user_id(row["user"]) + user_row_map[user_id] = { + "user": copy.deepcopy(row["user"]), + "score": {"value": 0}, + "statuses": [{"result": None, "solutions": []} for _ in range(problem_count)], + } + for user_id, problem_index, result, time in solutions: + row = user_row_map.get(user_id) + if not row: + break + row["statuses"][problem_index]["solutions"].append({"result": result, "time": time}) + + problem_accepted_count = [0] * problem_count + problem_submitted_count = [0] * problem_count + for row in user_row_map.values(): + score_value = 0 + total_time_ms = 0 + for index, status in enumerate(row["statuses"]): + for solution in status["solutions"]: + result = solution.get("result") + if not result: + continue + is_no_penalty = result in (sorter_config.get("noPenaltyResults") or []) + if result == "?": + status["result"] = result + if not is_no_penalty: + status["tries"] = (status.get("tries") or 0) + 1 + problem_submitted_count[index] += 1 + continue + if result in ("AC", "FB"): + status["result"] = result + status["time"] = solution["time"] + status["tries"] = (status.get("tries") or 0) + 1 + problem_accepted_count[index] += 1 + problem_submitted_count[index] += 1 + break + if is_no_penalty: + continue + status["result"] = "RJ" + status["tries"] = (status.get("tries") or 0) + 1 + problem_submitted_count[index] += 1 + if status.get("result") in ("AC", "FB"): + target_time = [ + format_time_duration( + status["time"], + sorter_config.get("timePrecision") or "ms", + _rounding_fn(sorter_config.get("timeRounding")), + ), + sorter_config.get("timePrecision") or "ms", + ] + score_value += 1 + total_time_ms += format_time_duration(target_time, "ms") + (status["tries"] - 1) * format_time_duration( + sorter_config["penalty"], "ms" + ) + row["score"] = {"value": score_value, "time": [total_time_ms, "ms"]} + rows.append(row) + ranklist["rows"] = sort_rows( + rows, + { + "rankingTimePrecision": sorter_config.get("rankingTimePrecision"), + "rankingTimeRounding": sorter_config.get("rankingTimeRounding"), + }, + ) + for index, problem in enumerate(ranklist["problems"]): + if not problem.get("statistics"): + problem["statistics"] = {"accepted": 0, "submitted": 0} + problem["statistics"]["accepted"] = problem_accepted_count[index] + problem["statistics"]["submitted"] = problem_submitted_count[index] + return ranklist + + +def regenerate_rows_by_incremental_solutions( + original_ranklist: dict[str, Any], + solutions: list[list[Any]], +) -> list[dict[str, Any]]: + if not _supports_regeneration(original_ranklist): + raise ValueError("The ranklist is not supported to regenerate") + sorter_config = _sorter_config(original_ranklist) + user_row_index_map = {_user_id(row["user"]): index for index, row in enumerate(original_ranklist["rows"])} + rows = [copy.deepcopy(row) for row in original_ranklist["rows"]] + cloned_statuses: set[str] = set() + for user_id, problem_index, result, time in solutions: + row_index = user_row_index_map.get(user_id) + if row_index is None: + break + row = rows[row_index] + status_key = f"{user_id}_{problem_index}" + if status_key not in cloned_statuses: + row["statuses"][problem_index] = copy.deepcopy(row["statuses"][problem_index]) + row["statuses"][problem_index]["solutions"] = list(row["statuses"][problem_index].get("solutions") or []) + cloned_statuses.add(status_key) + status = row["statuses"][problem_index] + status["solutions"].append({"result": result, "time": time}) + if status.get("result") in ("AC", "FB"): + continue + is_no_penalty = result in (sorter_config.get("noPenaltyResults") or []) + if result == "?": + status["result"] = result + if not is_no_penalty: + status["tries"] = (status.get("tries") or 0) + 1 + continue + if result in ("AC", "FB"): + status["result"] = result + status["time"] = time + status["tries"] = (status.get("tries") or 0) + 1 + row["score"]["value"] += 1 + target_time = [ + format_time_duration( + status["time"], + sorter_config.get("timePrecision") or "ms", + _rounding_fn(sorter_config.get("timeRounding")), + ), + sorter_config.get("timePrecision") or "ms", + ] + total_time = format_time_duration(row["score"]["time"], "ms") if row["score"].get("time") else 0 + row["score"]["time"] = [ + total_time + + format_time_duration(target_time, "ms") + + (status["tries"] - 1) * format_time_duration(sorter_config["penalty"], "ms"), + "ms", + ] + continue + if is_no_penalty: + continue + status["result"] = "RJ" + status["tries"] = (status.get("tries") or 0) + 1 + return sort_rows( + rows, + { + "rankingTimePrecision": sorter_config.get("rankingTimePrecision"), + "rankingTimeRounding": sorter_config.get("rankingTimeRounding"), + }, + ) + + +def _compare_score_equal(a: dict[str, Any], b: dict[str, Any], options: dict[str, Any]) -> bool: + if a["value"] != b["value"]: + return False + ranking_time_precision = options.get("rankingTimePrecision") or "ms" + rounding = _rounding_fn(options.get("rankingTimeRounding")) + da = format_time_duration(a["time"], ranking_time_precision, rounding) if a.get("time") else 0 + db = format_time_duration(b["time"], ranking_time_precision, rounding) if b.get("time") else 0 + return da == db + + +def _gen_row_ranks(rows: list[dict[str, Any]], options: dict[str, Any]) -> dict[str, list[Optional[int]]]: + def gen_ranks(current_rows: list[dict[str, Any]]) -> list[int]: + ranks: list[int] = [0] * len(current_rows) + for index, row in enumerate(current_rows): + if index == 0: + ranks[index] = 1 + elif _compare_score_equal(row["score"], current_rows[index - 1]["score"], options): + ranks[index] = ranks[index - 1] + else: + ranks[index] = index + 1 + return ranks + + ranks = gen_ranks(rows) + official_rows = [] + index_back_map = {} + for index, row in enumerate(rows): + if row["user"].get("official") is not False: + index_back_map[index] = len(official_rows) + official_rows.append(row) + official_partial_ranks = gen_ranks(official_rows) + official_ranks = [ + None if index not in index_back_map else official_partial_ranks[index_back_map[index]] + for index in range(len(rows)) + ] + return {"ranks": ranks, "officialRanks": official_ranks} + + +def _stringify(value: Any) -> str: + if isinstance(value, dict): + import json + + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + return str(value) + + +def _gen_series_calc_fns( + series: list[dict[str, Any]], + rows: list[dict[str, Any]], + ranks: list[int], + official_ranks: list[Any], +): + def fallback(_row: dict[str, Any], _index: int) -> dict[str, Any]: + return {"rank": None, "segmentIndex": None} + + fns = [] + for series_config in series: + rule = series_config.get("rule") + if not rule: + fns.append(fallback) + continue + preset = rule.get("preset") + if preset == "Normal": + options = rule.get("options") or {} + + def normal(row, index, options=options): + if options.get("includeOfficialOnly") and row["user"].get("official") is False: + return {"rank": None, "segmentIndex": None} + return { + "rank": official_ranks[index] if options.get("includeOfficialOnly") else ranks[index], + "segmentIndex": None, + } + + fns.append(normal) + continue + if preset == "UniqByUserField": + options = rule.get("options") or {} + field = options.get("field") + assigned = {} + values = set() + last_outer_rank = 0 + last_rank = 0 + for index, row in enumerate(rows): + if options.get("includeOfficialOnly") and row["user"].get("official") is False: + continue + is_valid = field in GROUPABLE_USER_FIELDS + value = _stringify(row["user"].get(field)) + if not is_valid or (value and value not in values): + outer_rank = official_ranks[index] if options.get("includeOfficialOnly") else ranks[index] + if is_valid: + values.add(value) + if outer_rank != last_outer_rank: + last_outer_rank = outer_rank + last_rank = len(assigned) + 1 + assigned[index] = last_rank + assigned[index] = last_rank + + def uniq(_row, index, assigned=assigned): + return {"rank": assigned.get(index), "segmentIndex": None} + + fns.append(uniq) + continue + if preset == "ICPC": + options = rule.get("options") or {} + filtered_rows = [row for row in rows if row["user"].get("official") is not False] + filtered_official_ranks = list(official_ranks) + filters = [] + if options.get("filter"): + filter_options = options["filter"] + for filter_config in filter_options.get("byUserFields") or []: + field = filter_config.get("field") + pattern = filter_config.get("rule") + if field not in FILTERABLE_USER_FIELDS: + continue + try: + regexp = re.compile(pattern) + except re.error: + filters.append(lambda _row: False) + continue + + def test(row, field=field, regexp=regexp): + value = row["user"].get(field) + if value is None: + return False + if isinstance(value, dict): + return any(regexp.search(str(item)) for item in value.values()) + if isinstance(value, list): + return any(regexp.search(str(item)) for item in value) + return regexp.search(str(value)) is not None + + filters.append(test) + if filter_options.get("byMarker"): + marker = filter_options["byMarker"] + + def marker_test(row, marker=marker): + user = row["user"] + if isinstance(user.get("markers"), list): + return marker in user["markers"] + return user.get("marker") == marker + + filters.append(marker_test) + if filters: + current_filtered_rows = [] + filtered_official_ranks = [None] * len(filtered_official_ranks) + current_rank = 0 + current_official_rank = 0 + current_official_rank_old = 0 + for index, row in enumerate(rows): + if all(test(row) for test in filters): + current_filtered_rows.append(row) + old_rank = official_ranks[index] + if old_rank is not None: + current_rank += 1 + if current_official_rank_old != old_rank: + current_official_rank = current_rank + current_official_rank_old = old_rank + filtered_official_ranks[index] = current_official_rank + filtered_rows = [row for row in current_filtered_rows if row["user"].get("official") is not False] + endpoint_rules = [] + no_tied = False + if options.get("ratio"): + ratio = options["ratio"] + denominator = ratio.get("denominator", "all") + if denominator == "submitted": + total = len( + [ + row + for row in filtered_rows + if not all(status.get("result") is None for status in row["statuses"]) + ] + ) + elif denominator == "scored": + total = len([row for row in filtered_rows if row["score"]["value"] > 0]) + else: + total = len(filtered_rows) + acc_values = [] + for index, value in enumerate(ratio["value"]): + current = Decimal(str(value)) + acc_values.append(current if index == 0 else acc_values[index - 1] + current) + rounding = ratio.get("rounding", "ceil") + endpoint_rules.append( + [ + math.floor(float(value * total)) + if rounding == "floor" + else _js_round(float(value * total)) + if rounding == "round" + else math.ceil(float(value * total)) + for value in acc_values + ] + ) + if ratio.get("noTied"): + no_tied = True + if options.get("count"): + acc_values = [] + for index, value in enumerate(options["count"]["value"]): + acc_values.append((acc_values[index - 1] if index > 0 else 0) + value) + endpoint_rules.append(acc_values) + if options["count"].get("noTied"): + no_tied = True + official_ranks_no_tied = [] + current_official_rank = 0 + for rank in filtered_official_ranks: + if rank is None: + official_ranks_no_tied.append(None) + else: + current_official_rank += 1 + official_ranks_no_tied.append(current_official_rank) + filtered_ids = {row["user"].get("id") for row in filtered_rows} + + def icpc( + row, + index, + endpoint_rules=endpoint_rules, + filtered_ids=filtered_ids, + filtered_official_ranks=filtered_official_ranks, + official_ranks_no_tied=official_ranks_no_tied, + no_tied=no_tied, + series_config=series_config, + ): + if row["user"].get("official") is False or row["user"].get("id") not in filtered_ids: + return {"rank": None, "segmentIndex": None} + using_ranks = official_ranks_no_tied if no_tied else filtered_official_ranks + segment_index = None + for seg_index, _segment in enumerate(series_config.get("segments") or []): + rank_for_compare = 0 if using_ranks[index] is None else using_ranks[index] + if all( + seg_index < len(endpoints) and rank_for_compare <= endpoints[seg_index] + for endpoints in endpoint_rules + ): + segment_index = seg_index + break + return {"rank": filtered_official_ranks[index], "segmentIndex": segment_index} + + fns.append(icpc) + continue + fns.append(fallback) + return fns + + +def convert_to_static_ranklist(ranklist: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]: + if not ranklist: + return ranklist + row_ranks = _gen_row_ranks( + ranklist["rows"], + { + "rankingTimePrecision": (ranklist.get("sorter", {}).get("config") or {}).get("rankingTimePrecision"), + "rankingTimeRounding": (ranklist.get("sorter", {}).get("config") or {}).get("rankingTimeRounding"), + }, + ) + series_calc_fns = _gen_series_calc_fns( + ranklist["series"], + ranklist["rows"], + row_ranks["ranks"], + row_ranks["officialRanks"], + ) + result = copy.deepcopy(ranklist) + result["rows"] = [] + for index, row in enumerate(ranklist["rows"]): + copied = copy.deepcopy(row) + copied["rankValues"] = [fn(row, index) for fn in series_calc_fns] + result["rows"].append(copied) + return result diff --git a/python/src/standard_ranklist_utils/resolvers.py b/python/src/standard_ranklist_utils/resolvers.py new file mode 100644 index 0000000..a968ec1 --- /dev/null +++ b/python/src/standard_ranklist_utils/resolvers.py @@ -0,0 +1,132 @@ +import re +from collections.abc import Iterable +from typing import Any, Optional + +from .enums import EnumTheme + + +def resolve_text(text: Any, languages: Optional[Iterable[str]] = None) -> str: + if text is None: + return "" + if isinstance(text, str): + return text + langs = sorted((key for key in text.keys() if key and key != "fallback"), reverse=True) + user_langs = list(languages or []) + using_lang = "" + for lang in user_langs: + if lang in langs: + using_lang = lang + break + if not using_lang: + for lang in user_langs: + primary = lang.split("-")[0] + match = next( + (candidate for candidate in langs if candidate == primary or candidate.startswith(f"{primary}-")), + "", + ) + if match: + using_lang = match + break + if using_lang in text and text[using_lang] is not None: + return text[using_lang] + if text.get("fallback") is not None: + return text["fallback"] + return "" + + +def resolve_contributor(contributor: Optional[str]) -> Optional[dict[str, str]]: + if not contributor: + return None + email = None + url = None + words = [part.strip() for part in contributor.split(" ")] + index = len(words) - 1 + while index > 0: + word = words[index] + if word.startswith("<") and word.endswith(">"): + email = word[1:-1] + index -= 1 + continue + if word.startswith("(") and word.endswith(")"): + url = word[1:-1] + index -= 1 + continue + break + result = {"name": " ".join(words[: index + 1])} + if email is not None: + result["email"] = email + if url is not None: + result["url"] = url + return result + + +def resolve_color(color: Any) -> Optional[str]: + if isinstance(color, list): + return f"rgba({color[0]},{color[1]},{color[2]},{color[3]})" + if color: + return color + return None + + +def resolve_theme_color(theme_color: Any) -> dict[str, Optional[str]]: + if isinstance(theme_color, str): + light = resolve_color(theme_color) + dark = resolve_color(theme_color) + else: + theme_color = theme_color or {} + light = resolve_color(theme_color.get("light")) + dark = resolve_color(theme_color.get("dark")) + return {EnumTheme.light.value: light, EnumTheme.dark.value: dark} + + +def _parse_color_rgb(color: str) -> tuple[float, float, float]: + if color.startswith("#") and len(color) == 4: + return (int(color[1] * 2, 16), int(color[2] * 2, 16), int(color[3] * 2, 16)) + if color.startswith("#") and len(color) == 7: + return (int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16)) + match = re.match(r"rgba?\(([^,]+),([^,]+),([^,\)]+)", color) + if match: + return (float(match.group(1)), float(match.group(2)), float(match.group(3))) + return (255, 255, 255) + + +def _auto_text_color(background_color: str) -> str: + red, green, blue = _parse_color_rgb(background_color) + return "#000000" if 0.213 * red + 0.715 * green + 0.072 * blue > 255 / 2 else "#ffffff" + + +def resolve_style(style: dict[str, Any]) -> dict[str, dict[str, Optional[str]]]: + text_color = style.get("textColor") + background_color = style.get("backgroundColor") + using_text_color = text_color + if background_color and not text_color: + if isinstance(background_color, str): + using_text_color = _auto_text_color(background_color) + else: + using_text_color = { + "light": _auto_text_color(background_color["light"]) if background_color.get("light") else None, + "dark": _auto_text_color(background_color["dark"]) if background_color.get("dark") else None, + } + return { + "textColor": resolve_theme_color(using_text_color or ""), + "backgroundColor": resolve_theme_color(background_color or ""), + } + + +def resolve_user_markers( + user: Optional[dict[str, Any]], + markers_config: Optional[list[dict[str, Any]]], +) -> list[dict[str, Any]]: + if not user: + return [] + if isinstance(user.get("markers"), list): + user_markers = user["markers"] + else: + user_markers = [user.get("marker")] + markers = markers_config or [] + result = [] + for marker_id in filter(None, user_markers): + match = next((marker for marker in markers if marker.get("id") == marker_id), None) + if match: + result.append(match) + return result diff --git a/python/src/standard_ranklist_utils/types.py b/python/src/standard_ranklist_utils/types.py new file mode 100644 index 0000000..a30c880 --- /dev/null +++ b/python/src/standard_ranklist_utils/types.py @@ -0,0 +1,29 @@ +from collections.abc import Sequence +from typing import Any, Literal, Optional, TypedDict, Union + +TimeUnit = Literal["ms", "s", "min", "h", "d"] +TimeDuration = Sequence[Union[float, TimeUnit]] +I18NStringSet = dict[str, str] +Text = Union[str, I18NStringSet] +Color = Union[str, list[float]] +ThemeColorInput = Union[str, dict[str, str]] + + +class ThemeColor(TypedDict): + light: Optional[str] + dark: Optional[str] + + +class _RankValueRequired(TypedDict): + rank: Optional[int] + + +class RankValue(_RankValueRequired, total=False): + segmentIndex: Optional[int] + + +CalculatedSolutionTetrad = list[Any] +Ranklist = dict[str, Any] +RanklistRow = dict[str, Any] +RankProblemStatus = dict[str, Any] +Marker = dict[str, Any] diff --git a/python/tests/fixtures/contract-fixtures.json b/python/tests/fixtures/contract-fixtures.json new file mode 100644 index 0000000..9f962dd --- /dev/null +++ b/python/tests/fixtures/contract-fixtures.json @@ -0,0 +1,948 @@ +{ + "constants": { + "minRegenSupportedVersion": "0.3.0", + "srkSupportedVersions": ">=0.3.0 <0.4.0", + "enumTheme": { + "light": "light", + "dark": "dark" + } + }, + "formatters": { + "formatTimeDuration": { + "hoursToMinutes": 90, + "secondsToMinutesCeil": 2, + "secondsToMillisecondsIgnoresFormatter": 2000 + }, + "preZeroFill": { + "short": "007", + "long": "1234" + }, + "secToTimeStr": { + "fillHour": "01:01:01", + "showDay": "1D 1:01:01", + "negative": "--" + }, + "alphabet": { + "zero": "A", + "z": "Z", + "aa": "AA", + "acFromString": "AC", + "zz": "ZZ", + "aaa": "AAA", + "numberA": 0, + "numberAA": 26, + "numberLowerAc": 28, + "numberEmpty": -1 + } + }, + "resolvers": { + "text": { + "undefined": "", + "plain": "plain", + "zhCN": "中文", + "enGB": "English", + "zhHansCN": "中文", + "fallback": "Fallback", + "emptyMatch": "" + }, + "contributor": { + "missing": null, + "nameOnly": { + "name": "Alice" + }, + "nameEmail": { + "name": "Bob", + "email": "bob@example.com" + }, + "full": { + "name": "bLue", + "email": "mail@example.com", + "url": "https://example.com/" + }, + "nameUrl": { + "name": "John Smith", + "url": "https://example.com/" + } + }, + "color": { + "string": "#123456", + "rgbaTuple": "rgba(1,2,3,0.5)" + }, + "themeColor": { + "single": { + "light": "#abcdef", + "dark": "#abcdef" + }, + "pair": { + "light": "#ffffff", + "dark": "#000000" + } + }, + "style": { + "explicit": { + "textColor": { + "light": "#111111", + "dark": "#111111" + }, + "backgroundColor": { + "light": "#eeeeee", + "dark": "#eeeeee" + } + }, + "auto": { + "textColor": { + "light": "#000000", + "dark": "#ffffff" + }, + "backgroundColor": { + "light": "#ffffff", + "dark": "#000000" + } + }, + "autoGreen": { + "textColor": { + "light": "#000000", + "dark": "#000000" + }, + "backgroundColor": { + "light": "#00c000", + "dark": "#00c000" + } + }, + "autoShortHex": { + "textColor": { + "light": "#000000", + "dark": "#000000" + }, + "backgroundColor": { + "light": "#0c0", + "dark": "#0c0" + } + } + }, + "markers": { + "modernPrecedence": [ + { + "id": "girls", + "label": "Girls", + "style": "pink" + } + ], + "emptyModern": [], + "legacy": [ + { + "id": "official", + "label": "Official", + "style": "blue" + } + ], + "missingConfig": [] + } + }, + "ranklist": { + "sortedRows": [ + "solved-more", + "fast", + "slow" + ], + "regenerated": { + "type": "general", + "version": "0.3.9", + "contest": { + "title": "Contest", + "startAt": "2026-01-01T00:00:00+08:00", + "duration": [ + 5, + "h" + ] + }, + "problems": [ + { + "alias": "A", + "statistics": { + "accepted": 3, + "submitted": 4 + } + }, + { + "alias": "B", + "statistics": { + "accepted": 1, + "submitted": 2 + } + } + ], + "series": [ + { + "title": "Rank", + "rule": { + "preset": "Normal" + } + } + ], + "sorter": { + "algorithm": "ICPC", + "config": {} + }, + "rows": [ + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 2, + "time": [ + 11400000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "WA", + "time": [ + 10, + "min" + ] + }, + { + "result": "CE", + "time": [ + 15, + "min" + ] + }, + { + "result": "AC", + "time": [ + 50, + "min" + ] + } + ], + "tries": 2, + "time": [ + 50, + "min" + ] + }, + { + "result": "AC", + "solutions": [ + { + "result": "AC", + "time": [ + 120, + "min" + ] + } + ], + "time": [ + 120, + "min" + ], + "tries": 1 + } + ] + }, + { + "user": { + "id": "u3", + "name": "u3", + "official": false + }, + "score": { + "value": 1, + "time": [ + 1200000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "AC", + "time": [ + 20, + "min" + ] + } + ], + "time": [ + 20, + "min" + ], + "tries": 1 + }, + { + "result": null, + "solutions": [] + } + ] + }, + { + "user": { + "id": "u2", + "name": "u2" + }, + "score": { + "value": 1, + "time": [ + 1800000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "AC", + "time": [ + 30, + "min" + ] + } + ], + "time": [ + 30, + "min" + ], + "tries": 1 + }, + { + "result": "RJ", + "solutions": [ + { + "result": "WA", + "time": [ + 100, + "min" + ] + } + ], + "tries": 1 + } + ] + } + ] + }, + "defaultNoPenalty": { + "type": "general", + "version": "0.3.9", + "contest": { + "title": "Contest", + "startAt": "2026-01-01T00:00:00+08:00", + "duration": [ + 5, + "h" + ] + }, + "problems": [ + { + "alias": "A", + "statistics": { + "accepted": 1, + "submitted": 3 + } + } + ], + "series": [ + { + "title": "Rank", + "rule": { + "preset": "Normal" + } + } + ], + "sorter": { + "algorithm": "ICPC", + "config": {} + }, + "rows": [ + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 1, + "time": [ + 4200000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "WA", + "time": [ + 10, + "min" + ] + }, + { + "result": "CE", + "time": [ + 15, + "min" + ] + }, + { + "result": "WA", + "time": [ + 20, + "min" + ] + }, + { + "result": "?", + "time": [ + 25, + "min" + ] + }, + { + "result": "AC", + "time": [ + 30, + "min" + ] + } + ], + "tries": 3, + "time": [ + 30, + "min" + ] + } + ] + } + ] + }, + "customNoPenalty": { + "type": "general", + "version": "0.3.9", + "contest": { + "title": "Contest", + "startAt": "2026-01-01T00:00:00+08:00", + "duration": [ + 5, + "h" + ] + }, + "problems": [ + { + "alias": "A", + "statistics": { + "accepted": 1, + "submitted": 2 + } + } + ], + "series": [ + { + "title": "Rank", + "rule": { + "preset": "Normal" + } + } + ], + "sorter": { + "algorithm": "ICPC", + "config": { + "noPenaltyResults": [ + "FB", + "AC", + "?", + "NOUT", + "UKE", + null + ] + } + }, + "rows": [ + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 1, + "time": [ + 3000000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "CE", + "time": [ + 10, + "min" + ] + }, + { + "result": "AC", + "time": [ + 30, + "min" + ] + } + ], + "tries": 2, + "time": [ + 30, + "min" + ] + } + ] + } + ] + }, + "postAc": { + "type": "general", + "version": "0.3.9", + "contest": { + "title": "Contest", + "startAt": "2026-01-01T00:00:00+08:00", + "duration": [ + 5, + "h" + ] + }, + "problems": [ + { + "alias": "A", + "statistics": { + "accepted": 1, + "submitted": 2 + } + } + ], + "series": [ + { + "title": "Rank", + "rule": { + "preset": "Normal" + } + } + ], + "sorter": { + "algorithm": "ICPC", + "config": {} + }, + "rows": [ + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 1, + "time": [ + 2400000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "WA", + "time": [ + 10, + "min" + ] + }, + { + "result": "AC", + "time": [ + 20, + "min" + ] + }, + { + "result": "WA", + "time": [ + 30, + "min" + ] + }, + { + "result": "FB", + "time": [ + 40, + "min" + ] + } + ], + "tries": 2, + "time": [ + 20, + "min" + ] + } + ] + } + ] + }, + "timePrecision": { + "type": "general", + "version": "0.3.9", + "contest": { + "title": "Contest", + "startAt": "2026-01-01T00:00:00+08:00", + "duration": [ + 5, + "h" + ] + }, + "problems": [ + { + "alias": "A", + "statistics": { + "accepted": 1, + "submitted": 1 + } + } + ], + "series": [ + { + "title": "Rank", + "rule": { + "preset": "Normal" + } + } + ], + "sorter": { + "algorithm": "ICPC", + "config": { + "timePrecision": "min", + "timeRounding": "ceil" + } + }, + "rows": [ + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 1, + "time": [ + 180000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "AC", + "time": [ + 125, + "s" + ] + } + ], + "time": [ + 125, + "s" + ], + "tries": 1 + } + ] + } + ] + }, + "rankingPrecisionOrder": [ + "slow-original-first", + "fast-original-second" + ], + "incrementalRows": [ + { + "user": { + "id": "u2", + "name": "u2" + }, + "score": { + "value": 1, + "time": [ + 1200000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "AC", + "time": [ + 20, + "min" + ] + } + ], + "time": [ + 20, + "min" + ], + "tries": 1 + } + ] + }, + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 1, + "time": [ + 3300000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "WA", + "time": [ + 10, + "min" + ] + }, + { + "result": "CE", + "time": [ + 15, + "min" + ] + }, + { + "result": "AC", + "time": [ + 35, + "min" + ] + } + ], + "tries": 2, + "time": [ + 35, + "min" + ] + } + ] + } + ], + "incrementalPostAcRows": [ + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 1, + "time": [ + 20, + "min" + ] + }, + "statuses": [ + { + "result": "AC", + "time": [ + 20, + "min" + ], + "tries": 1, + "solutions": [ + { + "result": "AC", + "time": [ + 20, + "min" + ] + }, + { + "result": "WA", + "time": [ + 30, + "min" + ] + }, + { + "result": "AC", + "time": [ + 40, + "min" + ] + } + ] + } + ] + } + ], + "staticRankValues": [ + [ + { + "rank": 1, + "segmentIndex": null + }, + { + "rank": 1, + "segmentIndex": null + }, + { + "rank": 1, + "segmentIndex": null + }, + { + "rank": 1, + "segmentIndex": 0 + } + ], + [ + { + "rank": 1, + "segmentIndex": null + }, + { + "rank": 1, + "segmentIndex": null + }, + { + "rank": null, + "segmentIndex": null + }, + { + "rank": 1, + "segmentIndex": 1 + } + ], + [ + { + "rank": 3, + "segmentIndex": null + }, + { + "rank": null, + "segmentIndex": null + }, + { + "rank": 2, + "segmentIndex": null + }, + { + "rank": null, + "segmentIndex": null + } + ], + [ + { + "rank": 4, + "segmentIndex": null + }, + { + "rank": 3, + "segmentIndex": null + }, + { + "rank": null, + "segmentIndex": null + }, + { + "rank": 3, + "segmentIndex": null + } + ] + ], + "markerRankValues": [ + { + "rank": 1, + "segmentIndex": 0 + }, + { + "rank": null, + "segmentIndex": null + }, + { + "rank": 2, + "segmentIndex": 1 + } + ], + "invalidFilterRankValue": { + "rank": null, + "segmentIndex": null + }, + "ratioRankValues": [ + { + "rank": 1, + "segmentIndex": 0 + }, + { + "rank": 2, + "segmentIndex": 1 + }, + { + "rank": 3, + "segmentIndex": 1 + }, + { + "rank": 4, + "segmentIndex": null + }, + { + "rank": 5, + "segmentIndex": null + }, + { + "rank": 6, + "segmentIndex": null + }, + { + "rank": 7, + "segmentIndex": null + }, + { + "rank": 8, + "segmentIndex": null + }, + { + "rank": 9, + "segmentIndex": null + }, + { + "rank": 10, + "segmentIndex": null + } + ], + "strictIdRankValues": [ + { + "rank": 1, + "segmentIndex": 0 + }, + { + "rank": null, + "segmentIndex": 0 + } + ] + } +} diff --git a/python/tests/test_contract.py b/python/tests/test_contract.py new file mode 100644 index 0000000..d177be1 --- /dev/null +++ b/python/tests/test_contract.py @@ -0,0 +1,406 @@ +import json +import math +from pathlib import Path + +import pytest + +from standard_ranklist_utils import ( + EnumTheme, + alphabet_to_number, + convert_to_static_ranklist, + format_time_duration, + number_to_alphabet, + pre_zero_fill, + regenerate_ranklist_by_solutions, + regenerate_rows_by_incremental_solutions, + resolve_color, + resolve_contributor, + resolve_style, + resolve_text, + resolve_theme_color, + resolve_user_markers, + sec_to_time_str, + sort_rows, +) +from standard_ranklist_utils.constants import MIN_REGEN_SUPPORTED_VERSION, SRK_SUPPORTED_VERSIONS + +FIXTURES = json.loads((Path(__file__).parent / "fixtures" / "contract-fixtures.json").read_text(encoding="utf-8")) + + +def make_ranklist(**overrides): + ranklist = { + "type": "general", + "version": "0.3.9", + "contest": { + "title": "Contest", + "startAt": "2026-01-01T00:00:00+08:00", + "duration": [5, "h"], + }, + "problems": [{"alias": "A"}, {"alias": "B"}], + "series": [{"title": "Rank", "rule": {"preset": "Normal"}}], + "rows": [], + "sorter": { + "algorithm": "ICPC", + "config": {}, + }, + } + ranklist.update(overrides) + return ranklist + + +def make_row(user_id, score=None, statuses=None, user=None): + return { + "user": {"id": user_id, "name": user_id, **(user or {})}, + "score": score if score is not None else {"value": 0, "time": [0, "ms"]}, + "statuses": statuses + if statuses is not None + else [{"result": None, "solutions": []}, {"result": None, "solutions": []}], + } + + +def test_constants_and_enums_match_js_contract(): + assert MIN_REGEN_SUPPORTED_VERSION == FIXTURES["constants"]["minRegenSupportedVersion"] + assert SRK_SUPPORTED_VERSIONS == FIXTURES["constants"]["srkSupportedVersions"] + assert EnumTheme.light.value == FIXTURES["constants"]["enumTheme"]["light"] + assert EnumTheme.dark.value == FIXTURES["constants"]["enumTheme"]["dark"] + + +def test_formatters_match_js_contract(): + expected = FIXTURES["formatters"] + + assert format_time_duration([1.5, "h"], "min") == expected["formatTimeDuration"]["hoursToMinutes"] + assert format_time_duration([61, "s"], "min", math.ceil) == expected["formatTimeDuration"]["secondsToMinutesCeil"] + assert ( + format_time_duration([2, "s"], "ms", lambda _: 0) + == expected["formatTimeDuration"]["secondsToMillisecondsIgnoresFormatter"] + ) + + with pytest.raises(ValueError): + format_time_duration([-1, "s"]) + with pytest.raises(ValueError): + format_time_duration([math.inf, "s"]) + with pytest.raises(ValueError): + format_time_duration([1, "week"]) + with pytest.raises(ValueError): + format_time_duration([1, "s"], "week") + + assert pre_zero_fill(7, 3) == expected["preZeroFill"]["short"] + assert pre_zero_fill(1234, 3) == expected["preZeroFill"]["long"] + assert sec_to_time_str(3661, fill_hour=True) == expected["secToTimeStr"]["fillHour"] + assert sec_to_time_str(90061, show_day=True) == expected["secToTimeStr"]["showDay"] + assert sec_to_time_str(-1) == expected["secToTimeStr"]["negative"] + assert number_to_alphabet(0) == expected["alphabet"]["zero"] + assert number_to_alphabet(25) == expected["alphabet"]["z"] + assert number_to_alphabet(26) == expected["alphabet"]["aa"] + assert number_to_alphabet("28") == expected["alphabet"]["acFromString"] + assert number_to_alphabet(701) == expected["alphabet"]["zz"] + assert number_to_alphabet(702) == expected["alphabet"]["aaa"] + assert alphabet_to_number("A") == expected["alphabet"]["numberA"] + assert alphabet_to_number("AA") == expected["alphabet"]["numberAA"] + assert alphabet_to_number("ac") == expected["alphabet"]["numberLowerAc"] + assert alphabet_to_number("") == expected["alphabet"]["numberEmpty"] + + +def test_resolvers_match_js_contract(): + expected = FIXTURES["resolvers"] + + assert resolve_text(None) == expected["text"]["undefined"] + assert resolve_text("plain") == expected["text"]["plain"] + assert ( + resolve_text({"fallback": "Fallback", "en-US": "English", "zh-CN": "中文"}, ["zh-CN"]) + == expected["text"]["zhCN"] + ) + assert ( + resolve_text({"fallback": "Fallback", "en-US": "English", "zh-CN": "中文"}, ["en-GB"]) + == expected["text"]["enGB"] + ) + assert resolve_text({"fallback": "Fallback", "zh-CN": "中文"}, ["zh-Hans-CN"]) == expected["text"]["zhHansCN"] + assert resolve_text({"fallback": "Fallback", "en-US": "English"}, ["fr-FR"]) == expected["text"]["fallback"] + assert resolve_text({"fallback": "Fallback", "en-US": ""}, ["en-US"]) == expected["text"]["emptyMatch"] + + assert resolve_contributor(None) == expected["contributor"]["missing"] + assert resolve_contributor("Alice") == expected["contributor"]["nameOnly"] + assert resolve_contributor("Bob ") == expected["contributor"]["nameEmail"] + assert resolve_contributor("bLue (https://example.com/)") == expected["contributor"]["full"] + assert resolve_contributor("John Smith (https://example.com/)") == expected["contributor"]["nameUrl"] + + assert resolve_color("#123456") == expected["color"]["string"] + assert resolve_color("") is None + assert resolve_color([1, 2, 3, 0.5]) == expected["color"]["rgbaTuple"] + assert resolve_theme_color("#abcdef") == expected["themeColor"]["single"] + assert resolve_theme_color({"light": "#ffffff", "dark": "#000000"}) == expected["themeColor"]["pair"] + assert resolve_style({"textColor": "#111111", "backgroundColor": "#eeeeee"}) == expected["style"]["explicit"] + assert resolve_style({"backgroundColor": {"light": "#ffffff", "dark": "#000000"}}) == expected["style"]["auto"] + assert resolve_style({"backgroundColor": "#00c000"}) == expected["style"]["autoGreen"] + assert resolve_style({"backgroundColor": "#0c0"}) == expected["style"]["autoShortHex"] + + markers = [ + {"id": "official", "label": "Official", "style": "blue"}, + {"id": "girls", "label": "Girls", "style": "pink"}, + ] + assert ( + resolve_user_markers({"id": "u1", "name": "U1", "marker": "official", "markers": ["girls", "none"]}, markers) + == expected["markers"]["modernPrecedence"] + ) + assert ( + resolve_user_markers({"id": "u2", "name": "U2", "marker": "official", "markers": []}, markers) + == expected["markers"]["emptyModern"] + ) + assert ( + resolve_user_markers({"id": "u2", "name": "U2", "marker": "official"}, markers) == expected["markers"]["legacy"] + ) + assert ( + resolve_user_markers({"id": "u3", "name": "U3", "markers": ["girls"]}, None) + == expected["markers"]["missingConfig"] + ) + + +def test_ranklist_helpers_match_js_contract(): + expected = FIXTURES["ranklist"] + + sorted_rows = sort_rows( + [ + make_row("slow", {"value": 1, "time": [30, "min"]}), + make_row("fast", {"value": 1, "time": [20, "min"]}), + make_row("solved-more", {"value": 2, "time": [90, "min"]}), + ] + ) + assert [row["user"]["id"] for row in sorted_rows] == expected["sortedRows"] + + +def test_ranklist_regeneration_matches_js_contract(): + expected = FIXTURES["ranklist"] + original = make_ranklist( + rows=[ + make_row("u1"), + make_row("u2"), + make_row("u3", {"value": 0, "time": [0, "ms"]}, user={"official": False}), + ], + problems=[{"alias": "A", "statistics": {"accepted": 0, "submitted": 0}}, {"alias": "B"}], + ) + solutions = [ + ["u1", 0, "WA", [10, "min"]], + ["u1", 0, "CE", [15, "min"]], + ["u3", 0, "AC", [20, "min"]], + ["u2", 0, "AC", [30, "min"]], + ["u1", 0, "AC", [50, "min"]], + ["u2", 1, "WA", [100, "min"]], + ["u1", 1, "AC", [120, "min"]], + ] + assert regenerate_ranklist_by_solutions(original, solutions) == expected["regenerated"] + + default_no_penalty = make_ranklist( + problems=[{"alias": "A"}], + rows=[make_row("u1", {"value": 0, "time": [0, "ms"]}, [{"result": None, "solutions": []}])], + ) + assert ( + regenerate_ranklist_by_solutions( + default_no_penalty, + [ + ["u1", 0, "WA", [10, "min"]], + ["u1", 0, "CE", [15, "min"]], + ["u1", 0, "WA", [20, "min"]], + ["u1", 0, "?", [25, "min"]], + ["u1", 0, "AC", [30, "min"]], + ], + ) + == expected["defaultNoPenalty"] + ) + + custom_no_penalty = make_ranklist( + problems=[{"alias": "A"}], + rows=[make_row("u1", {"value": 0, "time": [0, "ms"]}, [{"result": None, "solutions": []}])], + sorter={"algorithm": "ICPC", "config": {"noPenaltyResults": ["FB", "AC", "?", "NOUT", "UKE", None]}}, + ) + assert ( + regenerate_ranklist_by_solutions( + custom_no_penalty, [["u1", 0, "CE", [10, "min"]], ["u1", 0, "AC", [30, "min"]]] + ) + == expected["customNoPenalty"] + ) + + post_ac = make_ranklist( + problems=[{"alias": "A"}], + rows=[make_row("u1", {"value": 0, "time": [0, "ms"]}, [{"result": None, "solutions": []}])], + ) + assert ( + regenerate_ranklist_by_solutions( + post_ac, + [ + ["u1", 0, "WA", [10, "min"]], + ["u1", 0, "AC", [20, "min"]], + ["u1", 0, "WA", [30, "min"]], + ["u1", 0, "FB", [40, "min"]], + ], + ) + == expected["postAc"] + ) + + +def test_ranklist_precision_incremental_and_static_match_js_contract(): + expected = FIXTURES["ranklist"] + + assert ( + regenerate_ranklist_by_solutions( + make_ranklist( + problems=[{"alias": "A"}], + rows=[make_row("u1", {"value": 0, "time": [0, "ms"]}, [{"result": None, "solutions": []}])], + sorter={"algorithm": "ICPC", "config": {"timePrecision": "min", "timeRounding": "ceil"}}, + ), + [["u1", 0, "AC", [125, "s"]]], + ) + == expected["timePrecision"] + ) + + ranking_precision = regenerate_ranklist_by_solutions( + make_ranklist( + problems=[{"alias": "A"}], + rows=[ + make_row("slow-original-first", {"value": 0, "time": [0, "ms"]}, [{"result": None, "solutions": []}]), + make_row("fast-original-second", {"value": 0, "time": [0, "ms"]}, [{"result": None, "solutions": []}]), + ], + sorter={"algorithm": "ICPC", "config": {"rankingTimePrecision": "h", "rankingTimeRounding": "floor"}}, + ), + [["slow-original-first", 0, "AC", [359, "min"]], ["fast-original-second", 0, "AC", [301, "min"]]], + ) + assert [row["user"]["id"] for row in ranking_precision["rows"]] == expected["rankingPrecisionOrder"] + + incremental = make_ranklist( + problems=[{"alias": "A"}], + rows=[ + make_row("u1", {"value": 0, "time": [0, "ms"]}, [{"result": None, "solutions": []}]), + make_row("u2", {"value": 0, "time": [0, "ms"]}, [{"result": None, "solutions": []}]), + ], + ) + assert ( + regenerate_rows_by_incremental_solutions( + incremental, + [ + ["u1", 0, "WA", [10, "min"]], + ["u1", 0, "CE", [15, "min"]], + ["u2", 0, "AC", [20, "min"]], + ["u1", 0, "AC", [35, "min"]], + ], + ) + == expected["incrementalRows"] + ) + + incremental_post_ac = make_ranklist( + problems=[{"alias": "A"}], + rows=[ + make_row( + "u1", + {"value": 1, "time": [20, "min"]}, + [ + { + "result": "AC", + "time": [20, "min"], + "tries": 1, + "solutions": [{"result": "AC", "time": [20, "min"]}], + } + ], + ) + ], + ) + assert ( + regenerate_rows_by_incremental_solutions( + incremental_post_ac, + [["u1", 0, "WA", [30, "min"]], ["u1", 0, "AC", [40, "min"]]], + ) + == expected["incrementalPostAcRows"] + ) + + static_ranklist = make_ranklist( + series=[ + {"title": "Overall", "rule": {"preset": "Normal"}}, + {"title": "Official", "rule": {"preset": "Normal", "options": {"includeOfficialOnly": True}}}, + {"title": "School", "rule": {"preset": "UniqByUserField", "options": {"field": "organization"}}}, + { + "title": "Medals", + "segments": [{"title": "Gold"}, {"title": "Silver"}], + "rule": {"preset": "ICPC", "options": {"count": {"value": [1, 1], "noTied": True}}}, + }, + ], + rows=[ + make_row("u1", {"value": 2, "time": [100, "min"]}, user={"organization": "School A"}), + make_row("u2", {"value": 2, "time": [100, "min"]}, user={"organization": "School A"}), + make_row("u3", {"value": 1, "time": [50, "min"]}, user={"organization": "School B", "official": False}), + make_row("u4", {"value": 1, "time": [60, "min"]}, user={"organization": "School B"}), + ], + ) + assert [row["rankValues"] for row in convert_to_static_ranklist(static_ranklist)["rows"]] == expected[ + "staticRankValues" + ] + + marker_ranklist = make_ranklist( + series=[ + { + "title": "Girls", + "segments": [{"title": "Gold"}, {"title": "Silver"}], + "rule": {"preset": "ICPC", "options": {"filter": {"byMarker": "girls"}, "count": {"value": [1, 1]}}}, + } + ], + rows=[ + make_row("modern-marker", {"value": 3, "time": [10, "min"]}, user={"markers": ["girls"]}), + make_row( + "empty-modern-marker", + {"value": 2, "time": [20, "min"]}, + user={"marker": "girls", "markers": []}, + ), + make_row("legacy-marker", {"value": 1, "time": [30, "min"]}, user={"marker": "girls"}), + ], + ) + assert [row["rankValues"][0] for row in convert_to_static_ranklist(marker_ranklist)["rows"]] == expected[ + "markerRankValues" + ] + + invalid_filter = make_ranklist( + series=[ + { + "title": "Invalid filter", + "segments": [{"title": "Gold"}], + "rule": { + "preset": "ICPC", + "options": { + "filter": {"byUserFields": [{"field": "organization", "rule": "("}]}, + "count": {"value": [1]}, + }, + }, + } + ], + rows=[make_row("u1", {"value": 1, "time": [10, "min"]}, user={"organization": "SDUT"})], + ) + assert convert_to_static_ranklist(invalid_filter)["rows"][0]["rankValues"][0] == expected["invalidFilterRankValue"] + + ratio_ranklist = make_ranklist( + series=[ + { + "title": "Ratio", + "segments": [{"title": "A"}, {"title": "B"}], + "rule": {"preset": "ICPC", "options": {"ratio": {"value": [0.1, 0.2], "rounding": "ceil"}}}, + } + ], + rows=[make_row(f"ratio-u{index + 1}", {"value": 10 - index, "time": [index, "min"]}) for index in range(10)], + ) + assert [row["rankValues"][0] for row in convert_to_static_ranklist(ratio_ranklist)["rows"]] == expected[ + "ratioRankValues" + ] + + strict_id_ranklist = make_ranklist( + series=[ + { + "title": "Strict ID", + "segments": [{"title": "Only"}], + "rule": {"preset": "ICPC", "options": {"filter": {"byMarker": "girls"}, "count": {"value": [2]}}}, + } + ], + rows=[ + make_row( + "fallback-a", {"value": 2, "time": [10, "min"]}, user={"id": None, "name": "No ID A", "marker": "girls"} + ), + make_row("fallback-b", {"value": 1, "time": [20, "min"]}, user={"id": None, "name": "No ID B"}), + ], + ) + assert [row["rankValues"][0] for row in convert_to_static_ranklist(strict_id_ranklist)["rows"]] == expected[ + "strictIdRankValues" + ] diff --git a/scripts/check-fixtures.mjs b/scripts/check-fixtures.mjs new file mode 100644 index 0000000..5b95443 --- /dev/null +++ b/scripts/check-fixtures.mjs @@ -0,0 +1,18 @@ +import { readFileSync } from 'node:fs'; + +const source = readFileSync('testdata/contract-fixtures.json', 'utf8'); +const targets = [ + 'python/tests/fixtures/contract-fixtures.json', + 'go/testdata/fixtures/contract-fixtures.json', +]; + +const mismatches = targets.filter((target) => readFileSync(target, 'utf8') !== source); + +if (mismatches.length) { + console.error('Contract fixtures are out of sync:'); + for (const mismatch of mismatches) { + console.error(`- ${mismatch}`); + } + console.error('Run: pnpm run sync:fixtures'); + process.exit(1); +} diff --git a/scripts/prevent-root-pack.mjs b/scripts/prevent-root-pack.mjs new file mode 100644 index 0000000..c949362 --- /dev/null +++ b/scripts/prevent-root-pack.mjs @@ -0,0 +1,2 @@ +console.error('This repository root is a private monorepo package. Run npm pack from ./js instead.'); +process.exit(1); diff --git a/scripts/run-go-tests.mjs b/scripts/run-go-tests.mjs new file mode 100644 index 0000000..67e1a6d --- /dev/null +++ b/scripts/run-go-tests.mjs @@ -0,0 +1,19 @@ +import { mkdirSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const cwd = resolve('go'); +const goCache = resolve('go', '.gocache'); +mkdirSync(goCache, { recursive: true }); + +const result = spawnSync('go', ['test', './...'], { + cwd, + env: { + ...process.env, + GOCACHE: goCache, + }, + stdio: 'inherit', + shell: false, +}); + +process.exit(result.status ?? 1); diff --git a/scripts/run-python-tests.mjs b/scripts/run-python-tests.mjs new file mode 100644 index 0000000..8882da5 --- /dev/null +++ b/scripts/run-python-tests.mjs @@ -0,0 +1,15 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const venvPython = process.platform === 'win32' + ? join('python', '.venv', 'Scripts', 'python.exe') + : join('python', '.venv', 'bin', 'python'); + +const python = existsSync(venvPython) ? venvPython : process.platform === 'win32' ? 'python' : 'python3'; +const result = spawnSync(python, ['-m', 'pytest', 'python/tests'], { + stdio: 'inherit', + shell: false, +}); + +process.exit(result.status ?? 1); diff --git a/scripts/sync-fixtures.mjs b/scripts/sync-fixtures.mjs new file mode 100644 index 0000000..1fb21c4 --- /dev/null +++ b/scripts/sync-fixtures.mjs @@ -0,0 +1,11 @@ +import { copyFileSync } from 'node:fs'; + +const source = 'testdata/contract-fixtures.json'; +const targets = [ + 'python/tests/fixtures/contract-fixtures.json', + 'go/testdata/fixtures/contract-fixtures.json', +]; + +for (const target of targets) { + copyFileSync(source, target); +} diff --git a/testdata/contract-fixtures.json b/testdata/contract-fixtures.json new file mode 100644 index 0000000..9f962dd --- /dev/null +++ b/testdata/contract-fixtures.json @@ -0,0 +1,948 @@ +{ + "constants": { + "minRegenSupportedVersion": "0.3.0", + "srkSupportedVersions": ">=0.3.0 <0.4.0", + "enumTheme": { + "light": "light", + "dark": "dark" + } + }, + "formatters": { + "formatTimeDuration": { + "hoursToMinutes": 90, + "secondsToMinutesCeil": 2, + "secondsToMillisecondsIgnoresFormatter": 2000 + }, + "preZeroFill": { + "short": "007", + "long": "1234" + }, + "secToTimeStr": { + "fillHour": "01:01:01", + "showDay": "1D 1:01:01", + "negative": "--" + }, + "alphabet": { + "zero": "A", + "z": "Z", + "aa": "AA", + "acFromString": "AC", + "zz": "ZZ", + "aaa": "AAA", + "numberA": 0, + "numberAA": 26, + "numberLowerAc": 28, + "numberEmpty": -1 + } + }, + "resolvers": { + "text": { + "undefined": "", + "plain": "plain", + "zhCN": "中文", + "enGB": "English", + "zhHansCN": "中文", + "fallback": "Fallback", + "emptyMatch": "" + }, + "contributor": { + "missing": null, + "nameOnly": { + "name": "Alice" + }, + "nameEmail": { + "name": "Bob", + "email": "bob@example.com" + }, + "full": { + "name": "bLue", + "email": "mail@example.com", + "url": "https://example.com/" + }, + "nameUrl": { + "name": "John Smith", + "url": "https://example.com/" + } + }, + "color": { + "string": "#123456", + "rgbaTuple": "rgba(1,2,3,0.5)" + }, + "themeColor": { + "single": { + "light": "#abcdef", + "dark": "#abcdef" + }, + "pair": { + "light": "#ffffff", + "dark": "#000000" + } + }, + "style": { + "explicit": { + "textColor": { + "light": "#111111", + "dark": "#111111" + }, + "backgroundColor": { + "light": "#eeeeee", + "dark": "#eeeeee" + } + }, + "auto": { + "textColor": { + "light": "#000000", + "dark": "#ffffff" + }, + "backgroundColor": { + "light": "#ffffff", + "dark": "#000000" + } + }, + "autoGreen": { + "textColor": { + "light": "#000000", + "dark": "#000000" + }, + "backgroundColor": { + "light": "#00c000", + "dark": "#00c000" + } + }, + "autoShortHex": { + "textColor": { + "light": "#000000", + "dark": "#000000" + }, + "backgroundColor": { + "light": "#0c0", + "dark": "#0c0" + } + } + }, + "markers": { + "modernPrecedence": [ + { + "id": "girls", + "label": "Girls", + "style": "pink" + } + ], + "emptyModern": [], + "legacy": [ + { + "id": "official", + "label": "Official", + "style": "blue" + } + ], + "missingConfig": [] + } + }, + "ranklist": { + "sortedRows": [ + "solved-more", + "fast", + "slow" + ], + "regenerated": { + "type": "general", + "version": "0.3.9", + "contest": { + "title": "Contest", + "startAt": "2026-01-01T00:00:00+08:00", + "duration": [ + 5, + "h" + ] + }, + "problems": [ + { + "alias": "A", + "statistics": { + "accepted": 3, + "submitted": 4 + } + }, + { + "alias": "B", + "statistics": { + "accepted": 1, + "submitted": 2 + } + } + ], + "series": [ + { + "title": "Rank", + "rule": { + "preset": "Normal" + } + } + ], + "sorter": { + "algorithm": "ICPC", + "config": {} + }, + "rows": [ + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 2, + "time": [ + 11400000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "WA", + "time": [ + 10, + "min" + ] + }, + { + "result": "CE", + "time": [ + 15, + "min" + ] + }, + { + "result": "AC", + "time": [ + 50, + "min" + ] + } + ], + "tries": 2, + "time": [ + 50, + "min" + ] + }, + { + "result": "AC", + "solutions": [ + { + "result": "AC", + "time": [ + 120, + "min" + ] + } + ], + "time": [ + 120, + "min" + ], + "tries": 1 + } + ] + }, + { + "user": { + "id": "u3", + "name": "u3", + "official": false + }, + "score": { + "value": 1, + "time": [ + 1200000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "AC", + "time": [ + 20, + "min" + ] + } + ], + "time": [ + 20, + "min" + ], + "tries": 1 + }, + { + "result": null, + "solutions": [] + } + ] + }, + { + "user": { + "id": "u2", + "name": "u2" + }, + "score": { + "value": 1, + "time": [ + 1800000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "AC", + "time": [ + 30, + "min" + ] + } + ], + "time": [ + 30, + "min" + ], + "tries": 1 + }, + { + "result": "RJ", + "solutions": [ + { + "result": "WA", + "time": [ + 100, + "min" + ] + } + ], + "tries": 1 + } + ] + } + ] + }, + "defaultNoPenalty": { + "type": "general", + "version": "0.3.9", + "contest": { + "title": "Contest", + "startAt": "2026-01-01T00:00:00+08:00", + "duration": [ + 5, + "h" + ] + }, + "problems": [ + { + "alias": "A", + "statistics": { + "accepted": 1, + "submitted": 3 + } + } + ], + "series": [ + { + "title": "Rank", + "rule": { + "preset": "Normal" + } + } + ], + "sorter": { + "algorithm": "ICPC", + "config": {} + }, + "rows": [ + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 1, + "time": [ + 4200000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "WA", + "time": [ + 10, + "min" + ] + }, + { + "result": "CE", + "time": [ + 15, + "min" + ] + }, + { + "result": "WA", + "time": [ + 20, + "min" + ] + }, + { + "result": "?", + "time": [ + 25, + "min" + ] + }, + { + "result": "AC", + "time": [ + 30, + "min" + ] + } + ], + "tries": 3, + "time": [ + 30, + "min" + ] + } + ] + } + ] + }, + "customNoPenalty": { + "type": "general", + "version": "0.3.9", + "contest": { + "title": "Contest", + "startAt": "2026-01-01T00:00:00+08:00", + "duration": [ + 5, + "h" + ] + }, + "problems": [ + { + "alias": "A", + "statistics": { + "accepted": 1, + "submitted": 2 + } + } + ], + "series": [ + { + "title": "Rank", + "rule": { + "preset": "Normal" + } + } + ], + "sorter": { + "algorithm": "ICPC", + "config": { + "noPenaltyResults": [ + "FB", + "AC", + "?", + "NOUT", + "UKE", + null + ] + } + }, + "rows": [ + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 1, + "time": [ + 3000000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "CE", + "time": [ + 10, + "min" + ] + }, + { + "result": "AC", + "time": [ + 30, + "min" + ] + } + ], + "tries": 2, + "time": [ + 30, + "min" + ] + } + ] + } + ] + }, + "postAc": { + "type": "general", + "version": "0.3.9", + "contest": { + "title": "Contest", + "startAt": "2026-01-01T00:00:00+08:00", + "duration": [ + 5, + "h" + ] + }, + "problems": [ + { + "alias": "A", + "statistics": { + "accepted": 1, + "submitted": 2 + } + } + ], + "series": [ + { + "title": "Rank", + "rule": { + "preset": "Normal" + } + } + ], + "sorter": { + "algorithm": "ICPC", + "config": {} + }, + "rows": [ + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 1, + "time": [ + 2400000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "WA", + "time": [ + 10, + "min" + ] + }, + { + "result": "AC", + "time": [ + 20, + "min" + ] + }, + { + "result": "WA", + "time": [ + 30, + "min" + ] + }, + { + "result": "FB", + "time": [ + 40, + "min" + ] + } + ], + "tries": 2, + "time": [ + 20, + "min" + ] + } + ] + } + ] + }, + "timePrecision": { + "type": "general", + "version": "0.3.9", + "contest": { + "title": "Contest", + "startAt": "2026-01-01T00:00:00+08:00", + "duration": [ + 5, + "h" + ] + }, + "problems": [ + { + "alias": "A", + "statistics": { + "accepted": 1, + "submitted": 1 + } + } + ], + "series": [ + { + "title": "Rank", + "rule": { + "preset": "Normal" + } + } + ], + "sorter": { + "algorithm": "ICPC", + "config": { + "timePrecision": "min", + "timeRounding": "ceil" + } + }, + "rows": [ + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 1, + "time": [ + 180000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "AC", + "time": [ + 125, + "s" + ] + } + ], + "time": [ + 125, + "s" + ], + "tries": 1 + } + ] + } + ] + }, + "rankingPrecisionOrder": [ + "slow-original-first", + "fast-original-second" + ], + "incrementalRows": [ + { + "user": { + "id": "u2", + "name": "u2" + }, + "score": { + "value": 1, + "time": [ + 1200000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "AC", + "time": [ + 20, + "min" + ] + } + ], + "time": [ + 20, + "min" + ], + "tries": 1 + } + ] + }, + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 1, + "time": [ + 3300000, + "ms" + ] + }, + "statuses": [ + { + "result": "AC", + "solutions": [ + { + "result": "WA", + "time": [ + 10, + "min" + ] + }, + { + "result": "CE", + "time": [ + 15, + "min" + ] + }, + { + "result": "AC", + "time": [ + 35, + "min" + ] + } + ], + "tries": 2, + "time": [ + 35, + "min" + ] + } + ] + } + ], + "incrementalPostAcRows": [ + { + "user": { + "id": "u1", + "name": "u1" + }, + "score": { + "value": 1, + "time": [ + 20, + "min" + ] + }, + "statuses": [ + { + "result": "AC", + "time": [ + 20, + "min" + ], + "tries": 1, + "solutions": [ + { + "result": "AC", + "time": [ + 20, + "min" + ] + }, + { + "result": "WA", + "time": [ + 30, + "min" + ] + }, + { + "result": "AC", + "time": [ + 40, + "min" + ] + } + ] + } + ] + } + ], + "staticRankValues": [ + [ + { + "rank": 1, + "segmentIndex": null + }, + { + "rank": 1, + "segmentIndex": null + }, + { + "rank": 1, + "segmentIndex": null + }, + { + "rank": 1, + "segmentIndex": 0 + } + ], + [ + { + "rank": 1, + "segmentIndex": null + }, + { + "rank": 1, + "segmentIndex": null + }, + { + "rank": null, + "segmentIndex": null + }, + { + "rank": 1, + "segmentIndex": 1 + } + ], + [ + { + "rank": 3, + "segmentIndex": null + }, + { + "rank": null, + "segmentIndex": null + }, + { + "rank": 2, + "segmentIndex": null + }, + { + "rank": null, + "segmentIndex": null + } + ], + [ + { + "rank": 4, + "segmentIndex": null + }, + { + "rank": 3, + "segmentIndex": null + }, + { + "rank": null, + "segmentIndex": null + }, + { + "rank": 3, + "segmentIndex": null + } + ] + ], + "markerRankValues": [ + { + "rank": 1, + "segmentIndex": 0 + }, + { + "rank": null, + "segmentIndex": null + }, + { + "rank": 2, + "segmentIndex": 1 + } + ], + "invalidFilterRankValue": { + "rank": null, + "segmentIndex": null + }, + "ratioRankValues": [ + { + "rank": 1, + "segmentIndex": 0 + }, + { + "rank": 2, + "segmentIndex": 1 + }, + { + "rank": 3, + "segmentIndex": 1 + }, + { + "rank": 4, + "segmentIndex": null + }, + { + "rank": 5, + "segmentIndex": null + }, + { + "rank": 6, + "segmentIndex": null + }, + { + "rank": 7, + "segmentIndex": null + }, + { + "rank": 8, + "segmentIndex": null + }, + { + "rank": 9, + "segmentIndex": null + }, + { + "rank": 10, + "segmentIndex": null + } + ], + "strictIdRankValues": [ + { + "rank": 1, + "segmentIndex": 0 + }, + { + "rank": null, + "segmentIndex": 0 + } + ] + } +} From 3dc4151b145c9f34114fe37ef025ec5850e9093b Mon Sep 17 00:00:00 2001 From: bLue Date: Tue, 19 May 2026 23:41:51 +0800 Subject: [PATCH 2/2] ci: add release --- .github/workflows/release.yml | 316 ++++++++++++++++++++++++++++++++++ README.md | 34 +++- 2 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7bc305e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,316 @@ +name: Release + +on: + workflow_dispatch: + inputs: + package: + description: Language package to release + required: true + type: choice + options: + - js + - python + - go + version: + description: Stable SemVer version without a leading v, for example 0.3.1 + required: true + type: string + dry_run: + description: Validate release without publishing or creating a tag + required: true + type: boolean + default: true + +permissions: + contents: read + +concurrency: + group: release-${{ inputs.package }}-${{ inputs.version }} + cancel-in-progress: false + +jobs: + validate: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.meta.outputs.tag }} + title: ${{ steps.meta.outputs.title }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate release inputs + id: meta + env: + PACKAGE: ${{ inputs.package }} + VERSION: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Version must be a stable SemVer value like 0.3.1" + exit 1 + fi + + if [[ "$DRY_RUN" != "true" && "$GITHUB_REF" != "refs/heads/main" && "$GITHUB_REF" != "refs/heads/master" ]]; then + echo "Real releases must be run from main or master" + exit 1 + fi + + case "$PACKAGE" in + js) TAG="js/v$VERSION" ;; + python) TAG="python/v$VERSION" ;; + go) TAG="go/v$VERSION" ;; + *) + echo "Unknown package: $PACKAGE" + exit 1 + ;; + esac + + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "Tag already exists: $TAG" + exit 1 + fi + + { + echo "tag=$TAG" + echo "title=$PACKAGE v$VERSION" + } >> "$GITHUB_OUTPUT" + + js-check: + needs: validate + if: ${{ inputs.package == 'js' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: pnpm/action-setup@v4 + with: + version: 8 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Check JS package version + env: + VERSION: ${{ inputs.version }} + run: | + node -e "const pkg = require('./js/package.json'); if (pkg.version !== process.env.VERSION) { throw new Error(`js/package.json version ${pkg.version} does not match ${process.env.VERSION}`); }" + + - run: pnpm run verify:fixtures + - run: pnpm -C js test + - run: pnpm -C js build + - run: npm pack --dry-run + working-directory: js + + js-publish: + needs: + - validate + - js-check + if: ${{ inputs.package == 'js' && inputs.dry_run == false }} + runs-on: ubuntu-latest + environment: npm + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: pnpm/action-setup@v4 + with: + version: 8 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - run: pnpm install --frozen-lockfile + - run: pnpm -C js build + - run: npm publish --access public + working-directory: js + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.validate.outputs.tag }} + TITLE: ${{ needs.validate.outputs.title }} + run: | + gh release create "$TAG" --target "$GITHUB_SHA" --title "$TITLE" --notes "Published $TITLE to npm." + + python-check: + needs: validate + if: ${{ inputs.package == 'python' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: pnpm/action-setup@v4 + with: + version: 8 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - run: pnpm install --frozen-lockfile + - run: pnpm run verify:fixtures + + - name: Check Python package version + env: + VERSION: ${{ inputs.version }} + run: | + python - <<'PY' + import os + import pathlib + import tomllib + + pyproject = tomllib.loads(pathlib.Path("python/pyproject.toml").read_text()) + actual = pyproject["project"]["version"] + expected = os.environ["VERSION"] + if actual != expected: + raise SystemExit(f"python/pyproject.toml version {actual} does not match {expected}") + PY + + - run: python -m pip install -e 'python[dev]' + - run: python -m pytest python/tests + - run: python -m ruff check python/src python/tests + - run: python -m build python + - run: python -m twine check python/dist/* + + - uses: actions/upload-artifact@v4 + with: + name: python-dist + path: python/dist/* + if-no-files-found: error + + python-publish: + needs: + - validate + - python-check + if: ${{ inputs.package == 'python' && inputs.dry_run == false }} + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/download-artifact@v4 + with: + name: python-dist + path: python/dist + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: python/dist + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.validate.outputs.tag }} + TITLE: ${{ needs.validate.outputs.title }} + run: | + gh release create "$TAG" --target "$GITHUB_SHA" --title "$TITLE" --notes "Published $TITLE to PyPI." + + go-check: + needs: validate + if: ${{ inputs.package == 'go' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: pnpm/action-setup@v4 + with: + version: 8 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + cache-dependency-path: go/go.mod + + - run: pnpm install --frozen-lockfile + - run: pnpm run verify:fixtures + + - run: go test ./... + working-directory: go + - run: go vet ./... + working-directory: go + - run: go mod tidy + working-directory: go + - run: git diff --exit-code -- go/go.mod go/go.sum + - run: test -z "$(git status --porcelain -- go/go.mod go/go.sum)" + + go-release: + needs: + - validate + - go-check + if: ${{ inputs.package == 'go' && inputs.dry_run == false }} + runs-on: ubuntu-latest + environment: go-release + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + + - name: Push Go module tag + env: + TAG: ${{ needs.validate.outputs.tag }} + run: | + git tag "$TAG" "$GITHUB_SHA" + git push origin "$TAG" + + - name: Request Go module indexing + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + for attempt in {1..6}; do + if GOPROXY=proxy.golang.org go list -m "github.com/algoux/standard-ranklist-utils/go@v$VERSION"; then + exit 0 + fi + echo "Go proxy has not indexed the module yet; retry $attempt/6" + sleep 10 + done + GOPROXY=proxy.golang.org go list -m "github.com/algoux/standard-ranklist-utils/go@v$VERSION" + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.validate.outputs.tag }} + TITLE: ${{ needs.validate.outputs.title }} + run: | + gh release create "$TAG" --title "$TITLE" --notes "Published $TITLE as a Go module tag." diff --git a/README.md b/README.md index 0b35351..6178b27 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,9 @@ pnpm run verify:fixtures ## Release Checks +The `Test` GitHub Actions workflow is CI only: it runs tests, builds, fixture checks, and packaging dry runs. Releases are +handled by the manual `Release` workflow so each language package can publish independently. + ```shell pnpm -C js build (cd js && npm pack --dry-run) @@ -66,4 +69,33 @@ go -C go test ./... go -C go vet ./... ``` -For Go subdirectory releases, tag with the module prefix, for example `go/v0.3.0`. +## Publishing + +Run **Actions > Release** manually with: + +- `package`: `js`, `python`, or `go`. +- `version`: stable SemVer without `v`, for example `0.3.1`. +- `dry_run`: keep `true` to validate only; set `false` to publish, tag, and create a GitHub Release. + +Real releases are restricted to `main` or `master`. The workflow validates that the target tag does not already exist, +runs the target package checks, and then publishes only the selected package. + +Version sources and tags are independent: + +- JS: update `js/package.json`; release tag `js/vX.Y.Z`. +- Python: update `python/pyproject.toml`; release tag `python/vX.Y.Z`. +- Go: no version in `go.mod`; release tag `go/vX.Y.Z`. + +Configure registry publishing before setting `dry_run=false`: + +- npm: configure Trusted Publishing for `@algoux/standard-ranklist-utils` with workflow `release.yml` and environment + `npm`. +- PyPI: configure Trusted Publisher for `algoux-standard-ranklist-utils` with workflow `release.yml` and environment + `pypi`. +- GitHub: create `npm`, `pypi`, and `go-release` environments, preferably with required reviewers. + +The workflow uses OIDC / Trusted Publishing and does not require `NPM_TOKEN` or `PYPI_API_TOKEN`. + +Registry setup references: [npm Trusted Publishing](https://docs.npmjs.com/trusted-publishers), +[PyPI Trusted Publishers](https://docs.pypi.org/trusted-publishers/using-a-publisher/), and +[Go modules](https://go.dev/ref/mod).