Skip to content

Commit abceae0

Browse files
docs: fix Exp references, add gitattributes rules, add node_hours/usage tests [skip-ci]
1 parent 960dd87 commit abceae0

5 files changed

Lines changed: 321 additions & 3 deletions

File tree

.gitattributes

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,12 @@
1-
# Shell scripts must use LF line endings (executed on Linux CI)
1+
# Default: let Git normalize line endings
2+
* text=auto
3+
4+
# Shell scripts must use LF (executed on Linux CI)
25
*.sh text eol=lf
6+
7+
# Python must use LF
8+
*.py text eol=lf
9+
10+
# Config files used by CI
11+
*.csv text eol=lf
12+
*.yml text eol=lf

ADD_APP.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,20 @@ FugakuLN,yes,1,1,1,0:10:00
7676
- `nthreads`: スレッド数
7777
- `elapse`: 実行時間制限
7878

79-
> **Note**: `mode``queue_group``config/system.csv` で一元管理されるため、list.csv には含めません。ジョブを無効化するには `enable=no` を設定します(`#` コメントアウトは使用しません)。
79+
> **Note**: `list.csv` は「アプリごとの実験条件」だけを書くファイルです。`mode``queue_group``config/system.csv` で一元管理されるため、list.csv には含めません。ジョブを無効化するには `enable=no` を設定します(`#` コメントアウトは使用しません)。
80+
81+
### `config/system.csv` との責務分担
82+
83+
BenchKit では、実行条件とシステム運用設定を明確に分けます。
84+
85+
- `programs/<code>/list.csv`
86+
- そのアプリをどのシステム・どのノード数・どのMPI/OpenMP条件で流すか
87+
- アプリごとに変わる条件を書く
88+
- `config/system.csv`
89+
- `mode`、Runner tag、`queue``queue_group` など、そのシステムで共通な運用設定を書く
90+
- 全アプリで共有される条件を書く
91+
92+
この分担により、同じシステムに対して各アプリが `mode``queue_group` を重複定義する必要がなくなります。
8093

8194
---
8295

@@ -229,6 +242,7 @@ cd ..
229242
sync
230243
```
231244

245+
232246
### 結果フォーマット
233247
`results/result` の各行は以下の形式:
234248
```
@@ -417,4 +431,4 @@ git push origin add-<code>
417431

418432
### Git リポジトリの取り扱い
419433
- `build.sh``run.sh` 両方でcloneする場合、ディレクトリ衝突に注意
420-
- 既存チェック: `[[ -d repo ]] || git clone ...`
434+
- 既存チェック: `[[ -d repo ]] || git clone ...`

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,15 @@ MiyabiG,cross,miyabi_g_login,miyabi_g_jacamar,PBS_Miyabi,debug-g
248248
MiyabiC,cross,miyabi_c_login,miyabi_c_jacamar,PBS_Miyabi,debug-c
249249
```
250250

251+
`system.csv`**システム固有・拠点固有の実行ポリシーの正本** です。各システムについて以下を一元管理します。
252+
253+
- `mode` - `cross` / `native` の実行モード
254+
- `tag_build` / `tag_run` - GitLab Runner / Jacamar のタグ
255+
- `queue` - `config/queue.csv` のテンプレート選択に使うキュー種別
256+
- `queue_group` - 実際の投入先キューグループ
257+
258+
> **設計方針**: 同じシステムで共通な情報は `system.csv` に寄せ、各アプリの `list.csv` に重複定義しません。
259+
251260
### `config/queue.csv` - キューシステム定義
252261
```csv
253262
queue,submit_cmd,template
@@ -273,6 +282,14 @@ MiyabiG,yes,1,1,72,0:10:00
273282
MiyabiC,no,1,1,112,0:10:00
274283
```
275284

285+
`list.csv`**アプリごとの実験条件マトリクスの正本** です。ここにはジョブ投入の有無と、投入時に変わる実行条件だけを書きます。
286+
287+
- `system` - 実行先システム名(`system.csv` と対応)
288+
- `enable` - その条件を実行するかどうか(`yes` / `no`
289+
- `nodes`, `numproc_node`, `nthreads`, `elapse` - 実験条件
290+
291+
> **設計方針**: `list.csv` には `mode``queue_group` を持たせません。これらは `system.csv` で一元管理します。
292+
276293

277294
## CI実行制御
278295

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""
2+
node_hours.py のユニットテスト
3+
"""
4+
5+
import os
6+
import sys
7+
from datetime import datetime
8+
9+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
10+
11+
from utils.node_hours import (
12+
compute_node_hours,
13+
extract_timestamp_from_filename,
14+
get_fiscal_year,
15+
get_fiscal_month_index,
16+
get_half,
17+
)
18+
19+
20+
class TestComputeNodeHours:
21+
def test_cross_mode_uses_run_time_only(self):
22+
data = {
23+
"node_count": 4,
24+
"execution_mode": "cross",
25+
"pipeline_timing": {"build_time": 100, "run_time": 1800},
26+
}
27+
assert compute_node_hours(data) == 2.0
28+
29+
def test_native_mode_uses_build_and_run_time(self):
30+
data = {
31+
"node_count": 2,
32+
"execution_mode": "native",
33+
"pipeline_timing": {"build_time": 600, "run_time": 1200},
34+
}
35+
assert compute_node_hours(data) == 1.0
36+
37+
def test_native_mode_missing_build_time_falls_back_to_zero(self):
38+
data = {
39+
"node_count": 3,
40+
"execution_mode": "native",
41+
"pipeline_timing": {"run_time": 600},
42+
}
43+
assert compute_node_hours(data) == 0.5
44+
45+
def test_missing_node_count_returns_zero(self):
46+
data = {"execution_mode": "cross", "pipeline_timing": {"run_time": 100}}
47+
assert compute_node_hours(data) == 0.0
48+
49+
def test_invalid_run_time_returns_zero(self):
50+
data = {
51+
"node_count": 2,
52+
"execution_mode": "cross",
53+
"pipeline_timing": {"run_time": "bad"},
54+
}
55+
assert compute_node_hours(data) == 0.0
56+
57+
def test_rounds_to_two_decimal_places(self):
58+
data = {
59+
"node_count": 1,
60+
"execution_mode": "cross",
61+
"pipeline_timing": {"run_time": 1000},
62+
}
63+
assert compute_node_hours(data) == 0.28
64+
65+
66+
class TestTimestampHelpers:
67+
def test_extract_timestamp_from_filename(self):
68+
ts = extract_timestamp_from_filename(
69+
"result_20260401_123456_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.json"
70+
)
71+
assert ts == datetime(2026, 4, 1, 12, 34, 56)
72+
73+
def test_extract_timestamp_from_filename_without_pattern(self):
74+
assert extract_timestamp_from_filename("result.json") is None
75+
76+
77+
class TestFiscalHelpers:
78+
def test_get_fiscal_year_january_is_previous_year(self):
79+
assert get_fiscal_year(datetime(2026, 1, 15)) == 2025
80+
81+
def test_get_fiscal_year_april_is_current_year(self):
82+
assert get_fiscal_year(datetime(2026, 4, 1)) == 2026
83+
84+
def test_get_fiscal_month_index(self):
85+
assert get_fiscal_month_index(datetime(2026, 4, 1)) == 0
86+
assert get_fiscal_month_index(datetime(2027, 3, 1)) == 11
87+
88+
def test_get_half(self):
89+
assert get_half(datetime(2026, 4, 1)) == "first"
90+
assert get_half(datetime(2026, 9, 30)) == "first"
91+
assert get_half(datetime(2026, 10, 1)) == "second"
92+
assert get_half(datetime(2027, 3, 31)) == "second"
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""
2+
/results/usage ルートと Usage ナビゲーションのテスト
3+
"""
4+
5+
import json
6+
import os
7+
import sys
8+
import tempfile
9+
import shutil
10+
import types
11+
12+
import fakeredis
13+
import pytest
14+
from flask import Flask
15+
16+
17+
def _setup_stubs():
18+
if "redis" not in sys.modules:
19+
sys.modules["redis"] = types.ModuleType("redis")
20+
21+
otp_mod = types.ModuleType("utils.otp_manager")
22+
otp_mod.get_affiliations = lambda email: ["dev"]
23+
otp_mod.is_allowed = lambda email: True
24+
sys.modules["utils.otp_manager"] = otp_mod
25+
26+
otp_redis_mod = types.ModuleType("utils.otp_redis_manager")
27+
otp_redis_mod.get_affiliations = lambda email: ["dev"]
28+
otp_redis_mod.is_allowed = lambda email: True
29+
otp_redis_mod.send_otp = lambda email: (True, "stub")
30+
otp_redis_mod.verify_otp = lambda email, code: True
31+
otp_redis_mod.invalidate_otp = lambda email: None
32+
sys.modules["utils.otp_redis_manager"] = otp_redis_mod
33+
34+
35+
_setup_stubs()
36+
37+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
38+
39+
from routes.results import results_bp
40+
from routes.estimated import estimated_bp
41+
from routes.auth import auth_bp
42+
from routes.admin import admin_bp
43+
from utils.user_store import UserStore
44+
45+
46+
@pytest.fixture
47+
def tmp_dirs():
48+
received = tempfile.mkdtemp()
49+
estimated = tempfile.mkdtemp()
50+
yield received, estimated
51+
shutil.rmtree(received)
52+
shutil.rmtree(estimated)
53+
54+
55+
@pytest.fixture
56+
def app(tmp_dirs):
57+
received, estimated = tmp_dirs
58+
template_dir = os.path.join(os.path.dirname(__file__), "..", "templates")
59+
60+
app = Flask(__name__, template_folder=template_dir)
61+
app.config["RECEIVED_DIR"] = received
62+
app.config["ESTIMATED_DIR"] = estimated
63+
app.config["SECRET_KEY"] = "test-secret"
64+
app.config["TESTING"] = True
65+
66+
r_conn = fakeredis.FakeRedis(decode_responses=True)
67+
app.config["REDIS_CONN"] = r_conn
68+
app.config["REDIS_PREFIX"] = "test:"
69+
store = UserStore(r_conn, "test:")
70+
app.config["USER_STORE"] = store
71+
app.config["TOTP_ISSUER"] = "BenchKit-Test"
72+
73+
store.create_user("admin@example.com", "SECRET", ["admin"])
74+
store.create_user("user@example.com", "SECRET", ["dev"])
75+
76+
app.register_blueprint(results_bp, url_prefix="/results")
77+
app.register_blueprint(estimated_bp, url_prefix="/estimated")
78+
app.register_blueprint(auth_bp, url_prefix="/auth")
79+
app.register_blueprint(admin_bp, url_prefix="/admin")
80+
81+
@app.route("/systemlist")
82+
def systemlist():
83+
return ""
84+
85+
yield app
86+
87+
88+
@pytest.fixture
89+
def client(app):
90+
return app.test_client()
91+
92+
93+
def _login_session(client, email, affiliations):
94+
with client.session_transaction() as sess:
95+
sess["authenticated"] = True
96+
sess["user_email"] = email
97+
sess["user_affiliations"] = affiliations
98+
99+
100+
def _write_result(directory, filename, data):
101+
path = os.path.join(directory, filename)
102+
with open(path, "w", encoding="utf-8") as f:
103+
json.dump(data, f, ensure_ascii=False)
104+
return path
105+
106+
107+
class TestUsageRoute:
108+
def test_unauthenticated_user_is_redirected_to_login(self, client):
109+
resp = client.get("/results/usage")
110+
assert resp.status_code == 302
111+
assert "/auth/login" in resp.headers["Location"]
112+
113+
def test_non_admin_user_gets_403(self, client):
114+
_login_session(client, "user@example.com", ["dev"])
115+
resp = client.get("/results/usage")
116+
assert resp.status_code == 403
117+
118+
def test_admin_user_can_access_usage_page(self, client):
119+
_login_session(client, "admin@example.com", ["admin"])
120+
resp = client.get("/results/usage")
121+
assert resp.status_code == 200
122+
assert "Usage Report" in resp.get_data(as_text=True)
123+
124+
def test_usage_route_uses_default_parameters(self, app, client, monkeypatch):
125+
_login_session(client, "admin@example.com", ["admin"])
126+
127+
captured = {}
128+
129+
def fake_aggregate(directory, fiscal_year, period_type):
130+
captured["directory"] = directory
131+
captured["fiscal_year"] = fiscal_year
132+
captured["period_type"] = period_type
133+
return {
134+
"apps": [],
135+
"systems": [],
136+
"periods": ["FY2025"],
137+
"table": {},
138+
"row_totals": {},
139+
"col_totals": {},
140+
"grand_totals": {},
141+
"available_fiscal_years": [2025],
142+
}
143+
144+
import routes.results as results_mod
145+
146+
monkeypatch.setattr(results_mod, "aggregate_node_hours", fake_aggregate)
147+
monkeypatch.setattr(results_mod, "get_fiscal_year", lambda dt: 2025)
148+
149+
resp = client.get("/results/usage")
150+
assert resp.status_code == 200
151+
assert captured["directory"] == app.config["RECEIVED_DIR"]
152+
assert captured["fiscal_year"] == 2025
153+
assert captured["period_type"] == "fiscal_year"
154+
155+
def test_usage_page_shows_no_data_message(self, client):
156+
_login_session(client, "admin@example.com", ["admin"])
157+
resp = client.get("/results/usage")
158+
assert resp.status_code == 200
159+
assert "該当期間のデータがありません" in resp.get_data(as_text=True)
160+
161+
def test_admin_navigation_shows_usage_link(self, client, tmp_dirs):
162+
received, _ = tmp_dirs
163+
_write_result(
164+
received,
165+
"result_20260401_123456_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.json",
166+
{"code": "qws", "system": "Fugaku", "Exp": "CASE0", "FOM": 1.0},
167+
)
168+
_login_session(client, "admin@example.com", ["admin"])
169+
resp = client.get("/results/confidential")
170+
text = resp.get_data(as_text=True)
171+
assert resp.status_code == 200
172+
assert "/results/usage" in text
173+
174+
def test_non_admin_navigation_hides_usage_link(self, client, tmp_dirs):
175+
received, _ = tmp_dirs
176+
_write_result(
177+
received,
178+
"result_20260401_123456_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.json",
179+
{"code": "qws", "system": "Fugaku", "Exp": "CASE0", "FOM": 1.0},
180+
)
181+
_login_session(client, "user@example.com", ["dev"])
182+
resp = client.get("/results/confidential")
183+
text = resp.get_data(as_text=True)
184+
assert resp.status_code == 200
185+
assert "/results/usage" not in text

0 commit comments

Comments
 (0)