Skip to content

Commit fab6f10

Browse files
committed
通常問題を60番まで追加し、問題文と解答例を整備
1 parent e5f58ab commit fab6f10

24 files changed

Lines changed: 675 additions & 12 deletions

backend/tests/integration/README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,14 @@ runnerとjudgeには起動時検証済みの同じ不変problem repositoryを注
8585
期待出力、正解画像がrequestごとのfile再読込なしで利用される経路を検証します。
8686
legacy `yaml_data/`とのsemantic一致と意図した改訂の範囲は、
8787
[問題データ仕様](../../../problems/README.md)に従いnon-Docker testで別途検証します。
88-
`test_reservation_problem.py`は未受付者を求める問題の参照解答と2つの別解を実行し
89-
集合差の逆転・重複の残存・部分一致による除外を不正解として区別します
88+
`test_reservation_problem.py`は未受付者を求める問題の参照解答と3つの別解を実行し
89+
集合差の逆転・重複の残存・部分一致による除外・初出判定前の予約抽出を不正解として区別します
9090
`test_remaining_problem_solutions.py`は通常21〜51・練習問題の改訂に関係する別解と誤答を検証します。
9191
画像5問ではMIFF中間画像を使う別解の正解判定と、色・寸法・図形端点の誤りの不正解判定を確認します。
92-
全93問の参照解答は上記の全問題回帰で確認します。
92+
現在登録されている全問題の参照解答は上記の全問題回帰で確認します。
93+
`test_new_standard_solutions.py`は通常53〜60の参照解答と別解、および初出優先、差の逆転、
94+
境界の部分一致、空欄を失う分割、大小文字の未統一、結合キーの重複除去、
95+
タブの固定幅置換・元のスペースの変更、終了時刻を含む区間、指定された集計時刻の欠落の誤答を実sandboxで区別します。
9396

9497
`test_revised_standard_solutions.py`は改訂した通常問題3・5〜20の参照解答・別解と、
9598
累積の欠落、丸め違い、改行の全削除、探索範囲の不足、記録や空白の破壊、置換漏れに加え、
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""通常53〜60の別解と典型的な誤答を、実sandboxとjudgeで検査する。"""
2+
3+
import asyncio
4+
import os
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
from soj_backend.judge import JudgeVerdict, ShellgeiJudge
10+
from soj_runner.container_manager import ContainerManager
11+
from soj_runner.run_shellgei import ShellgeiDockerClient
12+
from soj_shared.models.execution import ExecutionStatus
13+
from soj_shared.problem_repository import build_problem_repository
14+
15+
16+
pytestmark = [
17+
pytest.mark.docker,
18+
pytest.mark.skipif(
19+
os.getenv("SOJ_RUN_DOCKER_TESTS") != "1",
20+
reason="explicit isolated-host opt-in is required",
21+
),
22+
]
23+
PROBLEMS = Path(__file__).resolve().parents[3] / "problems"
24+
CASES = [
25+
(
26+
53,
27+
"awk '{last[$1]=$0} END{for(k in last)print last[k]}' input.txt | sort",
28+
"awk '!seen[$1]++' input.txt | sort",
29+
),
30+
(
31+
54,
32+
"awk 'NR>1{print $1,$2-prev} {prev=$2}' input.txt",
33+
"awk 'NR>1{print $1,prev-$2} {prev=$2}' input.txt",
34+
),
35+
(
36+
55,
37+
'awk \'$0=="END"{inside=0} inside{print} $0=="BEGIN"{inside=1}\' input.txt',
38+
"sed -n '/BEGIN/,/END/{/BEGIN/d;/END/d;p;}' input.txt",
39+
),
40+
(
41+
56,
42+
"awk 'BEGIN{FS=OFS=\"\\t\"} {print $1,$3}' input.txt",
43+
"awk 'BEGIN{OFS=\"\\t\"} {print $1,$3}' input.txt",
44+
),
45+
(
46+
57,
47+
"awk -F. '{n[tolower($NF)]++} END{for(e in n)print e,n[e]}' input.txt | sort",
48+
"sed 's/.*\\.//' input.txt | sort | uniq -c | awk '{print $2,$1}'",
49+
),
50+
(
51+
58,
52+
'awk \'$1=="team"{people[$2,$3]=1} $1=="file"{files[$2,$3]=1} '
53+
"END{for(p in people){split(p,a,SUBSEP);for(f in files){split(f,b,SUBSEP);"
54+
"if(a[1]==b[1])print a[1],a[2],b[2]}}}' input.txt | sort",
55+
"join <(sed -n 's/^team //p' input.txt | sort -u -k1,1) "
56+
"<(sed -n 's/^file //p' input.txt | sort) | sort",
57+
),
58+
(
59+
59,
60+
"awk '{col=0;for(i=1;i<=length;i++){c=substr($0,i,1);"
61+
'if(c=="\\t"){n=4-col%4;printf "%s",substr(">>>>",1,n);col+=n}'
62+
'else{printf "%s",c;col++}}print ""}\' input.txt',
63+
"sed 's/\t/>>>>/g' input.txt",
64+
),
65+
(
66+
60,
67+
"awk '{s[NR]=$2;e[NR]=$3} END{for(t=540;t<=660;t+=10){"
68+
'k=sprintf("%02d:%02d",t/60,t%60);n=0;'
69+
"for(i=1;i<=NR;i++)if(s[i]<=k&&k<e[i])n++;print k,n}}' input.txt",
70+
"awk '{s[NR]=$2;e[NR]=$3} END{for(t=540;t<=660;t+=10){"
71+
'k=sprintf("%02d:%02d",t/60,t%60);n=0;'
72+
"for(i=1;i<=NR;i++)if(s[i]<=k&&k<=e[i])n++;print k,n}}' input.txt",
73+
),
74+
]
75+
76+
77+
def test_new_standard_solutions_and_mistakes() -> None:
78+
# 各問の参照解答・別解は正解、初出優先・空白分割・閉区間等の誤解は不正解になる。
79+
repository = build_problem_repository(
80+
PROBLEMS / "v3", PROBLEMS / "image", PROBLEMS / "v3/manifest.json"
81+
)
82+
manager = ContainerManager(pool_size=1)
83+
client = ShellgeiDockerClient(
84+
container_manager=manager, max_concurrent=1, problem_repository=repository
85+
)
86+
judge = ShellgeiJudge(repository)
87+
try:
88+
manager.initialize_pool()
89+
for number, alternative, mistake in CASES:
90+
problem_id = f"STANDARD-{number:08}"
91+
reference = repository.require(problem_id).definition.reference_solution
92+
commands = [
93+
(reference, True),
94+
(alternative, True),
95+
(mistake, False),
96+
]
97+
if number == 59:
98+
# 元からあるスペースまで>に変換する誤答を区別する。
99+
commands.append(("expand -t 4 input.txt | tr ' ' '>'", False))
100+
if number == 60:
101+
# 記録に現れる時刻だけの出力と、11:00を落とす出力も区別する。
102+
commands.extend(
103+
[
104+
(
105+
"awk '{print $2,1;print $3,-1}' input.txt | sort -k1,1 | "
106+
"awk 'NR>1&&$1!=t{print t,n} {t=$1;n+=$2} END{print t,n}'",
107+
False,
108+
),
109+
(reference + " | head -n 12", False),
110+
]
111+
)
112+
for command, accepted in commands:
113+
execution = asyncio.run(client.run_with_timeout(command, problem_id))
114+
context = (problem_id, command, execution)
115+
assert execution.status is ExecutionStatus.COMPLETED, context
116+
assert execution.exit_code == 0, context
117+
assert execution.stderr == "", context
118+
expected = (
119+
JudgeVerdict.ACCEPTED if accepted else JudgeVerdict.WRONG_ANSWER
120+
)
121+
assert judge.judge(execution, problem_id).verdict is expected, context
122+
finally:
123+
manager.shutdown_pool()
124+
client.close()

backend/tests/integration/test_reservation_problem.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@
2626

2727

2828
def test_reservation_solutions_and_common_mistakes_in_real_sandboxes() -> None:
29-
# 参照解答・別解を受理し、逆の差・重複残存・部分一致による除外を実sandboxで区別する
29+
# 参照解答・別解を受理し、逆の差・重複残存・部分一致・初出判定前の予約抽出を区別する
3030
repository = build_problem_repository(
3131
PROBLEMS / "v3", PROBLEMS / "image", PROBLEMS / "v3/manifest.json"
3232
)
3333
cases = [
3434
(repository.require(PROBLEM_ID).definition.reference_solution, True),
35+
(f"comm -23 <({RESERVED} | sort -u) <({ARRIVED} | sort -u)", True),
3536
(
3637
'awk \'$1=="arrived"{a[$2]=1} $1=="reserved"{r[$2]=1} '
3738
"END{for(n in r)if(!(n in a))print n}' input.txt | sort",
@@ -47,6 +48,11 @@ def test_reservation_solutions_and_common_mistakes_in_real_sandboxes() -> None:
4748
False,
4849
),
4950
(f"grep -Fv -f <({ARRIVED}) <({RESERVED} | sort -u)", False),
51+
(
52+
"cat input.txt | awk '{print $2,$1}' | sort -u | "
53+
"awk '$2==\"reserved\" && !seen[$1]++{print $1}'",
54+
False,
55+
),
5056
]
5157
manager = ContainerManager(pool_size=1)
5258
client = ShellgeiDockerClient(
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""通常53〜60の期待値を、参照シェル解とは独立した処理で検証する。"""
2+
3+
from collections import Counter
4+
from pathlib import Path
5+
6+
import pytest
7+
from PIL import Image
8+
9+
from soj_shared.problem_schema import load_problem_definition
10+
11+
12+
PROBLEMS = Path(__file__).resolve().parents[2] / "problems"
13+
14+
15+
@pytest.mark.parametrize("number", range(53, 61))
16+
def test_new_standard_problem_expectations(number: int) -> None:
17+
# 公開入力から期待値を独立計算し、空欄・区間境界・出力順まで完全一致で検査する。
18+
problem_id = f"STANDARD-{number:08}"
19+
definition = load_problem_definition(PROBLEMS / "v3" / f"{problem_id}.yaml")
20+
assert definition.judge.type == "text"
21+
assert definition.execution.exit_code == "zero"
22+
assert definition.execution.stderr == "must_be_empty"
23+
assert len(definition.execution.fixtures) == 1
24+
assert definition.execution.fixtures[0].path == "input.txt"
25+
content = definition.execution.fixtures[0].content
26+
lines = content.splitlines()
27+
result: list[str] = []
28+
if number == 53:
29+
devices = sorted({line.split()[0] for line in lines})
30+
result = [
31+
[line for line in lines if line.split()[0] == device][-1]
32+
for device in devices
33+
]
34+
elif number == 54:
35+
measurements = [line.split() for line in lines]
36+
result = [
37+
f"{current[0]} {int(current[1]) - int(previous[1])}"
38+
for previous, current in zip(measurements, measurements[1:])
39+
]
40+
elif number == 55:
41+
# 境界の行番号から区間を切り出し、参照解答の範囲状態とは別に確認する。
42+
starts = [i for i, line in enumerate(lines) if line == "BEGIN"]
43+
ends = [i for i, line in enumerate(lines) if line == "END"]
44+
assert len(starts) == len(ends)
45+
for start, end in zip(starts, ends):
46+
assert start < end
47+
result.extend(lines[start + 1 : end])
48+
elif number == 56:
49+
fields = [line.split("\t") for line in lines]
50+
assert all(len(row) == 4 for row in fields)
51+
result = [f"{row[0]}\t{row[2]}" for row in fields]
52+
elif number == 57:
53+
counts = Counter(line.rsplit(".", 1)[1].lower() for line in lines)
54+
result = [f"{extension} {counts[extension]}" for extension in sorted(counts)]
55+
elif number == 58:
56+
records = [line.split() for line in lines]
57+
result = sorted(
58+
f"{team} {person} {document}"
59+
for kind, team, person in records
60+
for other_kind, other_team, document in records
61+
if kind == "team" and other_kind == "file" and team == other_team
62+
)
63+
elif number == 59:
64+
# 元のスペースを1文字幅の印で保護し、expandtabsが追加した空白だけを可視化する。
65+
assert "\0" not in content
66+
result = [
67+
line.replace(" ", "\0").expandtabs(4).replace(" ", ">").replace("\0", " ")
68+
for line in lines
69+
]
70+
else:
71+
intervals = [line.split()[1:] for line in lines]
72+
assert all(start < end for start, end in intervals)
73+
times = [
74+
f"{minute // 60:02}:{minute % 60:02}" for minute in range(540, 661, 10)
75+
]
76+
# イベントの累積を使わず、各時刻に区間を直接数える。
77+
result = [
78+
f"{time} {sum(start <= time < end for start, end in intervals)}"
79+
for time in times
80+
]
81+
assert definition.judge.expected_output == "\n".join(result) + "\n"
82+
with Image.open(PROBLEMS / "image" / f"{problem_id}.jpg") as image:
83+
image.load()
84+
assert image.format == "JPEG"
85+
assert image.convert("RGB").getextrema() == ((255, 255),) * 3

0 commit comments

Comments
 (0)