Harness: runner, generator, tasks T01 T13 T14 T15, CLAUDE.md

This commit is contained in:
Kral
2026-10-02 21:42:51 +02:00
commit 1c6c85492e
54 changed files with 3732 additions and 0 deletions

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
MCP_URL=http://127.0.0.1:3000/mcp
MCP_TOKEN=
A4H_URL=http://192.168.178.40:50000
A4H_USER=
A4H_PASSWORD=
A4H_CLIENT=001
LLM_BASE_URL=http://127.0.0.1:11434/v1

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.env
node_modules/
runs/
__pycache__/
.DS_Store

90
CLAUDE.md Normal file
View File

@@ -0,0 +1,90 @@
# ABAP LLM harness — instructions for Claude Code
Talk to the user (Kral) in Turkish. Keep answers short; remove words that add no value.
When you write English text (specs, prompts, docs for the model), use ASD-STE100 Simplified Technical English.
Ask before an action that uses much cloud budget.
## 1. Project
- Goal: train an open-weight ABAP model (Apache 2.0). Base model: Apache 2.0 or MIT only.
- Role of the model: technical ABAP consultant. It writes ABAP, knows Clean ABAP and what to use how.
No SAP module knowledge; the functional side (spec or the /sapplan planner) gives it.
- Contract of the model: "ABAP task as text + generic ABAP MCP interface".
The plan format (plan-agent.md) gives the best result, but it is not mandatory.
- Training data: never Claude output. Eval tasks never go into training data.
- Read first: `docs/yol-haritasi.md` (roadmap, current position) and `docs/faz1-tasarim.md` (design).
When a decision or a result changes, update these files.
## 2. Environment (this Mac mini)
- A4H: Docker container `a4h`, HTTP `localhost:50000`, client 001, SAP_BASIS 816 SP01.
- MCP server (EPOD) runs inside ADT (Eclipse) at `127.0.0.1:3000`. Token: `.env` (`MCP_TOKEN`).
System name `A4H`, mode write.
- Ollama `127.0.0.1:11434`:
- `qwen3.8-27b-32k` (local, num_ctx 32k, ~18 GB). Slow: 20–40 min per task.
- `deepseek-v4.1-flash:cloud` (Ollama cloud, MIT): teacher and task author. ~0.05–0.08 USD per run.
- Python 3.9 (system), Node; abaplint in `node_modules`.
## 3. Rules
- Local model: one request at a time. No time limit (tool-call budget limits a run).
- Do not run local and cloud model runs at the same time: Ollama can queue them together.
- A4H is small: MCP limit is 8 sessions (by design). The server shares ONE RFC connection between
sessions; a parallel call gets "[LOCK] Concurrent call detected". `mcp_client.py` retries this.
Keep parallel runs low (max 3). DDIC activation during setup is sensitive to parallel runs.
- Budget: `runs/ledger.jsonl` (list prices, upper bound). `.env`: `BUDGET_LIMIT_USD`, `BUDGET_CYCLE_START`.
Ollama usage resets on 12 October 2026, then +60 USD per month. At the limit, stop cloud work.
- Objects: package `$TMP` only. Prefix `Z` + run (4 chars base36) + task (3 chars base36) + `_`
(`harness/task.py`). Teardown after each run with the ADT deletion API (`adt_client.py`, credentials in `.env`).
Delete only objects with a run prefix. Clean up probe objects.
- The model never sees delete/teardown. The proxy (`proxy.py`) has a tool whitelist and hides other runs' objects.
## 4. Layout
- `harness/mcp_client.py` MCP client (retry on 404 and on LOCK).
- `harness/adt_client.py` ADT deletion API.
- `harness/proxy.py` tool whitelist, budget, prefix filter, trajectory log.
- `harness/agents.py` oracle, null, llm (OpenAI-compatible; retries; ledger).
- `harness/runner.py` setup → agent → gates G1–G6 → hidden tests → own tests → ATC → abaplint → score → teardown.
- `harness/generator.py` task generator (cloud model writes a bundle; validation oracle = 100, null = 0; max 3 repairs).
- `harness/pilot.py` pilot list (20 tasks G0002–G0021).
- `harness/ledger.py` cost ledger and budget guard.
- `harness/cli.py` run, rescore, teardown, teardown-all, cleanup-list.
- `tasks/` hand-written tasks T01 (CLAS), T13 (FUNC), T14 (PROG + ALV), T15 (CDS). Oracle 100, null 0 for all.
- `tasks_gen/eval/`, `tasks_gen/train/` generated tasks (separate pools).
- `runs/` run results (git ignores it). `runs/_archive_v1` old runs.
## 5. Commands
```
python3 -m harness.cli run T01 --agent oracle|null|llm [--model NAME] --run N
python3 -m harness.cli rescore runs/<run_dir>
python3 -m harness.cli cleanup-list <PREFIX> runs/<dir> && python3 -m harness.cli teardown runs/<dir>
python3 -m harness.generator --id G0100 --pool eval --object-type CLAS --category C --run-base 2000
python3 -m harness.pilot 2
python3 -c "from harness.ledger import spent; print(spent())"
```
## 6. Current state (2026-10-02)
- Done: harness on Mac mini; test include creation in the server; teardown; FUNC/PROG/DDLS support;
task generator; ledger and budget guard.
- Results: T01 oracle 100, Qwen 27B 41.7 (old run 103), DeepSeek 98.5. T13 DeepSeek 85, T14 DeepSeek 100.
- Running: pilot (`harness.pilot`, 20 tasks) → `runs/gen/pilot.json`, log `runs/gen/pilot.log`.
A macOS notification shows when it ends.
- First generated task G0001: not accepted after 3 attempts (activation error, seed save error,
reference failed 1 hidden test). Feedback now includes the reference write/activation errors.
## 7. Next steps
1. Analyze the pilot: acceptance rate, failure causes, cost per task. Improve the generator prompt.
2. Step D: mutation check (hidden tests must fail on a broken reference).
3. Step F: eval set of 110 tasks: ~150 candidates, empirical filter (2–3 models), Claude review with a
checklist, Kral spot-checks ~10 flagged tasks.
4. Scoring for stop tasks (category H) and category K (free-text input, other tool schema).
5. Step 1.3b: generic ABAP MCP interface (spec `abap-mcp-arayuz.md`, later). The proxy is the first adapter.
## 8. Open items for Kral (server)
- Concurrency: queue calls per RFC connection, or use a connection pool.
- BDEF creation (needed for RAP tasks).

0
harness/__init__.py Normal file
View File

72
harness/adt_client.py Normal file
View File

@@ -0,0 +1,72 @@
"""Direct ADT HTTP access. Only for teardown (deletion). The model never uses it.
Credentials come from the environment (or .env): A4H_URL, A4H_USER, A4H_PASSWORD, A4H_CLIENT.
"""
import base64
import http.cookiejar
import os
import re
import urllib.request
from xml.sax.saxutils import quoteattr
DEL_REQ = "application/vnd.sap.adt.deletion.request.v1+xml"
DEL_RES = "application/vnd.sap.adt.deletion.response.v1+xml"
def load_env(path=".env"):
if os.path.exists(path):
for line in open(path):
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
class AdtClient:
def __init__(self):
load_env()
self.base = os.environ["A4H_URL"].rstrip("/")
self.client = os.environ.get("A4H_CLIENT", "001")
auth = f'{os.environ["A4H_USER"]}:{os.environ["A4H_PASSWORD"]}'
self.auth = "Basic " + base64.b64encode(auth.encode()).decode()
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
self.csrf = None
def _req(self, method, path, body=None, headers=None):
url = f"{self.base}{path}{'&' if '?' in path else '?'}sap-client={self.client}"
h = {"Authorization": self.auth}
if self.csrf:
h["x-csrf-token"] = self.csrf
h.update(headers or {})
req = urllib.request.Request(url, body.encode() if body else None, h, method=method)
with self.opener.open(req, timeout=300) as r:
return r.status, dict(r.headers), r.read().decode()
def fetch_csrf(self):
_, hdr, _ = self._req("GET", "/sap/bc/adt/discovery",
headers={"x-csrf-token": "fetch", "Accept": "*/*"})
self.csrf = hdr.get("x-csrf-token") or hdr.get("X-CSRF-Token")
def delete(self, uris):
"""Delete objects by ADT URI. Returns {uri: (deleted, message)}."""
if not uris:
return {}
if not self.csrf:
self.fetch_csrf()
objs = "".join(f"<del:object adtcore:uri={quoteattr(u)}><del:transportNumber/></del:object>"
for u in uris)
body = ('<?xml version="1.0" encoding="UTF-8"?>'
'<del:deletionRequest xmlns:del="http://www.sap.com/adt/deletion" '
'xmlns:adtcore="http://www.sap.com/adt/core">' + objs + '</del:deletionRequest>')
_, _, text = self._req("POST", "/sap/bc/adt/deletion/delete", body,
{"Content-Type": DEL_REQ, "Accept": DEL_RES})
out = {}
for m in re.finditer(r'<del:object\b([^>]*)>(.*?)</del:object>', text, re.S):
attrs, inner = m.group(1), m.group(2)
uri = re.search(r'adtcore:uri="([^"]+)"', attrs)
ok = re.search(r'del:isDeleted="([^"]+)"', attrs)
msg = re.search(r'<del:text>(.*?)</del:text>', inner, re.S)
out[uri.group(1) if uri else "?"] = (ok is not None and ok.group(1) == "true",
msg.group(1) if msg else "")
return out

139
harness/agents.py Normal file
View File

@@ -0,0 +1,139 @@
"""Agents: oracle (reference solution), null (does nothing), llm (OpenAI-compatible tool loop)."""
import json
import os
import time
import urllib.request
from .ledger import add_usage, check_budget
from .proxy import BudgetExceeded
SYSTEM_PROMPT = """You are an ABAP developer. You implement a plan on an SAP system with the tools.
Rules:
- Read the plan. Implement the contract exactly. Do not change a public signature in the contract.
- You make the craft decisions: table types, keys, access paths, SQL strategy, exception design,
and syntax that fits the release target.
- If a business rule is missing, or two rules contradict, stop. Do not create objects.
Write the gap in your report.
- Do not change an object in the out-of-scope list.
- Create a new object in package $TMP with sap_create_object. Then write the source with
sap_push_source.
- Read an object that you do not know before you use it.
- After each write, check the result. If the same check fails two times, stop and report.
- Do not add behavior that the plan does not ask for.
At the end, write a short report:
1. Decisions: one line for each craft decision, with the reason.
2. Objects: object, type, action, reason.
3. Verification: what you checked and the result.
4. Deviations and open points.
"""
class NullAgent:
name = "null"
def run(self, task, proxy):
proxy.note("final", "No action.")
return "No action."
class OracleAgent:
"""Writes the reference solution. Validates harness and scoring (expected score ~ max)."""
name = "oracle"
def run(self, task, proxy):
for o in task.objects("reference"):
ident = {"objectType": o["type"], "objectName": o["name"]}
if o.get("functionGroup"):
ident["functionGroup"] = o["functionGroup"]
proxy.call("sap_create_object", dict(ident, packageName="$TMP",
description=o.get("description", o["name"])[:60]))
if o.get("source"):
proxy.call("sap_push_source", dict(ident, source=o["source"]))
if o.get("testclasses_source"):
proxy.call("sap_push_source", dict(ident, includeType="testclasses",
source=o["testclasses_source"]))
proxy.note("final", "Reference solution written.")
return "Reference solution written."
class LlmAgent:
"""OpenAI-compatible chat completions with tool calls (Ollama, MLX server, vLLM, ...).
Local models are slow: no time limit by default (tool-call budget limits the run).
One request at a time; tool calls run in sequence.
"""
def __init__(self, model, base_url=None, api_key=None, max_turns=80, temperature=0.2,
max_seconds=None):
self.model = model
self.name = f"llm:{model}"
self.base_url = (base_url or os.environ.get("LLM_BASE_URL", "http://127.0.0.1:11434/v1")).rstrip("/")
self.api_key = api_key or os.environ.get("LLM_API_KEY", "none")
self.max_turns = max_turns
self.temperature = temperature
self.max_seconds = max_seconds
self.request_timeout = 3600 # local models are slow; a hung request still ends
def _chat(self, messages, tools):
body = {"model": self.model, "messages": messages, "tools": tools,
"temperature": self.temperature, "parallel_tool_calls": False}
req = urllib.request.Request(f"{self.base_url}/chat/completions", json.dumps(body).encode(),
{"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}"})
last = None
for attempt in range(4): # model server errors (HTTP 5xx, timeouts): retry with backoff
try:
with urllib.request.urlopen(req, timeout=self.request_timeout) as r:
data = json.loads(r.read().decode())
return data["choices"][0]["message"], data.get("usage", {})
except Exception as e: # noqa: BLE001
last = e
code = getattr(e, "code", None)
if code is not None and code < 500 and code != 429:
raise
time.sleep(10 * (attempt + 1))
raise RuntimeError(f"model request failed after retries: {last}")
def run(self, task, proxy):
tools = [{"type": "function", "function": {"name": t["name"],
"description": t.get("description", ""),
"parameters": t.get("inputSchema", {})}}
for t in proxy.schemas()]
messages = [{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": task.spec}]
if ":cloud" in self.model:
check_budget()
final = ""
start = time.time()
for _ in range(self.max_turns):
if self.max_seconds and time.time() - start > self.max_seconds:
final = f"Stopped: time budget exceeded ({self.max_seconds} s)."
break
try:
msg, usage = self._chat(messages, tools)
add_usage(self.model, usage, kind="run", ref=proxy.prefix)
except RuntimeError as e:
final = f"Stopped: {e}"
break
proxy.note("assistant", {"content": msg.get("content"),
"tool_calls": msg.get("tool_calls"), "usage": usage})
messages.append({k: v for k, v in msg.items() if k in ("role", "content", "tool_calls")})
calls = msg.get("tool_calls") or []
if not calls:
final = msg.get("content") or ""
break
try:
for c in calls:
fn = c["function"]
args = fn.get("arguments") or "{}"
args = json.loads(args) if isinstance(args, str) else args
err, text = proxy.call(fn["name"], args)
messages.append({"role": "tool", "tool_call_id": c.get("id", ""),
"content": ("ERROR: " if err else "") + text[:12000]})
except BudgetExceeded as e:
final = f"Stopped: budget exceeded ({e})."
break
proxy.note("final", final)
return final

89
harness/cli.py Normal file
View File

@@ -0,0 +1,89 @@
"""CLI.
python3 -m harness.cli run T01 --agent oracle|null|llm [--model NAME] [--run N] [--no-teardown]
python3 -m harness.cli teardown runs/<run_dir>
python3 -m harness.cli cleanup-list Z900001_ runs/cleanup_probe
"""
import argparse
import json
import os
from .agents import LlmAgent, NullAgent, OracleAgent
from .adt_client import load_env
from .runner import Runner, delete_uris
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def next_run_no():
runs = os.path.join(ROOT, "runs")
nums = [int(d[:3]) for d in os.listdir(runs) if d[:3].isdigit()]
return max(nums, default=0) + 1
def main():
load_env(os.path.join(ROOT, ".env"))
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
r = sub.add_parser("run")
r.add_argument("task")
r.add_argument("--agent", default="oracle", choices=["oracle", "null", "llm"])
r.add_argument("--model")
r.add_argument("--base-url")
r.add_argument("--run", type=int)
r.add_argument("--no-teardown", action="store_true")
r.add_argument("--max-minutes", type=float, help="optional time limit for the llm agent (default: none)")
t = sub.add_parser("teardown")
t.add_argument("run_dir")
sub.add_parser("teardown-all", help="delete objects of every run that has delete_uris.json and no teardown.json")
rs = sub.add_parser("rescore", help="score an existing run again (objects must still exist)")
rs.add_argument("run_dir")
c = sub.add_parser("cleanup-list", help="write delete_uris.json for all objects with a prefix")
c.add_argument("prefix")
c.add_argument("out_dir")
a = ap.parse_args()
if a.cmd == "run":
agent = {"oracle": OracleAgent, "null": NullAgent}.get(a.agent)
agent = agent() if agent else LlmAgent(a.model, a.base_url,
max_seconds=a.max_minutes * 60 if a.max_minutes else None)
runner = Runner(os.path.join(ROOT, "tasks"), os.path.join(ROOT, "runs"))
rep, run_dir = runner.run(a.task, agent, a.run or next_run_no(), teardown=not a.no_teardown)
print(json.dumps({k: rep[k] for k in ("task", "agent", "prefix", "gates", "score",
"tool_calls", "seconds", "teardown")}, indent=1))
print("run dir:", run_dir)
elif a.cmd == "cleanup-list":
from .mcp_client import McpClient
from .runner import DELETE_ORDER, Runner as R
os.makedirs(a.out_dir, exist_ok=True)
with McpClient() as m:
objs = R("", "")._objects_with_prefix(m, a.prefix.upper())
objs.sort(key=lambda o: DELETE_ORDER.index(o["objectType"]) if o["objectType"] in DELETE_ORDER else 99)
json.dump([o["uri"] for o in objs], open(os.path.join(a.out_dir, "delete_uris.json"), "w"), indent=1)
print(len(objs), "objects ->", os.path.join(a.out_dir, "delete_uris.json"))
elif a.cmd == "rescore":
rd = os.path.abspath(a.run_dir)
name = os.path.basename(rd)
run_no, task_id, agent_name = name.split("_", 2)
runner = Runner(os.path.join(ROOT, "tasks"), os.path.join(ROOT, "runs"))
agent = NullAgent()
agent.name = agent_name
rep, _ = runner.run(task_id, agent, int(run_no), teardown=True, rescore_dir=rd)
print(json.dumps({k: rep.get(k) for k in ("task", "agent", "gates", "score", "tool_calls",
"activations", "max_fail_streak", "agent_seconds")}, indent=1))
elif a.cmd == "teardown-all":
runs = os.path.join(ROOT, "runs")
for d in sorted(os.listdir(runs)):
rd = os.path.join(runs, d)
if os.path.exists(os.path.join(rd, "delete_uris.json")) and not os.path.exists(os.path.join(rd, "teardown.json")):
res = delete_uris(json.load(open(os.path.join(rd, "delete_uris.json"))))
json.dump(res, open(os.path.join(rd, "teardown.json"), "w"), indent=1)
print(d, sum(v["deleted"] for v in res.values()), "/", len(res), "deleted")
else:
uris = json.load(open(os.path.join(a.run_dir, "delete_uris.json")))
res = delete_uris(uris)
json.dump(res, open(os.path.join(a.run_dir, "teardown.json"), "w"), indent=1)
print(json.dumps(res, indent=1))
if __name__ == "__main__":
main()

242
harness/generator.py Normal file
View File

@@ -0,0 +1,242 @@
"""Task generator: a cloud model writes a task bundle; the harness validates it (oracle = 100, null = 0).
python3 -m harness.generator --id G0001 --pool eval --object-type CLAS --category C --difficulty 2
"""
import argparse
import json
import os
import re
import time
import urllib.request
from .adt_client import load_env
from .agents import NullAgent, OracleAgent
from .ledger import add_usage, check_budget
from .runner import Runner
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
EXAMPLE_FOR = {"CLAS": "T01", "INTF": "T01", "FUNC": "T13", "PROG": "T14", "DDLS": "T15", "TABL": "T15"}
CATEGORIES = {
"A": "pure logic in a new class (language, OO design, Clean ABAP)",
"B": "database access (ABAP SQL, CDS) with test doubles",
"C": "internal table craft (table kind, keys, access path)",
"D": "exception design (class-based exceptions, messages)",
"E": "refactoring legacy code (seed contains legacy code; behavior must stay the same)",
"F": "use an unknown object: the spec names a seed object but not its signature; the model must read it",
"G": "old release target (v702 or v740sp05): no newer syntax",
"I": "bug fix: the seed contains a faulty object; the spec describes the symptom",
}
SYSTEM = """You write evaluation tasks for an ABAP developer model. Each task is a bundle of files.
The model gets only spec.md and works on an SAP ABAP Platform 2025 system (SAP_BASIS 816, client 001)
through ADT tools. The harness installs the seed objects, runs the model, then checks the result with
hidden ABAP Unit tests, ATC, and abaplint.
Rules for the bundle:
- Use the placeholder {{P}} (upper case) and {{p}} (lower case) at the start of EVERY object name.
The harness replaces it with a run prefix of 9 characters (for example Z005P001_).
Name length after replacement: classes, programs, function modules, CDS entities max 30;
database tables max 16; function groups max 26.
- All objects are in package $TMP. Do not use transports.
- Do not use SAP application module data (no FI, SD, MM tables). Use generic business domains and
only objects that exist in every ABAP Platform system (language, ABAP SQL, CDS, CL_ABAP_*, CL_SALV_TABLE,
CL_OSQL_TEST_ENVIRONMENT, CL_CDS_TEST_ENVIRONMENT). Seed your own tables and data if needed.
- No dynpro (CALL SCREEN), no SmartForms, no BAdI, no RAP behavior definitions.
- spec.md uses Simplified Technical English and these sections in this order:
1. Goal, 2. Open questions (write "None."), 3. Context, 4. Contract, 5. Business rules,
6. Constraints (release target, coding standards, out of scope), 7. Acceptance.
The Contract fixes every public name the hidden tests use: object names, method signatures,
function module parameters, report parameters, ALV column names, CDS element names.
The Business rules are complete and unambiguous. Every rule is checked by at least one hidden test.
Do not tell the model HOW to implement (no table kinds, no SQL). Craft decisions belong to the model.
- Seed objects: TABL as DDL source ("define table ..."), FUGR without source, FUNC with "functionGroup"
and full source including the signature in the FUNCTION statement, classes with full source.
Seed table data: a seed class that implements IF_OO_ADT_CLASSRUN, with "run": true.
- Hidden tests: one global class, "FOR TESTING DURATION SHORT RISK LEVEL HARMLESS", 5 to 12 test
methods. Use only the public contract. Function modules: CALL FUNCTION with EXCEPTIONS.
Reports: SUBMIT ... AND RETURN with cl_salv_bs_runtime_info=>set( display = abap_false
metadata = abap_false data = abap_true ) and get_data_ref. CDS: cl_cds_test_environment.
- Reference solution: correct, Clean ABAP, methods below 40 statements, passes all hidden tests,
no ATC priority 1 or 2 findings (for example: pass large parameters by reference).
Include the model's expected own tests: for classes a "testclasses_file" (local test classes);
for reports local test classes inside the program; for function modules and CDS a global test class.
- task.json keys: id, category, object_type, difficulty, release_target, expected_outcome ("implement"),
budget {max_tool_calls, max_activations}, seed[], contract[], out_of_scope[], hidden_tests[],
reference[], craft_checks[]. Contract entries: CLAS {"implements"} optional; FUNC {"functionGroup",
"params":[{"name","type"}]}; PROG {"parameters":[...]}; DDLS {"fields":[...]}.
Return ONLY one JSON object, no markdown fence:
{"task": <task.json object>, "files": {"<relative path>": "<file content>", ...}}
"""
def bundle_of(task_dir):
files = {}
for base, _, names in os.walk(task_dir):
for n in names:
if n.startswith(".") or n == "generation.json":
continue
path = os.path.join(base, n)
rel = os.path.relpath(path, task_dir)
if rel != "task.json":
files[rel] = open(path).read()
return {"task": json.load(open(os.path.join(task_dir, "task.json"))), "files": files}
def chat(model, messages, base_url):
body = {"model": model, "messages": messages, "temperature": 0.7}
req = urllib.request.Request(f"{base_url}/chat/completions", json.dumps(body).encode(),
{"Content-Type": "application/json", "Authorization": "Bearer none"})
for attempt in range(4):
try:
with urllib.request.urlopen(req, timeout=1800) as r:
data = json.loads(r.read().decode())
add_usage(model, data.get("usage", {}), kind="generate")
return data["choices"][0]["message"].get("content") or ""
except Exception as e: # noqa: BLE001
if getattr(e, "code", 500) < 500 and getattr(e, "code", 500) != 429:
raise
time.sleep(15 * (attempt + 1))
raise RuntimeError("generation request failed")
def parse_bundle(text):
text = text.strip()
text = re.sub(r"^```(json)?\s*|\s*```$", "", text)
start, end = text.find("{"), text.rfind("}")
return json.loads(text[start:end + 1])
def check_bundle(b):
errs = []
t, files = b.get("task", {}), b.get("files", {})
for k in ("seed", "contract", "hidden_tests", "reference"):
if k not in t:
errs.append(f"task.json misses '{k}'")
if "spec.md" not in files:
errs.append("spec.md missing")
for k in ("seed", "hidden_tests", "reference"):
for o in t.get(k, []):
for fk in ("file", "testclasses_file"):
if fk in o and o[fk] not in files:
errs.append(f"{k}: file {o[fk]} missing")
if not o.get("name", "").startswith("{{P}}"):
errs.append(f"{k}: name {o.get('name')} does not start with {{{{P}}}}")
limit = 16 if o.get("type") == "TABL" else 26 if o.get("type") == "FUGR" else 30
if len(o.get("name", "").replace("{{P}}", "Z0000000_")) > limit:
errs.append(f"{k}: name {o.get('name')} too long (max {limit})")
if not t.get("hidden_tests"):
errs.append("no hidden test class")
return errs
def write_bundle(b, task_dir, task_id):
os.makedirs(task_dir, exist_ok=True)
b["task"]["id"] = task_id
json.dump(b["task"], open(os.path.join(task_dir, "task.json"), "w"), indent=2)
for rel, content in b["files"].items():
path = os.path.join(task_dir, rel)
os.makedirs(os.path.dirname(path), exist_ok=True)
open(path, "w").write(content)
def validate(pool_root, task_id, run_base):
r = Runner(pool_root, os.path.join(ROOT, "runs", "gen"))
os.makedirs(os.path.join(ROOT, "runs", "gen"), exist_ok=True)
rep_o, dir_o = r.run(task_id, OracleAgent(), run_base)
rep_o["_dir"] = dir_o
rep_n, _ = r.run(task_id, NullAgent(), run_base + 1)
return rep_o, rep_n
def write_errors(run_dir):
"""Failed writes and activation messages of the oracle run."""
out = []
path = os.path.join(run_dir or "", "trajectory.jsonl")
if not os.path.exists(path):
return out
for line in open(path):
e = json.loads(line)
if e.get("tool") in ("sap_create_object", "sap_push_source") and (
e["is_error"] or '"success":false' in e["result"].replace(" ", "")):
out.append({"tool": e["tool"], "object": e["args"].get("objectName"),
"include": e["args"].get("includeType", "main"), "result": e["result"][:1500]})
return out
def failure_summary(rep):
out = {"reference_write_errors": write_errors(rep.get("_dir")),"gates": rep.get("gates"), "score": rep.get("score"), "setup": rep.get("setup"),
"atc": rep.get("atc"), "abaplint": rep.get("abaplint"), "own_tests": rep.get("own_tests"),
"hidden_failed": [d for d in rep.get("hidden_tests", {}).get("detail", []) if not d["ok"]],
"hidden_install": rep.get("hidden_tests", {}).get("install")}
return json.dumps(out)[:6000]
def generate(task_id, pool, object_type, category, difficulty, model, base_url, run_base, topic=None,
max_repairs=3):
check_budget()
pool_root = os.path.join(ROOT, "tasks_gen", pool)
task_dir = os.path.join(pool_root, task_id)
example = bundle_of(os.path.join(ROOT, "tasks", EXAMPLE_FOR[object_type]))
ask = (f"Write one new task.\nObject type of the main contract object: {object_type}.\n"
f"Skill category {category}: {CATEGORIES[category]}.\nDifficulty {difficulty} of 3.\n"
+ (f"Topic idea: {topic}\n" if topic else "Choose a new, realistic business topic.\n")
+ "Here is an example bundle of a different task (same format):\n" + json.dumps(example))
messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": ask}]
log = {"id": task_id, "pool": pool, "object_type": object_type, "category": category, "attempts": []}
for attempt in range(max_repairs + 1):
text = chat(model, messages, base_url)
messages.append({"role": "assistant", "content": text})
try:
b = parse_bundle(text)
errs = check_bundle(b)
except Exception as e: # noqa: BLE001
b, errs = None, [f"invalid JSON: {e}"]
if errs:
log["attempts"].append({"stage": "bundle", "errors": errs})
messages.append({"role": "user", "content": "Fix these problems and return the full bundle again:\n"
+ "\n".join(errs)})
continue
b["task"].setdefault("object_type", object_type)
b["task"].setdefault("category", category)
write_bundle(b, task_dir, task_id)
rep_o, rep_n = validate(pool_root, task_id, run_base + 2 * attempt)
so, sn = (rep_o.get("score") or {}).get("total"), (rep_n.get("score") or {}).get("total")
log["attempts"].append({"stage": "validate", "oracle": so, "null": sn})
if so == 100 and sn == 0:
log["accepted"] = True
break
messages.append({"role": "user", "content":
"The harness ran your reference solution (oracle) and an empty solution (null). "
f"Required: oracle 100, null 0. Result: oracle {so}, null {sn}.\n"
f"Oracle report: {failure_summary(rep_o)}\n"
"Fix the bundle (reference, hidden tests, seed, or contract) and return the full "
"bundle again."})
else:
log["accepted"] = False
json.dump(log, open(os.path.join(task_dir if os.path.isdir(task_dir) else pool_root, "generation.json"), "w"),
indent=1)
return log
def main():
load_env(os.path.join(ROOT, ".env"))
ap = argparse.ArgumentParser()
ap.add_argument("--id", required=True)
ap.add_argument("--pool", default="eval", choices=["eval", "train"])
ap.add_argument("--object-type", required=True, choices=sorted(EXAMPLE_FOR))
ap.add_argument("--category", required=True, choices=sorted(CATEGORIES))
ap.add_argument("--difficulty", type=int, default=2)
ap.add_argument("--topic")
ap.add_argument("--model", default="deepseek-v4.1-flash:cloud")
ap.add_argument("--base-url", default=os.environ.get("LLM_BASE_URL", "http://127.0.0.1:11434/v1"))
ap.add_argument("--run-base", type=int, required=True)
a = ap.parse_args()
os.makedirs(os.path.join(ROOT, "tasks_gen", a.pool), exist_ok=True)
log = generate(a.id, a.pool, a.object_type, a.category, a.difficulty, a.model, a.base_url,
a.run_base, a.topic)
print(json.dumps(log))
if __name__ == "__main__":
main()

42
harness/ledger.py Normal file
View File

@@ -0,0 +1,42 @@
"""Cost ledger for cloud model usage and a budget guard.
.env: BUDGET_LIMIT_USD (default 45), BUDGET_CYCLE_START (YYYY-MM-DD, default 2026-10-02).
Prices are DeepSeek V4.1 Flash list prices without cache discount (upper bound).
"""
import json
import os
import time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LEDGER = os.path.join(ROOT, "runs", "ledger.jsonl")
PRICE_IN, PRICE_OUT = 0.30 / 1e6, 1.20 / 1e6
class BudgetExceeded(Exception):
pass
def add_usage(model, usage, kind, ref=""):
if ":cloud" not in model:
return
pin, pout = usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0)
os.makedirs(os.path.dirname(LEDGER), exist_ok=True)
with open(LEDGER, "a") as f:
f.write(json.dumps({"t": time.time(), "day": time.strftime("%Y-%m-%d"), "model": model, "kind": kind,
"ref": ref, "in": pin, "out": pout,
"usd": round(pin * PRICE_IN + pout * PRICE_OUT, 5)}) + "\n")
def spent(since=None):
since = since or os.environ.get("BUDGET_CYCLE_START", "2026-10-02")
if not os.path.exists(LEDGER):
return 0.0
return round(sum(json.loads(l)["usd"] for l in open(LEDGER) if json.loads(l)["day"] >= since), 4)
def check_budget():
limit = float(os.environ.get("BUDGET_LIMIT_USD", "45"))
s = spent()
if s >= limit:
raise BudgetExceeded(f"cycle spend {s} USD >= limit {limit} USD")
return s

96
harness/mcp_client.py Normal file
View File

@@ -0,0 +1,96 @@
"""Minimal MCP streamable-http client. One session per worker."""
import json
import os
import random
import time
import urllib.request
import urllib.error
class McpError(Exception):
pass
class McpClient:
def __init__(self, url=None, token=None, timeout=300):
self.url = url or os.environ.get("MCP_URL", "http://127.0.0.1:3000/mcp")
self.token = token or os.environ.get("MCP_TOKEN", "")
self.timeout = timeout
self.sid = None
self._id = 0
self._lock_retries = 0
self.lock_retry_total = 0
def _post(self, body, method="POST"):
h = {"Content-Type": "application/json",
"Accept": "application/json, text/event-stream"}
if self.token:
h["Authorization"] = f"Bearer {self.token}"
if self.sid:
h["Mcp-Session-Id"] = self.sid
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(self.url, data, h, method=method)
with urllib.request.urlopen(req, timeout=self.timeout) as r:
sid = r.headers.get("Mcp-Session-Id")
raw = r.read().decode()
if sid:
self.sid = sid
if "data:" in raw[:40]:
raw = "".join(l[5:].strip() for l in raw.splitlines() if l.startswith("data:"))
return json.loads(raw) if raw.strip() else None
def open(self):
self._id += 1
self._post({"jsonrpc": "2.0", "id": self._id, "method": "initialize",
"params": {"protocolVersion": "2025-03-26", "capabilities": {},
"clientInfo": {"name": "abap-llm-harness", "version": "0.1"}}})
self._post({"jsonrpc": "2.0", "method": "notifications/initialized"})
return self
def close(self):
if self.sid:
try:
self._post(None, method="DELETE")
except Exception:
pass
self.sid = None
def list_tools(self):
self._id += 1
res = self._post({"jsonrpc": "2.0", "id": self._id, "method": "tools/list"})
return res["result"]["tools"]
def call(self, name, args, retry=True):
"""Return (is_error, text)."""
self._id += 1
body = {"jsonrpc": "2.0", "id": self._id, "method": "tools/call",
"params": {"name": name, "arguments": args}}
try:
res = self._post(body)
except urllib.error.HTTPError as e:
if e.code == 404 and retry: # session dropped (capacity limit)
self.sid = None
self.open()
return self.call(name, args, retry=False)
raise McpError(f"{name}: HTTP {e.code}") from e
if "error" in res:
return True, json.dumps(res["error"])
r = res["result"]
text = "\n".join(c.get("text", "") for c in r.get("content", []))
# The server shares one RFC connection between sessions. A parallel call gets
# "Concurrent call detected" instead of waiting. Retry; the caller never sees it.
if "Concurrent call detected" in text and self._lock_retries < 8:
self._lock_retries += 1
time.sleep(0.5 * self._lock_retries + random.random())
try:
return self.call(name, args, retry)
finally:
self._lock_retries = 0
self.lock_retry_total += self._lock_retries
return bool(r.get("isError")), text
def __enter__(self):
return self.open()
def __exit__(self, *a):
self.close()

48
harness/pilot.py Normal file
View File

@@ -0,0 +1,48 @@
"""Pilot: generate and validate a list of tasks, one after the other."""
import json
import os
import sys
from .adt_client import load_env
from .generator import ROOT, generate
from .ledger import BudgetExceeded, spent
PLAN = [
("CLAS", "A"), ("CLAS", "B"), ("CLAS", "C"), ("CLAS", "D"), ("CLAS", "E"),
("CLAS", "F"), ("CLAS", "G"), ("CLAS", "I"), ("CLAS", "C"),
("FUNC", "A"), ("FUNC", "F"), ("FUNC", "I"),
("PROG", "B"), ("PROG", "B"), ("PROG", "E"),
("DDLS", "B"), ("DDLS", "B"), ("DDLS", "B"), ("DDLS", "B"), ("DDLS", "B"),
]
DDLS_TOPICS = ["join of two tables and a calculated element", "association and path expression",
"aggregation with CASE and GROUP BY", "view with input parameters",
"UNION of two tables with a literal source element"]
def main():
load_env(os.path.join(ROOT, ".env"))
first = int(sys.argv[1]) if len(sys.argv) > 1 else 2
results = []
d = 0
for k, (otype, cat) in enumerate(PLAN):
tid = f"G{first + k:04d}"
topic = None
if otype == "DDLS":
topic, d = DDLS_TOPICS[d], d + 1
try:
log = generate(tid, "eval", otype, cat, 2, "deepseek-v4.1-flash:cloud",
os.environ.get("LLM_BASE_URL", "http://127.0.0.1:11434/v1"),
1100 + 10 * k, topic)
except BudgetExceeded as e:
print("BUDGET", e, flush=True)
break
except Exception as e: # noqa: BLE001
log = {"id": tid, "error": str(e)[:500]}
log["spent_total"] = spent()
results.append(log)
print(json.dumps(log), flush=True)
json.dump(results, open(os.path.join(ROOT, "runs", "gen", "pilot.json"), "w"), indent=1)
if __name__ == "__main__":
main()

80
harness/proxy.py Normal file
View File

@@ -0,0 +1,80 @@
"""Tool proxy between agent and MCP: whitelist, budget, prefix filter, trajectory log."""
import json
import re
import time
MODEL_TOOLS = {
"sap_search_object", "sap_pull_source", "sap_object_structure", "sap_object_members",
"sap_usage_references", "sap_element_info", "sap_inactive_objects", "sap_short_dumps",
"sap_sql_query", "sap_create_object", "sap_push_source", "sap_push_element",
"sap_push_message", "sap_activate", "sap_syntax_check", "sap_check_object",
"sap_run_unit_test", "sap_atc_run", "sap_pretty_print", "sap_run_class",
}
WRITE_TOOLS = {"sap_create_object", "sap_push_source", "sap_push_element",
"sap_push_message", "sap_activate"}
RUN_PREFIX = re.compile(r"^Z\d[0-9A-Z]{5,6}_", re.I)
class BudgetExceeded(Exception):
pass
class ToolProxy:
def __init__(self, mcp, prefix, budget, log_path):
self.mcp = mcp
self.prefix = prefix.upper()
self.max_calls = budget.get("max_tool_calls", 60)
self.max_activations = budget.get("max_activations", 15)
self.calls = 0
self.activations = 0
self.fail_streak = 0
self.max_fail_streak = 0
self.log = open(log_path, "a")
def schemas(self):
return [t for t in self.mcp.list_tools() if t["name"] in MODEL_TOOLS]
def _foreign(self, name):
n = (name or "").upper()
return bool(RUN_PREFIX.match(n)) and not n.startswith(self.prefix)
def _filter(self, tool, text):
if tool not in ("sap_search_object", "sap_usage_references"):
return text
try:
data = json.loads(text)
except ValueError:
return text
if isinstance(data, list):
data = [d for d in data if not self._foreign(d.get("name") if isinstance(d, dict) else "")]
return json.dumps(data)
return text
def call(self, tool, args):
entry = {"t": time.time(), "tool": tool, "args": args}
if tool not in MODEL_TOOLS:
result = (True, f"Tool {tool} is not available.")
elif self.calls >= self.max_calls:
raise BudgetExceeded(f"max_tool_calls={self.max_calls}")
else:
self.calls += 1
name = str(args.get("objectName", "")).upper()
if tool in WRITE_TOOLS and self._foreign(name):
result = (True, f"{name} is not available.")
else:
if tool in WRITE_TOOLS and (tool == "sap_activate" or args.get("activate", True)):
self.activations += 1
err, text = self.mcp.call(tool, args)
result = (err, self._filter(tool, text))
if tool in WRITE_TOOLS:
failed = err or '"success":false' in text.replace(" ", "")
self.fail_streak = self.fail_streak + 1 if failed else 0
self.max_fail_streak = max(self.max_fail_streak, self.fail_streak)
entry["is_error"], entry["result"] = result[0], result[1][:20000]
self.log.write(json.dumps(entry) + "\n")
self.log.flush()
return result
def note(self, kind, content):
self.log.write(json.dumps({"t": time.time(), "kind": kind, "content": content}) + "\n")
self.log.flush()

394
harness/runner.py Normal file
View File

@@ -0,0 +1,394 @@
"""One task run: setup -> agent -> collect -> gates -> tests -> checks -> score -> teardown."""
import json
import os
import re
import subprocess
import time
from .adt_client import AdtClient, load_env
from .mcp_client import McpClient
from .proxy import ToolProxy
from .task import Task, prefix_for
ABAPLINT = os.path.join(os.path.dirname(__file__), "..", "node_modules", ".bin", "abaplint")
CLEAN_RULES = {
"unused_variables": True, "unused_types": True, "prefer_xsdbool": True, "use_new": True,
"prefer_returning_to_exporting": True, "functional_writing": True,
"preferred_compare_operator": True, "use_line_exists": True, "line_length": {"length": 120},
"max_one_statement": True, "empty_statement": True, "commented_code": True,
"omit_parameter_name": True, "prefer_is_not": True, "exporting": True,
}
DELETE_ORDER = ["CLAS", "INTF", "PROG", "FUNC", "FUGR", "SRVD", "DDLX", "DCLS", "DDLS",
"TTYP", "TABL", "STRU", "DTEL", "DOMA", "MSAG"]
SOURCE_TYPES = ("CLAS", "INTF", "PROG", "FUNC", "DDLS", "DCLS", "DDLX", "TABL")
def _obj_args(otype, name, fg=None):
a = {"objectType": otype, "objectName": name}
if fg:
a["functionGroup"] = fg
return a
def _json(text):
try:
return json.loads(text)
except ValueError:
return None
def _norm(src):
return "\n".join(l.rstrip().lower() for l in (src or "").splitlines() if l.strip())
def trajectory_stats(path):
"""Rebuild agent statistics from a trajectory (same rules as ToolProxy)."""
from .proxy import WRITE_TOOLS
calls = activations = streak = max_streak = 0
final, t_first, t_last = "", None, None
for line in open(path):
e = json.loads(line)
t_first = t_first or e["t"]
t_last = e["t"]
if e.get("kind") == "final":
final = e.get("content") or ""
if "tool" not in e or e["result"].startswith("Tool ") and e["is_error"]:
continue
calls += 1
tool, args = e["tool"], e.get("args", {})
if tool in WRITE_TOOLS:
if tool == "sap_activate" or args.get("activate", True):
activations += 1
failed = e["is_error"] or '"success":false' in e["result"].replace(" ", "")
streak = streak + 1 if failed else 0
max_streak = max(max_streak, streak)
return {"tool_calls": calls, "activations": activations, "max_fail_streak": max_streak,
"final_report": final, "agent_seconds": round((t_last or 0) - (t_first or 0), 1)}
class Runner:
def __init__(self, tasks_root, runs_root):
self.tasks_root = tasks_root
self.runs_root = runs_root
# ---------- helpers ----------
def _install(self, mcp, objs):
out = []
for o in objs:
fg = o.get("functionGroup")
cargs = dict(_obj_args(o["type"], o["name"], fg), packageName="$TMP",
description=o.get("description", o["name"])[:60])
e1, t1 = mcp.call("sap_create_object", cargs)
ok, t2 = not e1, ""
if o.get("source"):
e2, t2 = mcp.call("sap_push_source", dict(_obj_args(o["type"], o["name"], fg),
source=o["source"]))
ok = not e2 and (_json(t2) or {}).get("success", False)
if o.get("testclasses_source"):
e3, t3 = mcp.call("sap_push_source", dict(_obj_args(o["type"], o["name"], fg),
includeType="testclasses",
source=o["testclasses_source"]))
ok = ok and not e3 and (_json(t3) or {}).get("success", False)
if o.get("run"):
e4, t4 = mcp.call("sap_run_class", {"className": o["name"]})
ok = ok and not e4 and (_json(t4) or {}).get("success", False)
out.append({"name": o["name"], "ok": ok, "create": t1[:300], "push": t2[:500]})
return out
def _objects_with_prefix(self, mcp, prefix):
_, text = mcp.call("sap_search_object", {"query": prefix + "*", "maxResults": 200})
return [d for d in (_json(text) or []) if d.get("name", "").upper().startswith(prefix)
and d.get("objectType")] # skips STOB entries of CDS entities
def _source(self, mcp, otype, name, fg=None):
err, text = mcp.call("sap_pull_source", _obj_args(otype, name, fg))
return None if err else text
def _abaplint(self, run_dir, task, sources):
d = os.path.join(run_dir, "abaplint")
os.makedirs(os.path.join(d, "src"), exist_ok=True)
ext = {"CLAS": "clas", "INTF": "intf", "PROG": "prog"}
for (otype, name), src in sources.items():
if otype not in ext:
continue
base = os.path.join(d, "src", f"{name.lower()}.{ext[otype]}")
open(base + ".abap", "w").write(src)
if otype == "PROG":
open(base + ".xml", "w").write(
f'<?xml version="1.0" encoding="utf-8"?><abapGit version="v1.0.0" serializer="LCL_OBJECT_PROG" '
f'serializer_version="v1.0.0"><asx:abap xmlns:asx="http://www.sap.com/abapxml" version="1.0">'
f'<asx:values><PROGDIR><NAME>{name}</NAME><SUBC>1</SUBC><RLOAD>E</RLOAD><FIXPT>X</FIXPT>'
f'<UCCHECK>X</UCCHECK></PROGDIR></asx:values></asx:abap></abapGit>')
continue
tag = "VSEOCLASS" if otype == "CLAS" else "VSEOINTERF"
ser = "LCL_OBJECT_CLAS" if otype == "CLAS" else "LCL_OBJECT_INTF"
open(base + ".xml", "w").write(
f'<?xml version="1.0" encoding="utf-8"?><abapGit version="v1.0.0" serializer="{ser}" '
f'serializer_version="v1.0.0"><asx:abap xmlns:asx="http://www.sap.com/abapxml" version="1.0">'
f'<asx:values><{tag}><CLSNAME>{name}</CLSNAME><LANGU>E</LANGU><DESCRIPT>x</DESCRIPT>'
f'<STATE>1</STATE><UNICODE>X</UNICODE></{tag}></asx:values></asx:abap></abapGit>')
rules = {"check_syntax": True, "unknown_types": True}
rules.update(CLEAN_RULES)
for r in task.meta.get("craft_checks", []):
rules[r] = {"statements": 40} if r == "method_length" else True
cfg = {"global": {"files": "/src/**/*.*"}, "dependencies": [],
"syntax": {"version": task.meta.get("release_target", "v758"),
# objects of this run that abaplint cannot read (TABL, DDLS, FUNC) are not errors;
# A4H syntax check (G1) covers them
"errorNamespace": f"^(?!{task.prefix})(Z|Y|LCL_|TY_|LIF_|LTC_)"},
"rules": rules}
json.dump(cfg, open(os.path.join(d, "abaplint.json"), "w"), indent=1)
p = subprocess.run([ABAPLINT, "abaplint.json", "-f", "json"], cwd=d,
capture_output=True, text=True, timeout=300)
try:
issues = json.loads(p.stdout or "[]")
except ValueError:
issues = [{"key": "abaplint_failed", "description": (p.stdout + p.stderr)[:500],
"file": {"filename": ""}}]
def _fname(i):
f = i.get("file", "")
return f.get("filename", "") if isinstance(f, dict) else str(f)
return [{"rule": i.get("key"), "file": os.path.basename(_fname(i)),
"line": i.get("start", {}).get("row"), "msg": i.get("description")} for i in issues]
# ---------- main ----------
def run(self, task_id, agent, run_no, teardown=True, rescore_dir=None):
prefix = prefix_for(run_no, task_id)
task = Task(os.path.join(self.tasks_root, task_id), prefix)
run_dir = rescore_dir or os.path.join(self.runs_root, f"{int(run_no):03d}_{task_id}_{agent.name.replace(':', '_').replace('/', '_')}")
os.makedirs(run_dir, exist_ok=True)
rep = {"task": task_id, "agent": agent.name, "run": run_no, "prefix": prefix,
"started": time.strftime("%Y-%m-%dT%H:%M:%S")}
t0 = time.time()
with McpClient() as mcp:
# 1 setup
seed = task.objects("seed")
if rescore_dir:
seed_src = {o["name"].upper(): o["source"] for o in seed}
else:
rep["setup"] = self._install(mcp, seed)
if not all(x["ok"] for x in rep["setup"]):
rep["setup_failed"] = True
rep["score"] = {"total": None, "note": "setup failed; run not scored"}
all_objs = self._objects_with_prefix(mcp, prefix)
rep["teardown"] = self._teardown(all_objs, run_dir) if teardown else "skipped"
json.dump(rep, open(os.path.join(run_dir, "report.json"), "w"), indent=1)
return rep, run_dir
seed_src = {o["name"].upper(): self._source(mcp, o["type"], o["name"], o.get("functionGroup"))
for o in seed}
# 2 run
if rescore_dir:
rep.update(trajectory_stats(os.path.join(run_dir, "trajectory.jsonl")))
rep["rescored"] = True
else:
proxy = ToolProxy(mcp, prefix, task.meta.get("budget", {}),
os.path.join(run_dir, "trajectory.jsonl"))
proxy.note("spec", task.spec)
t1 = time.time()
rep["final_report"] = agent.run(task, proxy)
rep["agent_seconds"] = round(time.time() - t1, 1)
rep["tool_calls"], rep["activations"] = proxy.calls, proxy.activations
rep["max_fail_streak"] = proxy.max_fail_streak
# 3 collect
hidden_names = {o["name"].upper() for o in task.objects("hidden_tests")}
seed_names = set(seed_src)
objs = self._objects_with_prefix(mcp, prefix)
model_objs = [o for o in objs if o["name"].upper() not in seed_names | hidden_names]
sources = {}
for o in objs:
if o["objectType"] in SOURCE_TYPES:
src = self._source(mcp, o["objectType"], o["name"], o.get("functionGroup"))
if src is not None:
sources[(o["objectType"], o["name"].upper())] = src
os.makedirs(os.path.join(run_dir, "sources"), exist_ok=True)
for (t, n), s in sources.items():
open(os.path.join(run_dir, "sources", f"{n.lower()}.{t.lower()}.abap"), "w").write(s)
rep["model_objects"] = [o["name"] for o in model_objs]
# 4 gates
g = {}
_, inact = mcp.call("sap_inactive_objects", {})
inactive = {d.get("name", "").upper() for d in (_json(inact) or []) if isinstance(d, dict)}
contract = task.objects("contract")
g1 = []
for c in contract:
exists = any(o["name"].upper() == c["name"].upper() for o in objs)
ok = exists and c["name"].upper() not in inactive
if ok:
_, st = mcp.call("sap_syntax_check", _obj_args(c["type"], c["name"], c.get("functionGroup")))
ok = (_json(st) or {}).get("errorCount", 1) == 0
g1.append({"name": c["name"], "ok": ok})
g["G1_active"] = all(x["ok"] for x in g1) and bool(g1)
g2 = True
for c in contract:
src = sources.get((c["type"], c["name"].upper()), "")
if c.get("implements") and not re.search(rf"INTERFACES\s+{re.escape(c['implements'])}\b", src, re.I):
g2 = False
if c["type"] == "FUNC": # signature: every parameter with its type in the FUNCTION header
header = src.split(".", 1)[0]
for prm in c.get("params", []):
if not re.search(rf"\b{re.escape(prm['name'])}\b\)?\s+TYPE\s+{re.escape(prm['type'])}\b",
header, re.I):
g2 = False
if c["type"] == "PROG": # selection screen parameters
for prm in c.get("parameters", []):
if not re.search(rf"(PARAMETERS|SELECT-OPTIONS)\s*:?[^.]*\b{re.escape(prm)}\b", src, re.I):
g2 = False
if c["type"] == "DDLS" and c.get("fields") and g["G1_active"]:
_, q = mcp.call("sap_sql_query", {"query": f"SELECT * FROM {c['name']}", "maxRows": 1})
cols = {col.get("name", "").upper() for col in (_json(q) or {}).get("columns", [])}
if not {f.upper() for f in c["fields"]} <= cols:
g2 = False
g["G2_contract"] = g2 and g["G1_active"]
g["G4_out_of_scope"] = all(
_norm(self._source(mcp, o["type"], o["name"], o.get("functionGroup")))
== _norm(seed_src[o["name"].upper()])
for o in seed)
atc = []
for c in contract:
if g["G1_active"]:
_, t = mcp.call("sap_atc_run", _obj_args(c["type"], c["name"], c.get("functionGroup")))
for f in (_json(t) or []):
if isinstance(f, dict):
atc.append({"obj": c["name"], "prio": str(f.get("priority", "")),
"check": f.get("checkTitle") or f.get("checkId"),
"msg": (f.get("messageTitle") or f.get("text") or "")[:200]})
rep["atc"] = atc
g["G5_no_p1"] = not any(a["prio"] == "1" for a in atc)
# 5 hidden tests
hidden = {"total": 0, "passed": 0, "detail": []}
if g["G2_contract"]:
inst = self._install(mcp, task.objects("hidden_tests"))
hidden["install"] = inst
for o in task.objects("hidden_tests"):
_, t = mcp.call("sap_run_unit_test", {"objectType": "CLAS", "objectName": o["name"]})
for prog in (_json(t) or {}).get("programs", []):
for tc in prog.get("testClasses", []):
for m in tc.get("testMethods", []):
ok = not m.get("alerts")
hidden["total"] += 1
hidden["passed"] += ok
hidden["detail"].append({"method": m["name"], "ok": ok,
"alerts": json.dumps(m.get("alerts"))[:300]})
rep["hidden_tests"] = hidden
g["G3_hidden_runs"] = hidden["passed"] > 0
# 6 own tests (local test include or own global test class)
own = {"tests": 0, "failures": 0, "coverage": None}
for c in contract:
if c["type"] == "CLAS" and g["G1_active"]:
_, t = mcp.call("sap_check_object", {"objectType": "CLAS", "objectName": c["name"],
"runAtc": False, "runUnitTest": True, "coverage": True})
for st in (_json(t) or {}).get("steps", []):
if st.get("step") == "unittest":
own["tests"] += st.get("tests", 0)
own["failures"] += st.get("failures", 0)
cov = st.get("coverage", {}).get("class", {}).get("statement", {})
own["coverage"] = cov.get("percent")
if c["type"] == "PROG" and g["G1_active"]: # local test classes inside the report
_, t = mcp.call("sap_run_unit_test", {"objectType": "PROG", "objectName": c["name"]})
for prog in (_json(t) or {}).get("programs", []):
for tc in prog.get("testClasses", []):
for m in tc.get("testMethods", []):
own["tests"] += 1
own["failures"] += bool(m.get("alerts"))
# own global test classes (FM and CDS tasks; also classes)
contract_names = {c["name"].upper() for c in contract}
for (otype, name), src in sources.items():
if (otype == "CLAS" and name not in contract_names | seed_names | hidden_names
and re.search(r"FOR\s+TESTING", src, re.I)):
_, t = mcp.call("sap_run_unit_test", {"objectType": "CLAS", "objectName": name})
for prog in (_json(t) or {}).get("programs", []):
for tc in prog.get("testClasses", []):
for m in tc.get("testMethods", []):
own["tests"] += 1
own["failures"] += bool(m.get("alerts"))
own.setdefault("global_test_classes", []).append(name)
rep["own_tests"] = own
# 7 abaplint
lint_sources = {k: v for k, v in sources.items() if k[1] not in hidden_names}
issues = self._abaplint(run_dir, task, lint_sources)
model_files = {f"{n.lower()}.{t.lower()}.abap" for (t, n) in lint_sources if n not in seed_names}
issues = [i for i in issues if i["file"] in model_files or i["rule"] == "abaplint_failed"]
rep["abaplint"] = issues
# Release gate only on contract objects. Helper objects (smoke runners, test classes)
# can use standard APIs that abaplint does not know; A4H syntax check covers them.
contract_files = {f"{c['name'].lower()}.{c['type'].lower()}.abap" for c in contract}
g["G6_release"] = not any(i["rule"] == "check_syntax" and i["file"] in contract_files
for i in issues)
# 8 score
rep["gates"] = g
rep["score"] = self._score(task, rep)
rep["seconds"] = round(time.time() - t0, 1)
# 9 teardown
all_objs = self._objects_with_prefix(mcp, prefix)
rep["teardown"] = self._teardown(all_objs, run_dir) if teardown else "skipped"
json.dump(rep, open(os.path.join(run_dir, "report.json"), "w"), indent=1)
return rep, run_dir
def _score(self, task, rep):
g = rep["gates"]
if task.meta.get("expected_outcome") == "stop":
return {"total": None, "note": "stop-task scoring not implemented in skeleton"}
if not all(g.values()):
return {"total": 0, "failed_gates": [k for k, v in g.items() if not v]}
h = rep["hidden_tests"]
correctness = 40 * h["passed"] / max(h["total"], 1)
craft_rules = set(task.meta.get("craft_checks", []))
contract_files = {f"{c['name'].lower()}.{c['type'].lower()}.abap" for c in task.objects("contract")}
craft_n = sum(1 for i in rep["abaplint"] if i["rule"] in craft_rules and i["file"] in contract_files)
craft_n += sum(1 for a in rep["atc"] if a["prio"] == "2")
craft = 20 * max(0.0, 1 - 0.25 * craft_n)
clean_n = sum(1 for i in rep["abaplint"] if i["rule"] in CLEAN_RULES)
clean = 15 * max(0.0, 1 - 0.1 * clean_n)
own = rep["own_tests"]
own_score = 0.0
if own["tests"] > 0 and own["failures"] == 0:
own_score += 7.5
if own["coverage"] is not None:
own_score += 7.5 * min(1.0, own["coverage"] / 70.0)
elif own_score: # no coverage figure for this object type (FUNC, PROG, DDLS): tests count fully
own_score = 15.0
b = task.meta.get("budget", {})
disc = 10.0
if rep["tool_calls"] > b.get("max_tool_calls", 60):
disc -= 5
if rep["activations"] > b.get("max_activations", 15):
disc -= 5
if rep.get("max_fail_streak", 0) > 2: # rule: stop after two failures of the same check
disc -= 5
final = (rep.get("final_report") or "").strip()
if not final or final.startswith("Stopped:"): # no report from the model
disc -= 5
disc = max(disc, 0.0)
parts = {"correctness": round(correctness, 1), "craft": round(craft, 1), "clean": round(clean, 1),
"own_tests": round(own_score, 1), "discipline": disc}
parts["total"] = round(sum(parts.values()), 1)
parts["note"] = "own_tests: faulty-reference part not implemented in skeleton"
return parts
def _teardown(self, objs, run_dir):
objs = sorted(objs, key=lambda o: DELETE_ORDER.index(o["objectType"])
if o["objectType"] in DELETE_ORDER else 99)
uris = [o["uri"] for o in objs]
json.dump(uris, open(os.path.join(run_dir, "delete_uris.json"), "w"), indent=1)
load_env()
if not os.environ.get("A4H_PASSWORD"):
return {"status": "pending", "count": len(uris),
"hint": "Set A4H_URL/A4H_USER/A4H_PASSWORD in .env, then: python3 -m harness.cli teardown <run_dir>"}
return delete_uris(uris)
def delete_uris(uris):
"""Delete one by one; this keeps the dependency order explicit."""
adt = AdtClient()
res = {}
for u in uris:
res.update(adt.delete([u]))
return {u: {"deleted": ok, "msg": msg} for u, (ok, msg) in res.items()}

62
harness/task.py Normal file
View File

@@ -0,0 +1,62 @@
"""Task loading and prefix substitution."""
import json
import os
_B36 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
def _b36(n, width):
out = ""
while n:
n, r = divmod(n, 36)
out = _B36[r] + out
return out.rjust(width, "0")[-width:]
def prefix_for(run_no, task_id):
"""Z + run (4 chars base36) + task (3 chars base36) + '_'.
Run numbers below 36**3 keep a digit at position 2; the proxy uses this to tell
harness objects from other Z objects. Old prefixes (Z201001_) match the same pattern.
"""
task_no = int("".join(ch for ch in task_id if ch.isdigit()) or 0)
return f"Z{_b36(int(run_no), 4)}{_b36(task_no, 3)}_"
class Task:
def __init__(self, task_dir, prefix):
self.dir = task_dir
self.prefix = prefix
self.meta = json.loads(self._read("task.json"))
self.id = self.meta["id"]
def _read(self, rel):
with open(os.path.join(self.dir, rel)) as f:
return f.read()
def sub(self, text):
return text.replace("{{P}}", self.prefix).replace("{{p}}", self.prefix.lower())
def text(self, rel):
return self.sub(self._read(rel))
def objects(self, key):
"""List of dicts with substituted names and loaded source (if 'file')."""
out = []
for o in self.meta.get(key, []):
o = {k: self.sub(v) if isinstance(v, str) else v for k, v in o.items()}
if "file" in o:
o["source"] = self.text(o["file"])
if "testclasses_file" in o:
o["testclasses_source"] = self.text(o["testclasses_file"])
out.append(o)
return out
@property
def spec(self):
return self.text("spec.md")
@property
def out_of_scope(self):
return [self.sub(n) for n in self.meta.get("out_of_scope", [])]

31
package-lock.json generated Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "harness",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "harness",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@abaplint/cli": "^2.120.60"
}
},
"node_modules/@abaplint/cli": {
"version": "2.120.63",
"resolved": "https://registry.npmjs.org/@abaplint/cli/-/cli-2.120.63.tgz",
"integrity": "sha512-7y4TCHO9vFIZvL273NCMLu1qofXpu4O6GWVwsjuu4sw6IcIzNvV9pTTdMa5mTCTstg+UvTgzFqOhtC2y7lBCnA==",
"license": "MIT",
"bin": {
"abaplint": "abaplint"
},
"engines": {
"node": ">=22.0.0"
},
"funding": {
"url": "https://github.com/sponsors/larshp"
}
}
}
}

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "harness",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@abaplint/cli": "^2.120.60"
}
}

View File

@@ -0,0 +1,93 @@
CLASS {{p}}t01_hidden DEFINITION PUBLIC FINAL CREATE PUBLIC
FOR TESTING DURATION SHORT RISK LEVEL HARMLESS.
PRIVATE SECTION.
DATA mo_cut TYPE REF TO {{p}}if_iban_check.
METHODS setup.
METHODS valid_de FOR TESTING.
METHODS valid_letters_in_body FOR TESTING.
METHODS valid_all_countries FOR TESTING.
METHODS lower_case_ok FOR TESTING.
METHODS empty FOR TESTING.
METHODS only_spaces FOR TESTING.
METHODS bad_chars FOR TESTING.
METHODS digit_in_country FOR TESTING.
METHODS unknown_country FOR TESTING.
METHODS wrong_length FOR TESTING.
METHODS wrong_checksum FOR TESTING.
METHODS order_chars_before_country FOR TESTING.
ENDCLASS.
CLASS {{p}}t01_hidden IMPLEMENTATION.
METHOD setup.
mo_cut = NEW {{p}}iban_validator( ).
ENDMETHOD.
METHOD valid_de.
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-ok
act = mo_cut->check( `DE89 3704 0044 0532 0130 00` ) ).
ENDMETHOD.
METHOD valid_letters_in_body.
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-ok
act = mo_cut->check( `GB82 WEST 1234 5698 7654 32` ) ).
ENDMETHOD.
METHOD valid_all_countries.
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-ok
act = mo_cut->check( `NL91 ABNA 0417 1643 00` ) msg = `NL` ).
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-ok
act = mo_cut->check( `FR14 2004 1010 0505 0001 3M02 606` ) msg = `FR` ).
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-ok
act = mo_cut->check( `TR33 0006 1005 1978 6457 8413 26` ) msg = `TR` ).
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-ok
act = mo_cut->check( `CH93 0076 2011 6238 5295 7` ) msg = `CH` ).
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-ok
act = mo_cut->check( `AT61 1904 3002 3457 3201` ) msg = `AT` ).
ENDMETHOD.
METHOD lower_case_ok.
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-ok
act = mo_cut->check( `de89370400440532013000` ) ).
ENDMETHOD.
METHOD empty.
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-empty
act = mo_cut->check( `` ) ).
ENDMETHOD.
METHOD only_spaces.
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-empty
act = mo_cut->check( ` ` ) ).
ENDMETHOD.
METHOD bad_chars.
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-chars
act = mo_cut->check( `DE89-3704-0044-0532-0130-00` ) ).
ENDMETHOD.
METHOD digit_in_country.
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-chars
act = mo_cut->check( `1E89370400440532013000` ) ).
ENDMETHOD.
METHOD unknown_country.
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-country
act = mo_cut->check( `XX89370400440532013000` ) ).
ENDMETHOD.
METHOD wrong_length.
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-length
act = mo_cut->check( `DE8937040044053201300` ) ).
ENDMETHOD.
METHOD wrong_checksum.
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-checksum
act = mo_cut->check( `DE89 3704 0044 0532 0130 01` ) ).
ENDMETHOD.
METHOD order_chars_before_country.
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-chars
act = mo_cut->check( `XX!!` ) ).
ENDMETHOD.
ENDCLASS.

View File

@@ -0,0 +1,84 @@
CLASS {{p}}iban_validator DEFINITION PUBLIC FINAL CREATE PUBLIC.
PUBLIC SECTION.
INTERFACES {{p}}if_iban_check.
PRIVATE SECTION.
TYPES:
BEGIN OF ty_country,
code TYPE c LENGTH 2,
length TYPE i,
END OF ty_country,
tt_country TYPE HASHED TABLE OF ty_country WITH UNIQUE KEY code.
METHODS normalize
IMPORTING iv_iban TYPE string
RETURNING VALUE(rv_iban) TYPE string.
METHODS has_valid_format
IMPORTING iv_iban TYPE string
RETURNING VALUE(rv_valid) TYPE abap_bool.
METHODS country_lengths
RETURNING VALUE(rt_country) TYPE tt_country.
METHODS remainder_97
IMPORTING iv_iban TYPE string
RETURNING VALUE(rv_mod) TYPE i.
ENDCLASS.
CLASS {{p}}iban_validator IMPLEMENTATION.
METHOD {{p}}if_iban_check~check.
DATA(lv_iban) = normalize( iv_iban ).
IF lv_iban IS INITIAL.
rv_reason = {{p}}if_iban_check=>c_reason-empty.
RETURN.
ENDIF.
IF has_valid_format( lv_iban ) = abap_false.
rv_reason = {{p}}if_iban_check=>c_reason-chars.
RETURN.
ENDIF.
DATA(lt_country) = country_lengths( ).
ASSIGN lt_country[ code = substring( val = lv_iban len = 2 ) ] TO FIELD-SYMBOL(<ls_country>).
IF sy-subrc <> 0.
rv_reason = {{p}}if_iban_check=>c_reason-country.
RETURN.
ENDIF.
IF strlen( lv_iban ) <> <ls_country>-length.
rv_reason = {{p}}if_iban_check=>c_reason-length.
RETURN.
ENDIF.
IF remainder_97( lv_iban ) <> 1.
rv_reason = {{p}}if_iban_check=>c_reason-checksum.
RETURN.
ENDIF.
rv_reason = {{p}}if_iban_check=>c_reason-ok.
ENDMETHOD.
METHOD normalize.
rv_iban = to_upper( replace( val = iv_iban sub = ` ` with = `` occ = 0 ) ).
ENDMETHOD.
METHOD has_valid_format.
rv_valid = xsdbool( matches( val = iv_iban pcre = `^[A-Z]{2}[0-9]{2}[A-Z0-9]*$` ) ).
ENDMETHOD.
METHOD country_lengths.
rt_country = VALUE #( ( code = 'DE' length = 22 )
( code = 'GB' length = 22 )
( code = 'TR' length = 26 )
( code = 'FR' length = 27 )
( code = 'NL' length = 18 )
( code = 'AT' length = 20 )
( code = 'CH' length = 21 ) ).
ENDMETHOD.
METHOD remainder_97.
DATA(lv_moved) = substring( val = iv_iban off = 4 ) && substring( val = iv_iban len = 4 ).
DO strlen( lv_moved ) TIMES.
DATA(lv_char) = substring( val = lv_moved off = sy-index - 1 len = 1 ).
IF lv_char CO '0123456789'.
rv_mod = ( rv_mod * 10 + CONV i( lv_char ) ) MOD 97.
ELSE.
rv_mod = ( rv_mod * 100 + find( val = sy-abcde sub = lv_char ) + 10 ) MOD 97.
ENDIF.
ENDDO.
ENDMETHOD.
ENDCLASS.

View File

@@ -0,0 +1,48 @@
CLASS ltc_iban_validator DEFINITION FINAL FOR TESTING DURATION SHORT RISK LEVEL HARMLESS.
PRIVATE SECTION.
DATA mo_cut TYPE REF TO {{p}}if_iban_check.
METHODS setup.
METHODS valid_iban_is_ok FOR TESTING.
METHODS empty_input FOR TESTING.
METHODS invalid_character FOR TESTING.
METHODS unknown_country FOR TESTING.
METHODS wrong_length FOR TESTING.
METHODS wrong_checksum FOR TESTING.
ENDCLASS.
CLASS ltc_iban_validator IMPLEMENTATION.
METHOD setup.
mo_cut = NEW {{p}}iban_validator( ).
ENDMETHOD.
METHOD valid_iban_is_ok.
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-ok
act = mo_cut->check( `GB82 WEST 1234 5698 7654 32` ) ).
ENDMETHOD.
METHOD empty_input.
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-empty
act = mo_cut->check( ` ` ) ).
ENDMETHOD.
METHOD invalid_character.
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-chars
act = mo_cut->check( `DE89*370400440532013000` ) ).
ENDMETHOD.
METHOD unknown_country.
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-country
act = mo_cut->check( `US89370400440532013000` ) ).
ENDMETHOD.
METHOD wrong_length.
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-length
act = mo_cut->check( `NL91ABNA041716430` ) ).
ENDMETHOD.
METHOD wrong_checksum.
cl_abap_unit_assert=>assert_equals( exp = {{p}}if_iban_check=>c_reason-checksum
act = mo_cut->check( `GB82 WEST 1234 5698 7654 33` ) ).
ENDMETHOD.
ENDCLASS.

View File

@@ -0,0 +1,15 @@
INTERFACE {{p}}if_iban_check PUBLIC.
TYPES ty_reason TYPE c LENGTH 10.
CONSTANTS:
BEGIN OF c_reason,
ok TYPE ty_reason VALUE 'OK',
empty TYPE ty_reason VALUE 'EMPTY',
chars TYPE ty_reason VALUE 'CHARS',
country TYPE ty_reason VALUE 'COUNTRY',
length TYPE ty_reason VALUE 'LENGTH',
checksum TYPE ty_reason VALUE 'CHECKSUM',
END OF c_reason.
METHODS check
IMPORTING iv_iban TYPE string
RETURNING VALUE(rv_reason) TYPE ty_reason.
ENDINTERFACE.

45
tasks/T01/spec.md Normal file
View File

@@ -0,0 +1,45 @@
# 1. Goal
Check the format and the checksum of an IBAN. A payment program calls the check
before it sends a payment. The program needs one reason code for each IBAN.
# 2. Open questions
None.
# 3. Context
- Interface {{P}}IF_IBAN_CHECK exists in package $TMP. It is active.
- The interface has the type TY_REASON (CHAR 10), the constant structure
C_REASON (OK, EMPTY, CHARS, COUNTRY, LENGTH, CHECKSUM), and one method:
CHECK IMPORTING iv_iban TYPE string RETURNING VALUE(rv_reason) TYPE ty_reason.
# 4. Contract
- Create the class {{P}}IBAN_VALIDATOR in package $TMP.
- The class is public, final, and has a public constructor without parameters.
- The class implements {{P}}IF_IBAN_CHECK.
- Do not add other public methods.
# 5. Business rules
Apply the rules in this sequence. Return the reason of the first rule that fails.
1. Remove all spaces. Convert all letters to upper case.
2. If the result is empty, return EMPTY.
3. The result must contain only the letters A to Z and the digits 0 to 9.
Positions 1 and 2 must be letters. Positions 3 and 4 must be digits. If not,
return CHARS.
4. Positions 1 and 2 are the country code. Use this list of codes and lengths:
DE 22, GB 22, TR 26, FR 27, NL 18, AT 20, CH 21. If the code is not in the
list, return COUNTRY.
5. If the length is not the length of the country, return LENGTH.
6. Calculate the checksum: move the first 4 characters to the end. Replace each
letter with a number (A = 10, B = 11, ..., Z = 35). Calculate the remainder
of this number divided by 97. If the remainder is not 1, return CHECKSUM.
7. Else return OK.
# 6. Constraints
- Release target: 7.58.
- Coding standards: Clean ABAP. Method length below 40 statements. No global
variables. No comment that restates the code.
- Out of scope: do not change {{P}}IF_IBAN_CHECK.
# 7. Acceptance
- The class is active and has no syntax error.
- The hidden tests pass.
- Write your own ABAP Unit tests for the class.

50
tasks/T01/task.json Normal file
View File

@@ -0,0 +1,50 @@
{
"id": "T01",
"category": "A",
"difficulty": 1,
"release_target": "v758",
"expected_outcome": "implement",
"budget": {
"max_tool_calls": 40,
"max_activations": 10
},
"seed": [
{
"type": "INTF",
"name": "{{P}}IF_IBAN_CHECK",
"file": "seed/if_iban_check.intf.abap",
"description": "IBAN check contract"
}
],
"contract": [
{
"type": "CLAS",
"name": "{{P}}IBAN_VALIDATOR",
"implements": "{{P}}IF_IBAN_CHECK"
}
],
"out_of_scope": [
"{{P}}IF_IBAN_CHECK"
],
"hidden_tests": [
{
"type": "CLAS",
"name": "{{P}}T01_HIDDEN",
"file": "hidden/t01_hidden.clas.abap",
"description": "T01 hidden tests"
}
],
"reference": [
{
"type": "CLAS",
"name": "{{P}}IBAN_VALIDATOR",
"file": "reference/iban_validator.clas.abap",
"description": "IBAN validator",
"testclasses_file": "reference/iban_validator.testclasses.abap"
}
],
"craft_checks": [
"method_length",
"db_operation_in_loop"
]
}

View File

@@ -0,0 +1,70 @@
CLASS {{p}}t13_hidden DEFINITION PUBLIC FINAL CREATE PUBLIC
FOR TESTING DURATION SHORT RISK LEVEL HARMLESS.
PRIVATE SECTION.
METHODS call IMPORTING iv_type TYPE string iv_amount TYPE decfloat34 iv_fee TYPE decfloat34 DEFAULT 7
EXPORTING ev_skip TYPE abap_bool ev_fee TYPE decfloat34 ev_subrc TYPE sysubrc.
METHODS invoice_percent FOR TESTING.
METHODS invoice_minimum FOR TESTING.
METHODS invoice_maximum FOR TESTING.
METHODS invoice_rounding FOR TESTING.
METHODS credit_memo_skips FOR TESTING.
METHODS other_type_zero FOR TESTING.
METHODS negative_amount FOR TESTING.
METHODS skip_false_for_invoice FOR TESTING.
ENDCLASS.
CLASS {{p}}t13_hidden IMPLEMENTATION.
METHOD call.
ev_fee = iv_fee.
CALL FUNCTION '{{P}}EVT_FEE'
EXPORTING iv_doc_type = iv_type iv_amount = iv_amount
IMPORTING ev_skip = ev_skip
CHANGING cv_fee = ev_fee
EXCEPTIONS invalid_amount = 1 OTHERS = 2.
ev_subrc = sy-subrc.
ENDMETHOD.
METHOD invoice_percent.
call( EXPORTING iv_type = `IN` iv_amount = 1000 IMPORTING ev_fee = DATA(lv_fee) ).
cl_abap_unit_assert=>assert_equals( exp = CONV decfloat34( 20 ) act = lv_fee ).
ENDMETHOD.
METHOD invoice_minimum.
call( EXPORTING iv_type = `IN` iv_amount = 10 IMPORTING ev_fee = DATA(lv_fee) ).
cl_abap_unit_assert=>assert_equals( exp = CONV decfloat34( 1 ) act = lv_fee ).
ENDMETHOD.
METHOD invoice_maximum.
call( EXPORTING iv_type = `IN` iv_amount = 100000 IMPORTING ev_fee = DATA(lv_fee) ).
cl_abap_unit_assert=>assert_equals( exp = CONV decfloat34( 50 ) act = lv_fee ).
ENDMETHOD.
METHOD invoice_rounding.
call( EXPORTING iv_type = `IN` iv_amount = '123.25' IMPORTING ev_fee = DATA(lv_fee) ).
cl_abap_unit_assert=>assert_equals( exp = CONV decfloat34( '2.47' ) act = lv_fee ).
ENDMETHOD.
METHOD credit_memo_skips.
call( EXPORTING iv_type = `CR` iv_amount = 500 iv_fee = 7
IMPORTING ev_skip = DATA(lv_skip) ev_fee = DATA(lv_fee) ).
cl_abap_unit_assert=>assert_equals( exp = abap_true act = lv_skip ).
cl_abap_unit_assert=>assert_equals( exp = CONV decfloat34( 7 ) act = lv_fee ).
ENDMETHOD.
METHOD other_type_zero.
call( EXPORTING iv_type = `XX` iv_amount = 500 IMPORTING ev_fee = DATA(lv_fee) ev_skip = DATA(lv_skip) ).
cl_abap_unit_assert=>assert_equals( exp = CONV decfloat34( 0 ) act = lv_fee ).
cl_abap_unit_assert=>assert_equals( exp = abap_false act = lv_skip ).
ENDMETHOD.
METHOD negative_amount.
call( EXPORTING iv_type = `IN` iv_amount = -1 IMPORTING ev_subrc = DATA(lv_rc) ).
cl_abap_unit_assert=>assert_equals( exp = 1 act = lv_rc ).
ENDMETHOD.
METHOD skip_false_for_invoice.
call( EXPORTING iv_type = `IN` iv_amount = 100 IMPORTING ev_skip = DATA(lv_skip) ).
cl_abap_unit_assert=>assert_equals( exp = abap_false act = lv_skip ).
ENDMETHOD.
ENDCLASS.

View File

@@ -0,0 +1,30 @@
FUNCTION {{p}}evt_fee
IMPORTING
iv_doc_type TYPE string
iv_amount TYPE decfloat34
EXPORTING
ev_skip TYPE abap_bool
CHANGING
cv_fee TYPE decfloat34
EXCEPTIONS
invalid_amount.
ev_skip = abap_false.
IF iv_amount < 0.
RAISE invalid_amount.
ENDIF.
CASE iv_doc_type.
WHEN 'IN'.
cv_fee = round( val = iv_amount * 2 / 100 dec = 2 mode = cl_abap_math=>round_half_up ).
IF cv_fee < 1.
cv_fee = 1.
ELSEIF cv_fee > 50.
cv_fee = 50.
ENDIF.
WHEN 'CR'.
ev_skip = abap_true.
WHEN OTHERS.
cv_fee = 0.
ENDCASE.
ENDFUNCTION.

View File

@@ -0,0 +1,52 @@
CLASS {{p}}t13_test DEFINITION PUBLIC FINAL CREATE PUBLIC
FOR TESTING DURATION SHORT RISK LEVEL HARMLESS.
PRIVATE SECTION.
METHODS fee_for IMPORTING iv_type TYPE string iv_amount TYPE decfloat34
RETURNING VALUE(rv_fee) TYPE decfloat34.
METHODS invoice_fee FOR TESTING.
METHODS fee_limits FOR TESTING.
METHODS credit_memo FOR TESTING.
METHODS negative_amount FOR TESTING.
ENDCLASS.
CLASS {{p}}t13_test IMPLEMENTATION.
METHOD fee_for.
DATA lv_skip TYPE abap_bool.
CALL FUNCTION '{{P}}EVT_FEE'
EXPORTING iv_doc_type = iv_type iv_amount = iv_amount
IMPORTING ev_skip = lv_skip
CHANGING cv_fee = rv_fee
EXCEPTIONS invalid_amount = 1 OTHERS = 2.
cl_abap_unit_assert=>assert_subrc( exp = 0 ).
ENDMETHOD.
METHOD invoice_fee.
cl_abap_unit_assert=>assert_equals( exp = CONV decfloat34( 4 ) act = fee_for( iv_type = `IN` iv_amount = 200 ) ).
ENDMETHOD.
METHOD fee_limits.
cl_abap_unit_assert=>assert_equals( exp = CONV decfloat34( 1 ) act = fee_for( iv_type = `IN` iv_amount = 5 ) ).
cl_abap_unit_assert=>assert_equals( exp = CONV decfloat34( 50 ) act = fee_for( iv_type = `IN` iv_amount = 9000 ) ).
ENDMETHOD.
METHOD credit_memo.
DATA lv_skip TYPE abap_bool.
DATA lv_fee TYPE decfloat34 VALUE 3.
CALL FUNCTION '{{P}}EVT_FEE'
EXPORTING iv_doc_type = `CR` iv_amount = 100
IMPORTING ev_skip = lv_skip
CHANGING cv_fee = lv_fee.
cl_abap_unit_assert=>assert_true( lv_skip ).
cl_abap_unit_assert=>assert_equals( exp = CONV decfloat34( 3 ) act = lv_fee ).
ENDMETHOD.
METHOD negative_amount.
DATA lv_fee TYPE decfloat34.
CALL FUNCTION '{{P}}EVT_FEE'
EXPORTING iv_doc_type = `IN` iv_amount = -5
CHANGING cv_fee = lv_fee
EXCEPTIONS invalid_amount = 1 OTHERS = 2.
cl_abap_unit_assert=>assert_equals( exp = 1 act = sy-subrc ).
ENDMETHOD.
ENDCLASS.

View File

@@ -0,0 +1,14 @@
FUNCTION {{p}}evt_sample
IMPORTING
iv_doc_type TYPE string
iv_amount TYPE decfloat34
EXPORTING
ev_skip TYPE abap_bool
CHANGING
cv_fee TYPE decfloat34
EXCEPTIONS
invalid_amount.
ev_skip = abap_false.
ENDFUNCTION.

35
tasks/T13/spec.md Normal file
View File

@@ -0,0 +1,35 @@
# 1. Goal
Calculate a processing fee for a posted document. The posting program calls one
event module for each document. The event module must have the same interface as
the sample event module, because the posting program calls all event modules in
the same way.
# 2. Open questions
None.
# 3. Context
- Function group {{P}}FG_EVT exists in package $TMP.
- Function module {{P}}EVT_SAMPLE exists in this group. It is the sample event
module. Its interface is the interface that all event modules must use.
# 4. Contract
- Create the function module {{P}}EVT_FEE in function group {{P}}FG_EVT.
- Use exactly the interface of {{P}}EVT_SAMPLE: the same parameter names, kinds,
types, and exceptions.
# 5. Business rules
1. If the amount is less than 0, raise the exception INVALID_AMOUNT.
2. Set the skip flag to false, except in rule 4.
3. Document type IN: the fee is 2 % of the amount, rounded half up to 2
decimals. The minimum fee is 1.00. The maximum fee is 50.00.
4. Document type CR: set the skip flag to true. Do not change the fee.
5. Other document types: the fee is 0.
# 6. Constraints
- Release target: 7.58.
- Out of scope: do not change {{P}}EVT_SAMPLE.
# 7. Acceptance
- The function module is active and has no syntax error.
- The hidden tests pass.
- Write ABAP Unit tests for the function module in a global test class.

80
tasks/T13/task.json Normal file
View File

@@ -0,0 +1,80 @@
{
"id": "T13",
"category": "F",
"object_type": "FUNC",
"difficulty": 2,
"release_target": "v758",
"expected_outcome": "implement",
"budget": {
"max_tool_calls": 40,
"max_activations": 10
},
"seed": [
{
"type": "FUGR",
"name": "{{P}}FG_EVT",
"description": "Fee events"
},
{
"type": "FUNC",
"name": "{{P}}EVT_SAMPLE",
"functionGroup": "{{P}}FG_EVT",
"file": "seed/evt_sample.func.abap",
"description": "Sample event module"
}
],
"contract": [
{
"type": "FUNC",
"name": "{{P}}EVT_FEE",
"functionGroup": "{{P}}FG_EVT",
"params": [
{
"name": "iv_doc_type",
"type": "string"
},
{
"name": "iv_amount",
"type": "decfloat34"
},
{
"name": "ev_skip",
"type": "abap_bool"
},
{
"name": "cv_fee",
"type": "decfloat34"
}
]
}
],
"out_of_scope": [
"{{P}}EVT_SAMPLE"
],
"hidden_tests": [
{
"type": "CLAS",
"name": "{{P}}T13_HIDDEN",
"file": "hidden/t13_hidden.clas.abap",
"description": "T13 hidden tests"
}
],
"reference": [
{
"type": "FUNC",
"name": "{{P}}EVT_FEE",
"functionGroup": "{{P}}FG_EVT",
"file": "reference/evt_fee.func.abap",
"description": "Fee event module"
},
{
"type": "CLAS",
"name": "{{P}}T13_TEST",
"file": "reference/t13_test.clas.abap",
"description": "T13 own tests"
}
],
"craft_checks": [
"method_length"
]
}

View File

@@ -0,0 +1,72 @@
CLASS {{p}}t14_hidden DEFINITION PUBLIC FINAL CREATE PUBLIC
FOR TESTING DURATION SHORT RISK LEVEL HARMLESS.
PRIVATE SECTION.
TYPES tt_region TYPE RANGE OF {{p}}sale-region.
METHODS run IMPORTING iv_from TYPE d it_region TYPE tt_region OPTIONAL
RETURNING VALUE(rr_data) TYPE REF TO data.
METHODS value_of IMPORTING ir_data TYPE REF TO data iv_row TYPE i iv_col TYPE string
RETURNING VALUE(rv_value) TYPE string.
METHODS lines_of IMPORTING ir_data TYPE REF TO data RETURNING VALUE(rv_lines) TYPE i.
METHODS all_regions FOR TESTING.
METHODS sorted_by_total FOR TESTING.
METHODS totals_and_counts FOR TESTING.
METHODS region_filter FOR TESTING.
METHODS date_filter FOR TESTING.
ENDCLASS.
CLASS {{p}}t14_hidden IMPLEMENTATION.
METHOD run.
cl_salv_bs_runtime_info=>set( display = abap_false metadata = abap_false data = abap_true ).
SUBMIT {{p}}sales_sum WITH p_from = iv_from WITH s_reg IN it_region AND RETURN.
TRY.
cl_salv_bs_runtime_info=>get_data_ref( IMPORTING r_data = rr_data ).
CATCH cx_salv_bs_sc_runtime_info.
ENDTRY.
cl_salv_bs_runtime_info=>clear_all( ).
ENDMETHOD.
METHOD lines_of.
FIELD-SYMBOLS <lt_data> TYPE STANDARD TABLE.
IF ir_data IS BOUND.
ASSIGN ir_data->* TO <lt_data>.
rv_lines = lines( <lt_data> ).
ENDIF.
ENDMETHOD.
METHOD value_of.
FIELD-SYMBOLS <lt_data> TYPE STANDARD TABLE.
ASSIGN ir_data->* TO <lt_data>.
ASSIGN <lt_data>[ iv_row ] TO FIELD-SYMBOL(<ls_line>).
ASSIGN COMPONENT iv_col OF STRUCTURE <ls_line> TO FIELD-SYMBOL(<lv_value>).
cl_abap_unit_assert=>assert_subrc( exp = 0 msg = |Column { iv_col } missing| ).
rv_value = condense( CONV string( <lv_value> ) ).
ENDMETHOD.
METHOD all_regions.
cl_abap_unit_assert=>assert_equals( exp = 3 act = lines_of( run( '20260101' ) ) ).
ENDMETHOD.
METHOD sorted_by_total.
DATA(lr_data) = run( '20251201' ).
cl_abap_unit_assert=>assert_equals( exp = `APAC` act = value_of( ir_data = lr_data iv_row = 1 iv_col = `REGION` ) ).
cl_abap_unit_assert=>assert_equals( exp = `EU` act = value_of( ir_data = lr_data iv_row = 3 iv_col = `REGION` ) ).
ENDMETHOD.
METHOD totals_and_counts.
DATA(lr_data) = run( '20260101' ).
cl_abap_unit_assert=>assert_equals( exp = `US` act = value_of( ir_data = lr_data iv_row = 1 iv_col = `REGION` ) ).
cl_abap_unit_assert=>assert_equals( exp = `150.00` act = value_of( ir_data = lr_data iv_row = 2 iv_col = `TOTAL` ) ).
cl_abap_unit_assert=>assert_equals( exp = `2` act = value_of( ir_data = lr_data iv_row = 2 iv_col = `COUNT` ) ).
ENDMETHOD.
METHOD region_filter.
DATA(lr_data) = run( iv_from = '20260101' it_region = VALUE #( ( sign = 'I' option = 'EQ' low = 'EU' ) ) ).
cl_abap_unit_assert=>assert_equals( exp = 1 act = lines_of( lr_data ) ).
cl_abap_unit_assert=>assert_equals( exp = `150.00` act = value_of( ir_data = lr_data iv_row = 1 iv_col = `TOTAL` ) ).
ENDMETHOD.
METHOD date_filter.
cl_abap_unit_assert=>assert_equals( exp = 2 act = lines_of( run( '20260201' ) ) ).
ENDMETHOD.
ENDCLASS.

View File

@@ -0,0 +1,89 @@
REPORT {{p}}sales_sum.
DATA gv_region TYPE {{p}}sale-region.
PARAMETERS p_from TYPE d OBLIGATORY.
SELECT-OPTIONS s_reg FOR gv_region.
CLASS lcl_report DEFINITION FINAL.
PUBLIC SECTION.
TYPES:
BEGIN OF ty_line,
region TYPE {{p}}sale-region,
total TYPE {{p}}sale-amount,
count TYPE i,
END OF ty_line,
tt_line TYPE STANDARD TABLE OF ty_line WITH EMPTY KEY.
TYPES tt_region TYPE RANGE OF {{p}}sale-region.
METHODS select_totals
IMPORTING iv_from TYPE d
it_region TYPE tt_region
RETURNING VALUE(rt_lines) TYPE tt_line.
METHODS display
CHANGING ct_lines TYPE tt_line.
ENDCLASS.
CLASS lcl_report IMPLEMENTATION.
METHOD select_totals.
SELECT region, SUM( amount ) AS total, COUNT( * ) AS count
FROM {{p}}sale
WHERE sale_date >= @iv_from
AND region IN @it_region
GROUP BY region
ORDER BY total DESCENDING
INTO CORRESPONDING FIELDS OF TABLE @rt_lines.
ENDMETHOD.
METHOD display.
TRY.
cl_salv_table=>factory( IMPORTING r_salv_table = DATA(lo_alv)
CHANGING t_table = ct_lines ).
lo_alv->display( ).
CATCH cx_salv_msg INTO DATA(lx_error).
MESSAGE lx_error TYPE 'I' DISPLAY LIKE 'E'.
ENDTRY.
ENDMETHOD.
ENDCLASS.
START-OF-SELECTION.
DATA(go_report) = NEW lcl_report( ).
DATA(gt_lines) = go_report->select_totals( iv_from = p_from it_region = s_reg[] ).
go_report->display( CHANGING ct_lines = gt_lines ).
CLASS ltc_report DEFINITION FINAL FOR TESTING DURATION SHORT RISK LEVEL HARMLESS.
PRIVATE SECTION.
CLASS-DATA go_env TYPE REF TO if_osql_test_environment.
CLASS-METHODS class_setup.
CLASS-METHODS class_teardown.
METHODS totals_per_region FOR TESTING.
METHODS region_filter FOR TESTING.
ENDCLASS.
CLASS ltc_report IMPLEMENTATION.
METHOD class_setup.
go_env = cl_osql_test_environment=>create( i_dependency_list = VALUE #( ( '{{P}}SALE' ) ) ).
DATA lt_sale TYPE STANDARD TABLE OF {{p}}sale WITH EMPTY KEY.
lt_sale = VALUE #( ( sale_id = 1 region = 'A' sale_date = '20260101' amount = 10 )
( sale_id = 2 region = 'A' sale_date = '20260102' amount = 5 )
( sale_id = 3 region = 'B' sale_date = '20260101' amount = 40 ) ).
go_env->insert_test_data( lt_sale ).
ENDMETHOD.
METHOD class_teardown.
go_env->destroy( ).
ENDMETHOD.
METHOD totals_per_region.
DATA(lt_lines) = NEW lcl_report( )->select_totals( iv_from = '20260101' it_region = VALUE #( ) ).
cl_abap_unit_assert=>assert_equals( exp = 2 act = lines( lt_lines ) ).
cl_abap_unit_assert=>assert_equals( exp = 'B' act = lt_lines[ 1 ]-region ).
cl_abap_unit_assert=>assert_equals( exp = 2 act = lt_lines[ 2 ]-count ).
ENDMETHOD.
METHOD region_filter.
DATA(lt_lines) = NEW lcl_report( )->select_totals(
iv_from = '20260101' it_region = VALUE #( ( sign = 'I' option = 'EQ' low = 'A' ) ) ).
cl_abap_unit_assert=>assert_equals( exp = 1 act = lines( lt_lines ) ).
cl_abap_unit_assert=>assert_equals( exp = CONV {{p}}sale-amount( 15 ) act = lt_lines[ 1 ]-total ).
ENDMETHOD.
ENDCLASS.

View File

@@ -0,0 +1,12 @@
@EndUserText.label : 'Sales'
@AbapCatalog.enhancement.category : #NOT_EXTENSIBLE
@AbapCatalog.tableCategory : #TRANSPARENT
@AbapCatalog.deliveryClass : #A
@AbapCatalog.dataMaintenance : #RESTRICTED
define table {{p}}sale {
key client : abap.clnt not null;
key sale_id : abap.int4 not null;
region : abap.char(4);
sale_date : abap.dats;
amount : abap.dec(15,2);
}

View File

@@ -0,0 +1,17 @@
CLASS {{p}}t14_seed DEFINITION PUBLIC FINAL CREATE PUBLIC.
PUBLIC SECTION.
INTERFACES if_oo_adt_classrun.
ENDCLASS.
CLASS {{p}}t14_seed IMPLEMENTATION.
METHOD if_oo_adt_classrun~main.
DELETE FROM {{p}}sale.
INSERT {{p}}sale FROM TABLE @( VALUE #(
( sale_id = 1 region = 'EU' sale_date = '20260110' amount = '100.00' )
( sale_id = 2 region = 'EU' sale_date = '20260210' amount = '50.00' )
( sale_id = 3 region = 'US' sale_date = '20260115' amount = '300.00' )
( sale_id = 4 region = 'APAC' sale_date = '20251231' amount = '500.00' )
( sale_id = 5 region = 'APAC' sale_date = '20260301' amount = '20.00' ) ) ).
out->write( sy-dbcnt ).
ENDMETHOD.
ENDCLASS.

33
tasks/T14/spec.md Normal file
View File

@@ -0,0 +1,33 @@
# 1. Goal
Show the sales total for each region in an ALV list. The sales team uses the
list to compare regions for a period.
# 2. Open questions
None.
# 3. Context
- Table {{P}}SALE exists in package $TMP. It contains data.
Fields: SALE_ID (INT4, key), REGION (CHAR 4), SALE_DATE (DATS), AMOUNT (DEC 15,2).
# 4. Contract
- Create the executable program {{P}}SALES_SUM in package $TMP.
- Selection screen:
- Parameter P_FROM, type DATS, mandatory: the first sale date to include.
- Select-option S_REG for the region.
- Show the result with CL_SALV_TABLE. The output table has these columns, with
these names: REGION, TOTAL (sum of AMOUNT), COUNT (number of sales).
# 5. Business rules
1. Include a sale only if SALE_DATE is on or after P_FROM and REGION is in S_REG.
2. Show one line for each region that has at least one included sale.
3. Sort the lines by TOTAL, the highest total first.
# 6. Constraints
- Release target: 7.58.
- Coding standards: Clean ABAP. Read the data with one database access.
- Out of scope: do not change {{P}}SALE.
# 7. Acceptance
- The program is active and has no syntax error.
- The hidden tests pass.
- Write ABAP Unit tests as local test classes in the program.

18
tasks/T14/task.json Normal file
View File

@@ -0,0 +1,18 @@
{
"id": "T14", "category": "B", "object_type": "PROG", "difficulty": 2,
"release_target": "v758", "expected_outcome": "implement",
"budget": {"max_tool_calls": 40, "max_activations": 10},
"seed": [
{"type": "TABL", "name": "{{P}}SALE", "file": "seed/sale.tabl.asabap", "description": "Sales"},
{"type": "CLAS", "name": "{{P}}T14_SEED", "file": "seed/t14_seed.clas.abap", "description": "T14 seed data", "run": true}
],
"contract": [
{"type": "PROG", "name": "{{P}}SALES_SUM", "parameters": ["p_from", "s_reg"]}
],
"out_of_scope": ["{{P}}SALE"],
"hidden_tests": [{"type": "CLAS", "name": "{{P}}T14_HIDDEN", "file": "hidden/t14_hidden.clas.abap",
"description": "T14 hidden tests"}],
"reference": [{"type": "PROG", "name": "{{P}}SALES_SUM", "file": "reference/sales_sum.prog.abap",
"description": "Sales per region"}],
"craft_checks": ["db_operation_in_loop", "method_length"]
}

View File

@@ -0,0 +1,64 @@
CLASS {{p}}t15_hidden DEFINITION PUBLIC FINAL CREATE PUBLIC
FOR TESTING DURATION SHORT RISK LEVEL HARMLESS.
PRIVATE SECTION.
CLASS-DATA go_env TYPE REF TO if_cds_test_environment.
CLASS-METHODS class_setup.
CLASS-METHODS class_teardown.
METHODS setup.
METHODS totals FOR TESTING.
METHODS item_counts FOR TESTING.
METHODS order_without_items FOR TESTING.
METHODS customer_shown FOR TESTING.
METHODS decimals FOR TESTING.
ENDCLASS.
CLASS {{p}}t15_hidden IMPLEMENTATION.
METHOD class_setup.
go_env = cl_cds_test_environment=>create( i_for_entity = '{{P}}I_ORDER_TOTAL' ).
ENDMETHOD.
METHOD class_teardown.
go_env->destroy( ).
ENDMETHOD.
METHOD setup.
DATA lt_ord TYPE STANDARD TABLE OF {{p}}ord WITH EMPTY KEY.
DATA lt_itm TYPE STANDARD TABLE OF {{p}}itm WITH EMPTY KEY.
go_env->clear_doubles( ).
lt_ord = VALUE #( ( order_id = 'O1' customer = 'ALPHA' )
( order_id = 'O2' customer = 'BETA' )
( order_id = 'O3' customer = 'GAMMA' ) ).
lt_itm = VALUE #( ( order_id = 'O1' item_no = 1 quantity = 3 price = '10.00' )
( order_id = 'O1' item_no = 2 quantity = 1 price = '2.50' )
( order_id = 'O1' item_no = 3 quantity = 4 price = '0.25' )
( order_id = 'O2' item_no = 1 quantity = 10 price = '1.99' ) ).
go_env->insert_test_data( lt_ord ).
go_env->insert_test_data( lt_itm ).
ENDMETHOD.
METHOD totals.
SELECT SINGLE total_amount FROM {{p}}i_order_total WHERE order_id = 'O1' INTO @DATA(lv_total).
cl_abap_unit_assert=>assert_equals( exp = CONV decfloat34( '33.50' ) act = CONV decfloat34( lv_total ) ).
ENDMETHOD.
METHOD item_counts.
SELECT SINGLE item_count FROM {{p}}i_order_total WHERE order_id = 'O1' INTO @DATA(lv_count).
cl_abap_unit_assert=>assert_equals( exp = 3 act = lv_count ).
ENDMETHOD.
METHOD order_without_items.
SELECT COUNT(*) FROM {{p}}i_order_total INTO @DATA(lv_rows).
cl_abap_unit_assert=>assert_equals( exp = 2 act = lv_rows ).
ENDMETHOD.
METHOD customer_shown.
SELECT SINGLE customer FROM {{p}}i_order_total WHERE order_id = 'O2' INTO @DATA(lv_customer).
cl_abap_unit_assert=>assert_equals( exp = 'BETA' act = lv_customer ).
ENDMETHOD.
METHOD decimals.
SELECT SINGLE total_amount FROM {{p}}i_order_total WHERE order_id = 'O2' INTO @DATA(lv_total).
cl_abap_unit_assert=>assert_equals( exp = CONV decfloat34( '19.90' ) act = CONV decfloat34( lv_total ) ).
ENDMETHOD.
ENDCLASS.

View File

@@ -0,0 +1,14 @@
@AccessControl.authorizationCheck: #NOT_REQUIRED
@EndUserText.label: 'Order totals'
define view entity {{P}}I_ORDER_TOTAL
as select from {{p}}ord as head
inner join {{p}}itm as item on item.order_id = head.order_id
{
key head.order_id as order_id,
head.customer as customer,
count( * ) as item_count,
sum( cast( item.quantity as abap.dec( 10, 0 ) ) * item.price ) as total_amount
}
group by
head.order_id,
head.customer

View File

@@ -0,0 +1,38 @@
CLASS {{p}}t15_test DEFINITION PUBLIC FINAL CREATE PUBLIC
FOR TESTING DURATION SHORT RISK LEVEL HARMLESS.
PRIVATE SECTION.
CLASS-DATA go_env TYPE REF TO if_cds_test_environment.
CLASS-METHODS class_setup.
CLASS-METHODS class_teardown.
METHODS setup.
METHODS total_of_one_order FOR TESTING.
ENDCLASS.
CLASS {{p}}t15_test IMPLEMENTATION.
METHOD class_setup.
go_env = cl_cds_test_environment=>create( i_for_entity = '{{P}}I_ORDER_TOTAL' ).
ENDMETHOD.
METHOD class_teardown.
go_env->destroy( ).
ENDMETHOD.
METHOD setup.
go_env->clear_doubles( ).
ENDMETHOD.
METHOD total_of_one_order.
DATA lt_ord TYPE STANDARD TABLE OF {{p}}ord WITH EMPTY KEY.
DATA lt_itm TYPE STANDARD TABLE OF {{p}}itm WITH EMPTY KEY.
lt_ord = VALUE #( ( order_id = 'A' customer = 'C1' ) ).
lt_itm = VALUE #( ( order_id = 'A' item_no = 1 quantity = 2 price = '1.50' )
( order_id = 'A' item_no = 2 quantity = 1 price = '4.00' ) ).
go_env->insert_test_data( lt_ord ).
go_env->insert_test_data( lt_itm ).
SELECT SINGLE item_count, total_amount FROM {{p}}i_order_total WHERE order_id = 'A'
INTO @DATA(ls_total).
cl_abap_unit_assert=>assert_equals( exp = 2 act = ls_total-item_count ).
cl_abap_unit_assert=>assert_equals( exp = CONV decfloat34( 7 ) act = CONV decfloat34( ls_total-total_amount ) ).
ENDMETHOD.
ENDCLASS.

View File

@@ -0,0 +1,13 @@
@EndUserText.label : 'Order item'
@AbapCatalog.enhancement.category : #NOT_EXTENSIBLE
@AbapCatalog.tableCategory : #TRANSPARENT
@AbapCatalog.deliveryClass : #A
@AbapCatalog.dataMaintenance : #RESTRICTED
define table {{p}}itm {
key client : abap.clnt not null;
key order_id : abap.char(10) not null;
key item_no : abap.int4 not null;
product : abap.char(10);
quantity : abap.int4;
price : abap.dec(15,2);
}

View File

@@ -0,0 +1,11 @@
@EndUserText.label : 'Order header'
@AbapCatalog.enhancement.category : #NOT_EXTENSIBLE
@AbapCatalog.tableCategory : #TRANSPARENT
@AbapCatalog.deliveryClass : #A
@AbapCatalog.dataMaintenance : #RESTRICTED
define table {{p}}ord {
key client : abap.clnt not null;
key order_id : abap.char(10) not null;
customer : abap.char(10);
order_date : abap.dats;
}

33
tasks/T15/spec.md Normal file
View File

@@ -0,0 +1,33 @@
# 1. Goal
Give the total value of each order. A report and an OData service will read the
totals, so the calculation must be in the database, in a CDS view.
# 2. Open questions
None.
# 3. Context
- Table {{P}}ORD (order header), package $TMP. Key: ORDER_ID (CHAR 10).
Fields: CUSTOMER (CHAR 10), ORDER_DATE (DATS).
- Table {{P}}ITM (order item), package $TMP. Key: ORDER_ID (CHAR 10), ITEM_NO (INT4).
Fields: PRODUCT (CHAR 10), QUANTITY (INT4), PRICE (DEC 15,2, price for one unit).
# 4. Contract
- Create the CDS view entity {{P}}I_ORDER_TOTAL in package $TMP.
- Elements, with these names: ORDER_ID (key), CUSTOMER, ITEM_COUNT, TOTAL_AMOUNT.
- No authorization check (#NOT_REQUIRED).
# 5. Business rules
1. Show one row for each order that has at least one item. Do not show an order
without items.
2. ITEM_COUNT is the number of items of the order.
3. TOTAL_AMOUNT is the sum of QUANTITY multiplied by PRICE for all items of the
order, with 2 decimals.
# 6. Constraints
- Release target: 7.58.
- Out of scope: do not change {{P}}ORD and {{P}}ITM.
# 7. Acceptance
- The view is active and has no syntax error.
- The hidden tests pass.
- Write ABAP Unit tests with CL_CDS_TEST_ENVIRONMENT in a global test class.

22
tasks/T15/task.json Normal file
View File

@@ -0,0 +1,22 @@
{
"id": "T15", "category": "B", "object_type": "DDLS", "difficulty": 2,
"release_target": "v758", "expected_outcome": "implement",
"budget": {"max_tool_calls": 40, "max_activations": 10},
"seed": [
{"type": "TABL", "name": "{{P}}ORD", "file": "seed/ord.tabl.asabap", "description": "Order header"},
{"type": "TABL", "name": "{{P}}ITM", "file": "seed/itm.tabl.asabap", "description": "Order item"}
],
"contract": [
{"type": "DDLS", "name": "{{P}}I_ORDER_TOTAL",
"fields": ["ORDER_ID", "CUSTOMER", "ITEM_COUNT", "TOTAL_AMOUNT"]}
],
"out_of_scope": ["{{P}}ORD", "{{P}}ITM"],
"hidden_tests": [{"type": "CLAS", "name": "{{P}}T15_HIDDEN", "file": "hidden/t15_hidden.clas.abap",
"description": "T15 hidden tests"}],
"reference": [
{"type": "DDLS", "name": "{{P}}I_ORDER_TOTAL", "file": "reference/i_order_total.ddls.asddls",
"description": "Order totals"},
{"type": "CLAS", "name": "{{P}}T15_TEST", "file": "reference/t15_test.clas.abap", "description": "T15 own tests"}
],
"craft_checks": []
}

View File

@@ -0,0 +1,24 @@
{
"id": "G0001",
"pool": "eval",
"object_type": "CLAS",
"category": "C",
"attempts": [
{
"stage": "validate",
"oracle": 0,
"null": 0
},
{
"stage": "validate",
"oracle": null,
"null": null
},
{
"stage": "validate",
"oracle": 89.2,
"null": 0
}
],
"accepted": false
}

View File

@@ -0,0 +1,335 @@
CLASS {{P}}T07_HIDDEN DEFINITION PUBLIC FINAL CREATE PUBLIC
FOR TESTING DURATION SHORT RISK LEVEL HARMLESS.
PRIVATE SECTION.
DATA mo_cut TYPE REF TO {{P}}TOUR_PLANNER.
METHODS setup.
METHODS add_stop
IMPORTING iv_tour_id TYPE {{P}}TOUR_PLANNER=>ty_tour_id
iv_stop_no TYPE i
iv_customer TYPE {{P}}TOUR_PLANNER=>ty_customer
iv_packages TYPE i
iv_weight TYPE {{P}}TOUR_PLANNER=>ty_weight.
METHODS assert_item
IMPORTING is_exp TYPE {{P}}TOUR_PLANNER=>ty_item
is_act TYPE {{P}}TOUR_PLANNER=>ty_item
iv_msg TYPE string.
METHODS assert_weight
IMPORTING iv_exp TYPE {{P}}TOUR_PLANNER=>ty_weight
iv_act TYPE {{P}}TOUR_PLANNER=>ty_weight
iv_msg TYPE string.
METHODS add_one_item FOR TESTING.
METHODS items_are_sorted FOR TESTING.
METHODS duplicate_is_rejected FOR TESTING.
METHODS empty_tour_id_is_invalid FOR TESTING.
METHODS bad_stop_no_is_invalid FOR TESTING.
METHODS negative_values_are_invalid FOR TESTING.
METHODS zero_values_are_valid FOR TESTING.
METHODS stops_of_tour_are_sorted FOR TESTING.
METHODS unknown_tour_has_no_stops FOR TESTING.
METHODS find_stop_works FOR TESTING.
METHODS summary_totals FOR TESTING.
METHODS remove_tour_works FOR TESTING.
ENDCLASS.
CLASS {{P}}T07_HIDDEN IMPLEMENTATION.
METHOD setup.
mo_cut = NEW {{P}}TOUR_PLANNER( ).
ENDMETHOD.
METHOD add_stop.
DATA(lv_status) = mo_cut->add_item( iv_tour_id = iv_tour_id
iv_stop_no = iv_stop_no
iv_customer = iv_customer
iv_packages = iv_packages
iv_weight = iv_weight ).
cl_abap_unit_assert=>assert_equals( exp = {{P}}TOUR_PLANNER=>c_status-ok
act = lv_status
msg = `add_item must store a valid stop` ).
ENDMETHOD.
METHOD assert_item.
cl_abap_unit_assert=>assert_equals( exp = is_exp-tour_id act = is_act-tour_id msg = iv_msg ).
cl_abap_unit_assert=>assert_equals( exp = is_exp-stop_no act = is_act-stop_no msg = iv_msg ).
cl_abap_unit_assert=>assert_equals( exp = is_exp-customer act = is_act-customer msg = iv_msg ).
cl_abap_unit_assert=>assert_equals( exp = is_exp-packages act = is_act-packages msg = iv_msg ).
assert_weight( iv_exp = is_exp-weight iv_act = is_act-weight iv_msg = iv_msg ).
ENDMETHOD.
METHOD assert_weight.
cl_abap_unit_assert=>assert_equals( exp = iv_exp act = iv_act msg = iv_msg ).
ENDMETHOD.
METHOD add_one_item.
add_stop( iv_tour_id = 'T001'
iv_stop_no = 1
iv_customer = 'CUST01'
iv_packages = 3
iv_weight = '12.50' ).
DATA(lt_item) = mo_cut->items( ).
cl_abap_unit_assert=>assert_equals( exp = 1 act = lines( lt_item ) ).
assert_item( is_exp = VALUE #( tour_id = 'T001'
stop_no = 1
customer = 'CUST01'
packages = 3
weight = '12.50' )
is_act = lt_item[ 1 ]
iv_msg = `one stored stop` ).
ENDMETHOD.
METHOD items_are_sorted.
add_stop( iv_tour_id = 'T002'
iv_stop_no = 2
iv_customer = 'CUST02'
iv_packages = 1
iv_weight = '5.00' ).
add_stop( iv_tour_id = 'T001'
iv_stop_no = 3
iv_customer = 'CUST03'
iv_packages = 2
iv_weight = '6.00' ).
add_stop( iv_tour_id = 'T002'
iv_stop_no = 1
iv_customer = 'CUST04'
iv_packages = 4
iv_weight = '7.00' ).
add_stop( iv_tour_id = 'T001'
iv_stop_no = 1
iv_customer = 'CUST05'
iv_packages = 5
iv_weight = '8.00' ).
DATA(lt_item) = mo_cut->items( ).
cl_abap_unit_assert=>assert_equals( exp = 4 act = lines( lt_item ) ).
cl_abap_unit_assert=>assert_equals( exp = 'T001' act = lt_item[ 1 ]-tour_id msg = `row 1 tour` ).
cl_abap_unit_assert=>assert_equals( exp = 1 act = lt_item[ 1 ]-stop_no msg = `row 1 stop` ).
cl_abap_unit_assert=>assert_equals( exp = 'T001' act = lt_item[ 2 ]-tour_id msg = `row 2 tour` ).
cl_abap_unit_assert=>assert_equals( exp = 3 act = lt_item[ 2 ]-stop_no msg = `row 2 stop` ).
cl_abap_unit_assert=>assert_equals( exp = 'T002' act = lt_item[ 3 ]-tour_id msg = `row 3 tour` ).
cl_abap_unit_assert=>assert_equals( exp = 1 act = lt_item[ 3 ]-stop_no msg = `row 3 stop` ).
cl_abap_unit_assert=>assert_equals( exp = 'T002' act = lt_item[ 4 ]-tour_id msg = `row 4 tour` ).
cl_abap_unit_assert=>assert_equals( exp = 2 act = lt_item[ 4 ]-stop_no msg = `row 4 stop` ).
ENDMETHOD.
METHOD duplicate_is_rejected.
add_stop( iv_tour_id = 'T001'
iv_stop_no = 1
iv_customer = 'CUST01'
iv_packages = 3
iv_weight = '12.50' ).
DATA(lv_status) = mo_cut->add_item( iv_tour_id = 'T001'
iv_stop_no = 1
iv_customer = 'CUST99'
iv_packages = 9
iv_weight = '99.00' ).
cl_abap_unit_assert=>assert_equals( exp = {{P}}TOUR_PLANNER=>c_status-duplicate act = lv_status ).
DATA(lt_item) = mo_cut->items( ).
cl_abap_unit_assert=>assert_equals( exp = 1 act = lines( lt_item ) ).
assert_item( is_exp = VALUE #( tour_id = 'T001'
stop_no = 1
customer = 'CUST01'
packages = 3
weight = '12.50' )
is_act = lt_item[ 1 ]
iv_msg = `stored stop keeps its values` ).
ENDMETHOD.
METHOD empty_tour_id_is_invalid.
DATA(lv_status) = mo_cut->add_item( iv_tour_id = ''
iv_stop_no = 1
iv_customer = 'CUST01'
iv_packages = 1
iv_weight = '1.00' ).
cl_abap_unit_assert=>assert_equals( exp = {{P}}TOUR_PLANNER=>c_status-invalid act = lv_status ).
DATA(lt_item) = mo_cut->items( ).
cl_abap_unit_assert=>assert_equals( exp = 0 act = lines( lt_item ) ).
ENDMETHOD.
METHOD bad_stop_no_is_invalid.
DATA(lv_status) = mo_cut->add_item( iv_tour_id = 'T001'
iv_stop_no = 0
iv_customer = 'CUST01'
iv_packages = 1
iv_weight = '1.00' ).
cl_abap_unit_assert=>assert_equals( exp = {{P}}TOUR_PLANNER=>c_status-invalid act = lv_status ).
lv_status = mo_cut->add_item( iv_tour_id = 'T001'
iv_stop_no = -1
iv_customer = 'CUST01'
iv_packages = 1
iv_weight = '1.00' ).
cl_abap_unit_assert=>assert_equals( exp = {{P}}TOUR_PLANNER=>c_status-invalid act = lv_status ).
DATA(lt_item) = mo_cut->items( ).
cl_abap_unit_assert=>assert_equals( exp = 0 act = lines( lt_item ) ).
ENDMETHOD.
METHOD negative_values_are_invalid.
DATA(lv_status) = mo_cut->add_item( iv_tour_id = 'T001'
iv_stop_no = 1
iv_customer = 'CUST01'
iv_packages = -1
iv_weight = '1.00' ).
cl_abap_unit_assert=>assert_equals( exp = {{P}}TOUR_PLANNER=>c_status-invalid act = lv_status ).
lv_status = mo_cut->add_item( iv_tour_id = 'T001'
iv_stop_no = 1
iv_customer = 'CUST01'
iv_packages = 1
iv_weight = '-1.00' ).
cl_abap_unit_assert=>assert_equals( exp = {{P}}TOUR_PLANNER=>c_status-invalid act = lv_status ).
DATA(lt_item) = mo_cut->items( ).
cl_abap_unit_assert=>assert_equals( exp = 0 act = lines( lt_item ) ).
ENDMETHOD.
METHOD zero_values_are_valid.
add_stop( iv_tour_id = 'T001'
iv_stop_no = 1
iv_customer = ''
iv_packages = 0
iv_weight = '0.00' ).
DATA(lt_item) = mo_cut->items( ).
cl_abap_unit_assert=>assert_equals( exp = 1 act = lines( lt_item ) ).
cl_abap_unit_assert=>assert_equals( exp = 0 act = lt_item[ 1 ]-packages ).
ENDMETHOD.
METHOD stops_of_tour_are_sorted.
add_stop( iv_tour_id = 'T001'
iv_stop_no = 2
iv_customer = 'CUST02'
iv_packages = 1
iv_weight = '5.00' ).
add_stop( iv_tour_id = 'T002'
iv_stop_no = 1
iv_customer = 'CUST03'
iv_packages = 2
iv_weight = '6.00' ).
add_stop( iv_tour_id = 'T001'
iv_stop_no = 1
iv_customer = 'CUST04'
iv_packages = 4
iv_weight = '7.00' ).
DATA(lt_item) = mo_cut->stops_of_tour( 'T001' ).
cl_abap_unit_assert=>assert_equals( exp = 2 act = lines( lt_item ) ).
cl_abap_unit_assert=>assert_equals( exp = 1 act = lt_item[ 1 ]-stop_no ).
cl_abap_unit_assert=>assert_equals( exp = 'CUST04' act = lt_item[ 1 ]-customer ).
cl_abap_unit_assert=>assert_equals( exp = 2 act = lt_item[ 2 ]-stop_no ).
ENDMETHOD.
METHOD unknown_tour_has_no_stops.
add_stop( iv_tour_id = 'T001'
iv_stop_no = 1
iv_customer = 'CUST01'
iv_packages = 1
iv_weight = '1.00' ).
DATA(lt_item) = mo_cut->stops_of_tour( 'T999' ).
cl_abap_unit_assert=>assert_equals( exp = 0 act = lines( lt_item ) ).
ENDMETHOD.
METHOD find_stop_works.
add_stop( iv_tour_id = 'T001'
iv_stop_no = 1
iv_customer = 'CUST01'
iv_packages = 3
iv_weight = '12.50' ).
add_stop( iv_tour_id = 'T001'
iv_stop_no = 2
iv_customer = 'CUST02'
iv_packages = 4
iv_weight = '13.50' ).
assert_item( is_exp = VALUE #( tour_id = 'T001'
stop_no = 2
customer = 'CUST02'
packages = 4
weight = '13.50' )
is_act = mo_cut->find_stop( iv_tour_id = 'T001' iv_stop_no = 2 )
iv_msg = `known stop` ).
DATA(ls_missing) = mo_cut->find_stop( iv_tour_id = 'T001' iv_stop_no = 9 ).
cl_abap_unit_assert=>assert_initial( act = ls_missing-tour_id ).
cl_abap_unit_assert=>assert_initial( act = ls_missing-stop_no ).
cl_abap_unit_assert=>assert_initial( act = ls_missing-customer ).
cl_abap_unit_assert=>assert_initial( act = ls_missing-packages ).
cl_abap_unit_assert=>assert_initial( act = ls_missing-weight ).
ls_missing = mo_cut->find_stop( iv_tour_id = 'T999' iv_stop_no = 1 ).
cl_abap_unit_assert=>assert_initial( act = ls_missing-tour_id ).
ENDMETHOD.
METHOD summary_totals.
add_stop( iv_tour_id = 'T002'
iv_stop_no = 1
iv_customer = 'CUST01'
iv_packages = 2
iv_weight = '10.25' ).
add_stop( iv_tour_id = 'T001'
iv_stop_no = 1
iv_customer = 'CUST02'
iv_packages = 3
iv_weight = '20.50' ).
add_stop( iv_tour_id = 'T002'
iv_stop_no = 2
iv_customer = 'CUST03'
iv_packages = 4
iv_weight = '30.75' ).
add_stop( iv_tour_id = 'T001'
iv_stop_no = 2
iv_customer = 'CUST04'
iv_packages = 5
iv_weight = '40.25' ).
DATA(lt_tour) = mo_cut->tour_summary( ).
cl_abap_unit_assert=>assert_equals( exp = 2 act = lines( lt_tour ) ).
cl_abap_unit_assert=>assert_equals( exp = 'T001' act = lt_tour[ 1 ]-tour_id ).
cl_abap_unit_assert=>assert_equals( exp = 2 act = lt_tour[ 1 ]-stops ).
cl_abap_unit_assert=>assert_equals( exp = 8 act = lt_tour[ 1 ]-packages ).
assert_weight( iv_exp = '60.75' iv_act = lt_tour[ 1 ]-weight iv_msg = `T001 weight` ).
cl_abap_unit_assert=>assert_equals( exp = 'T002' act = lt_tour[ 2 ]-tour_id ).
cl_abap_unit_assert=>assert_equals( exp = 2 act = lt_tour[ 2 ]-stops ).
cl_abap_unit_assert=>assert_equals( exp = 6 act = lt_tour[ 2 ]-packages ).
assert_weight( iv_exp = '41.00' iv_act = lt_tour[ 2 ]-weight iv_msg = `T002 weight` ).
ENDMETHOD.
METHOD remove_tour_works.
add_stop( iv_tour_id = 'T001'
iv_stop_no = 1
iv_customer = 'CUST01'
iv_packages = 1
iv_weight = '1.00' ).
add_stop( iv_tour_id = 'T001'
iv_stop_no = 2
iv_customer = 'CUST02'
iv_packages = 2
iv_weight = '2.00' ).
add_stop( iv_tour_id = 'T002'
iv_stop_no = 1
iv_customer = 'CUST03'
iv_packages = 3
iv_weight = '3.00' ).
cl_abap_unit_assert=>assert_equals( exp = 2 act = mo_cut->remove_tour( 'T001' ) ).
DATA(lt_item) = mo_cut->items( ).
cl_abap_unit_assert=>assert_equals( exp = 1 act = lines( lt_item ) ).
cl_abap_unit_assert=>assert_equals( exp = 'T002' act = lt_item[ 1 ]-tour_id ).
cl_abap_unit_assert=>assert_equals( exp = 0 act = mo_cut->remove_tour( 'T001' ) ).
DATA(lt_tour) = mo_cut->tour_summary( ).
cl_abap_unit_assert=>assert_equals( exp = 1 act = lines( lt_tour ) ).
cl_abap_unit_assert=>assert_equals( exp = 'T002' act = lt_tour[ 1 ]-tour_id ).
ENDMETHOD.
ENDCLASS.

View File

@@ -0,0 +1,143 @@
CLASS {{P}}TOUR_PLANNER DEFINITION PUBLIC FINAL CREATE PUBLIC.
PUBLIC SECTION.
TYPES ty_status TYPE c LENGTH 10.
TYPES ty_tour_id TYPE c LENGTH 4.
TYPES ty_customer TYPE c LENGTH 10.
TYPES ty_weight TYPE p LENGTH 8 DECIMALS 2.
CONSTANTS:
BEGIN OF c_status,
ok TYPE ty_status VALUE 'OK',
invalid TYPE ty_status VALUE 'INVALID',
duplicate TYPE ty_status VALUE 'DUPLICATE',
END OF c_status.
TYPES:
BEGIN OF ty_item,
tour_id TYPE ty_tour_id,
stop_no TYPE i,
customer TYPE ty_customer,
packages TYPE i,
weight TYPE ty_weight,
END OF ty_item,
tt_item TYPE STANDARD TABLE OF ty_item WITH EMPTY KEY,
BEGIN OF ty_tour,
tour_id TYPE ty_tour_id,
stops TYPE i,
packages TYPE i,
weight TYPE ty_weight,
END OF ty_tour,
tt_tour TYPE STANDARD TABLE OF ty_tour WITH EMPTY KEY.
METHODS add_item
IMPORTING iv_tour_id TYPE ty_tour_id
iv_stop_no TYPE i
iv_customer TYPE ty_customer
iv_packages TYPE i
iv_weight TYPE ty_weight
RETURNING VALUE(rv_status) TYPE ty_status.
METHODS remove_tour
IMPORTING iv_tour_id TYPE ty_tour_id
RETURNING VALUE(rv_count) TYPE i.
METHODS items
RETURNING VALUE(rt_item) TYPE tt_item.
METHODS stops_of_tour
IMPORTING iv_tour_id TYPE ty_tour_id
RETURNING VALUE(rt_item) TYPE tt_item.
METHODS find_stop
IMPORTING iv_tour_id TYPE ty_tour_id
iv_stop_no TYPE i
RETURNING VALUE(rs_item) TYPE ty_item.
METHODS tour_summary
RETURNING VALUE(rt_tour) TYPE tt_tour.
PRIVATE SECTION.
TYPES tt_stop TYPE SORTED TABLE OF ty_item WITH UNIQUE KEY tour_id stop_no.
DATA mt_stop TYPE tt_stop.
METHODS is_invalid
IMPORTING iv_tour_id TYPE ty_tour_id
iv_stop_no TYPE i
iv_packages TYPE i
iv_weight TYPE ty_weight
RETURNING VALUE(rv_invalid) TYPE abap_bool.
ENDCLASS.
CLASS {{P}}TOUR_PLANNER IMPLEMENTATION.
METHOD add_item.
IF is_invalid( iv_tour_id = iv_tour_id
iv_stop_no = iv_stop_no
iv_packages = iv_packages
iv_weight = iv_weight ) = abap_true.
rv_status = c_status-invalid.
RETURN.
ENDIF.
DATA(ls_stop) = VALUE ty_item( tour_id = iv_tour_id
stop_no = iv_stop_no
customer = iv_customer
packages = iv_packages
weight = iv_weight ).
INSERT ls_stop INTO TABLE mt_stop.
IF sy-subrc <> 0.
rv_status = c_status-duplicate.
RETURN.
ENDIF.
rv_status = c_status-ok.
ENDMETHOD.
METHOD remove_tour.
DELETE mt_stop WHERE tour_id = iv_tour_id.
rv_count = sy-dbcnt.
ENDMETHOD.
METHOD items.
rt_item = mt_stop.
ENDMETHOD.
METHOD stops_of_tour.
rt_item = VALUE #( FOR ls_stop IN mt_stop WHERE ( tour_id = iv_tour_id ) ( ls_stop ) ).
ENDMETHOD.
METHOD find_stop.
READ TABLE mt_stop WITH KEY tour_id = iv_tour_id
stop_no = iv_stop_no INTO rs_item.
IF sy-subrc <> 0.
CLEAR rs_item.
ENDIF.
ENDMETHOD.
METHOD tour_summary.
DATA lt_tour TYPE SORTED TABLE OF ty_tour WITH UNIQUE KEY tour_id.
LOOP AT mt_stop INTO DATA(ls_stop).
READ TABLE lt_tour WITH KEY tour_id = ls_stop-tour_id ASSIGNING FIELD-SYMBOL(<ls_tour>).
IF sy-subrc <> 0.
INSERT VALUE ty_tour( tour_id = ls_stop-tour_id )
INTO TABLE lt_tour ASSIGNING <ls_tour>.
ENDIF.
<ls_tour>-stops = <ls_tour>-stops + 1.
<ls_tour>-packages = <ls_tour>-packages + ls_stop-packages.
<ls_tour>-weight = <ls_tour>-weight + ls_stop-weight.
ENDLOOP.
rt_tour = lt_tour.
ENDMETHOD.
METHOD is_invalid.
rv_invalid = xsdbool( iv_tour_id IS INITIAL
OR iv_stop_no < 1
OR iv_packages < 0
OR iv_weight < 0 ).
ENDMETHOD.
ENDCLASS.

View File

@@ -0,0 +1,93 @@
CLASS ltc_tour_planner DEFINITION FINAL FOR TESTING DURATION SHORT RISK LEVEL HARMLESS.
PRIVATE SECTION.
DATA mo_cut TYPE REF TO {{P}}TOUR_PLANNER.
METHODS setup.
METHODS add_and_read_item FOR TESTING.
METHODS duplicate_is_rejected FOR TESTING.
METHODS invalid_input_is_rejected FOR TESTING.
METHODS summary_totals FOR TESTING.
METHODS remove_tour_works FOR TESTING.
ENDCLASS.
CLASS ltc_tour_planner IMPLEMENTATION.
METHOD setup.
mo_cut = NEW {{P}}TOUR_PLANNER( ).
ENDMETHOD.
METHOD add_and_read_item.
DATA(lv_status) = mo_cut->add_item( iv_tour_id = 'T001'
iv_stop_no = 1
iv_customer = 'CUST01'
iv_packages = 3
iv_weight = '12.50' ).
cl_abap_unit_assert=>assert_equals( exp = {{P}}TOUR_PLANNER=>c_status-ok act = lv_status ).
DATA(lt_item) = mo_cut->items( ).
cl_abap_unit_assert=>assert_equals( exp = 1 act = lines( lt_item ) ).
cl_abap_unit_assert=>assert_equals( exp = 'CUST01' act = lt_item[ 1 ]-customer ).
ENDMETHOD.
METHOD duplicate_is_rejected.
DATA(lv_status) = mo_cut->add_item( iv_tour_id = 'T001'
iv_stop_no = 1
iv_customer = 'CUST01'
iv_packages = 3
iv_weight = '12.50' ).
cl_abap_unit_assert=>assert_equals( exp = {{P}}TOUR_PLANNER=>c_status-ok act = lv_status ).
lv_status = mo_cut->add_item( iv_tour_id = 'T001'
iv_stop_no = 1
iv_customer = 'CUST02'
iv_packages = 1
iv_weight = '1.00' ).
cl_abap_unit_assert=>assert_equals( exp = {{P}}TOUR_PLANNER=>c_status-duplicate act = lv_status ).
ENDMETHOD.
METHOD invalid_input_is_rejected.
DATA(lv_status) = mo_cut->add_item( iv_tour_id = ''
iv_stop_no = 1
iv_customer = 'CUST01'
iv_packages = 1
iv_weight = '1.00' ).
cl_abap_unit_assert=>assert_equals( exp = {{P}}TOUR_PLANNER=>c_status-invalid act = lv_status ).
DATA(lt_item) = mo_cut->items( ).
cl_abap_unit_assert=>assert_equals( exp = 0 act = lines( lt_item ) ).
ENDMETHOD.
METHOD summary_totals.
DATA(lv_status) = mo_cut->add_item( iv_tour_id = 'T001'
iv_stop_no = 1
iv_customer = 'CUST01'
iv_packages = 2
iv_weight = '10.00' ).
cl_abap_unit_assert=>assert_equals( exp = {{P}}TOUR_PLANNER=>c_status-ok act = lv_status ).
lv_status = mo_cut->add_item( iv_tour_id = 'T001'
iv_stop_no = 2
iv_customer = 'CUST02'
iv_packages = 3
iv_weight = '20.00' ).
cl_abap_unit_assert=>assert_equals( exp = {{P}}TOUR_PLANNER=>c_status-ok act = lv_status ).
DATA(lt_tour) = mo_cut->tour_summary( ).
cl_abap_unit_assert=>assert_equals( exp = 1 act = lines( lt_tour ) ).
cl_abap_unit_assert=>assert_equals( exp = 2 act = lt_tour[ 1 ]-stops ).
cl_abap_unit_assert=>assert_equals( exp = 5 act = lt_tour[ 1 ]-packages ).
ENDMETHOD.
METHOD remove_tour_works.
DATA(lv_status) = mo_cut->add_item( iv_tour_id = 'T001'
iv_stop_no = 1
iv_customer = 'CUST01'
iv_packages = 1
iv_weight = '1.00' ).
cl_abap_unit_assert=>assert_equals( exp = {{P}}TOUR_PLANNER=>c_status-ok act = lv_status ).
cl_abap_unit_assert=>assert_equals( exp = 1 act = mo_cut->remove_tour( 'T001' ) ).
DATA(lt_item) = mo_cut->items( ).
cl_abap_unit_assert=>assert_equals( exp = 0 act = lines( lt_item ) ).
ENDMETHOD.
ENDCLASS.

View File

@@ -0,0 +1,55 @@
INTERFACE {{p}}if_tour_planner PUBLIC.
TYPES ty_status TYPE c LENGTH 10.
CONSTANTS:
BEGIN OF c_status,
ok TYPE ty_status VALUE 'OK',
invalid TYPE ty_status VALUE 'INVALID',
duplicate TYPE ty_status VALUE 'DUPLICATE',
END OF c_status.
TYPES:
BEGIN OF ty_item,
tour_id TYPE c LENGTH 4,
stop_no TYPE i,
customer TYPE c LENGTH 10,
packages TYPE i,
weight TYPE p LENGTH 8 DECIMALS 2,
END OF ty_item,
tt_item TYPE STANDARD TABLE OF ty_item WITH EMPTY KEY,
BEGIN OF ty_tour,
tour_id TYPE c LENGTH 4,
stops TYPE i,
packages TYPE i,
weight TYPE p LENGTH 8 DECIMALS 2,
END OF ty_tour,
tt_tour TYPE STANDARD TABLE OF ty_tour WITH EMPTY KEY.
METHODS add_item
IMPORTING iv_tour_id TYPE c LENGTH 4
iv_stop_no TYPE i
iv_customer TYPE c LENGTH 10
iv_packages TYPE i
iv_weight TYPE p LENGTH 8 DECIMALS 2
RETURNING VALUE(rv_status) TYPE ty_status.
METHODS remove_tour
IMPORTING iv_tour_id TYPE c LENGTH 4
RETURNING VALUE(rv_count) TYPE i.
METHODS items
RETURNING VALUE(rt_item) TYPE tt_item.
METHODS stops_of_tour
IMPORTING iv_tour_id TYPE c LENGTH 4
RETURNING VALUE(rt_item) TYPE tt_item.
METHODS find_stop
IMPORTING iv_tour_id TYPE c LENGTH 4
iv_stop_no TYPE i
RETURNING VALUE(rs_item) TYPE ty_item.
METHODS tour_summary
RETURNING VALUE(rt_tour) TYPE tt_tour.
ENDINTERFACE.

View File

@@ -0,0 +1,118 @@
# 1. Goal
A transportation planner collects the stops of delivery tours. The planner needs one
class that stores the stops and reports the load of each tour.
# 2. Open questions
None.
# 3. Context
- The class {{P}}TOUR_PLANNER is new. Create it in package $TMP.
- A tour ID has 4 characters, for example T001. A stop number is a whole number.
A customer number has 10 characters. The weight is in kilograms with 2 decimal
places.
- No other object of this task exists.
# 4. Contract
- Create the class {{P}}TOUR_PLANNER in package $TMP.
- The class is public, final, and has a public constructor without parameters.
- The class has these public elementary types:
TYPES ty_status TYPE c LENGTH 10.
TYPES ty_tour_id TYPE c LENGTH 4.
TYPES ty_customer TYPE c LENGTH 10.
TYPES ty_weight TYPE p LENGTH 8 DECIMALS 2.
- The class has this public constant structure:
CONSTANTS:
BEGIN OF c_status,
ok TYPE ty_status VALUE 'OK',
invalid TYPE ty_status VALUE 'INVALID',
duplicate TYPE ty_status VALUE 'DUPLICATE',
END OF c_status.
- The class has these public structured types:
TYPES:
BEGIN OF ty_item,
tour_id TYPE ty_tour_id,
stop_no TYPE i,
customer TYPE ty_customer,
packages TYPE i,
weight TYPE ty_weight,
END OF ty_item,
tt_item TYPE STANDARD TABLE OF ty_item WITH EMPTY KEY,
BEGIN OF ty_tour,
tour_id TYPE ty_tour_id,
stops TYPE i,
packages TYPE i,
weight TYPE ty_weight,
END OF ty_tour,
tt_tour TYPE STANDARD TABLE OF ty_tour WITH EMPTY KEY.
- The class has these public methods:
METHODS add_item
IMPORTING iv_tour_id TYPE ty_tour_id
iv_stop_no TYPE i
iv_customer TYPE ty_customer
iv_packages TYPE i
iv_weight TYPE ty_weight
RETURNING VALUE(rv_status) TYPE ty_status.
METHODS remove_tour
IMPORTING iv_tour_id TYPE ty_tour_id
RETURNING VALUE(rv_count) TYPE i.
METHODS items
RETURNING VALUE(rt_item) TYPE tt_item.
METHODS stops_of_tour
IMPORTING iv_tour_id TYPE ty_tour_id
RETURNING VALUE(rt_item) TYPE tt_item.
METHODS find_stop
IMPORTING iv_tour_id TYPE ty_tour_id
iv_stop_no TYPE i
RETURNING VALUE(rs_item) TYPE ty_item.
METHODS tour_summary
RETURNING VALUE(rt_tour) TYPE tt_tour.
- Do not add other public methods.
# 5. Business rules
1. The tour ID and the stop number identify a stop.
2. ADD_ITEM stores one stop. It returns INVALID and changes no data if one of these
conditions is true:
- IV_TOUR_ID is empty,
- IV_STOP_NO is smaller than 1,
- IV_PACKAGES is smaller than 0,
- IV_WEIGHT is smaller than 0.
IV_CUSTOMER can be empty.
3. ADD_ITEM returns DUPLICATE and changes no data if a stop with the same tour ID and
the same stop number is already stored. The stored stop keeps its values.
4. Otherwise ADD_ITEM stores the stop and returns OK.
5. ITEMS returns all stored stops, sorted by tour ID ascending and, for the same tour
ID, by stop number ascending.
6. STOPS_OF_TOUR returns the stops of one tour, sorted by stop number ascending. The
result is empty if the tour is not known.
7. FIND_STOP returns the stop with the given tour ID and stop number. If the stop is
not known, all components of the result are initial.
8. TOUR_SUMMARY returns one line for each tour that has at least one stop. A line
contains the tour ID, the number of stops, the sum of the packages and the sum of
the weight. The result is sorted by tour ID ascending.
9. REMOVE_TOUR deletes all stops of the tour and returns the number of deleted stops.
It returns 0 if the tour is not known.
# 6. Constraints
- Release target: 7.58.
- Coding standards: Clean ABAP. Method length below 40 statements. No global variables.
No comment that restates the code.
- Out of scope: do not change objects that are not part of this task.
# 7. Acceptance
- The class is active and has no syntax error.
- The hidden tests pass.
- Write your own ABAP Unit tests for the class.

View File

@@ -0,0 +1,41 @@
{
"id": "G0001",
"category": "C",
"object_type": "CLAS",
"difficulty": 2,
"release_target": "v758",
"expected_outcome": "implement",
"budget": {
"max_tool_calls": 60,
"max_activations": 15
},
"seed": [],
"contract": [
{
"type": "CLAS",
"name": "{{P}}TOUR_PLANNER"
}
],
"out_of_scope": [],
"hidden_tests": [
{
"type": "CLAS",
"name": "{{P}}T07_HIDDEN",
"file": "hidden/t07_hidden.clas.abap",
"description": "T07 hidden tests for the tour planner"
}
],
"reference": [
{
"type": "CLAS",
"name": "{{P}}TOUR_PLANNER",
"file": "reference/tour_planner.clas.abap",
"description": "Tour planner with stored stops and tour summary",
"testclasses_file": "reference/tour_planner.testclasses.abap"
}
],
"craft_checks": [
"method_length",
"db_operation_in_loop"
]
}

View File

@@ -0,0 +1,25 @@
{
"id": "G0002",
"pool": "eval",
"object_type": "CLAS",
"category": "A",
"attempts": [
{
"stage": "validate",
"oracle": 0,
"null": 0
},
{
"stage": "bundle",
"errors": [
"invalid JSON: Extra data: line 1 column 14723 (char 14722)"
]
},
{
"stage": "validate",
"oracle": 100.0,
"null": 0
}
],
"accepted": true
}

View File

@@ -0,0 +1,132 @@
CLASS {{p}}t02_hidden DEFINITION PUBLIC FINAL CREATE PUBLIC
FOR TESTING DURATION SHORT RISK LEVEL HARMLESS.
PRIVATE SECTION.
CONSTANTS:
c_fri_05 TYPE d VALUE '20240105',
c_sat_06 TYPE d VALUE '20240106',
c_sun_07 TYPE d VALUE '20240107',
c_mon_08 TYPE d VALUE '20240108',
c_tue_09 TYPE d VALUE '20240109',
c_wed_10 TYPE d VALUE '20240110',
c_thu_11 TYPE d VALUE '20240111',
c_fri_12 TYPE d VALUE '20240112'.
DATA mo_cut TYPE REF TO {{p}}delivery_schedule.
DATA mt_holidays TYPE {{p}}delivery_schedule=>ty_holidays.
DATA mt_no_holidays TYPE {{p}}delivery_schedule=>ty_holidays.
METHODS setup.
METHODS weekend_is_not_working FOR TESTING.
METHODS working_day_is_true FOR TESTING.
METHODS holiday_is_not_working FOR TESTING.
METHODS next_day_same_for_workday FOR TESTING.
METHODS next_day_from_weekend FOR TESTING.
METHODS next_day_skips_holidays FOR TESTING.
METHODS add_zero_days FOR TESTING.
METHODS add_days_forward FOR TESTING.
METHODS add_forward_skips_holiday FOR TESTING.
METHODS add_days_backward FOR TESTING.
METHODS count_working_days_inclusive FOR TESTING.
METHODS count_reversed_range_is_zero FOR TESTING.
ENDCLASS.
CLASS {{p}}t02_hidden IMPLEMENTATION.
METHOD setup.
mo_cut = NEW {{p}}delivery_schedule( ).
mt_holidays = VALUE #( ( c_tue_09 ) ( c_wed_10 ) ).
ENDMETHOD.
METHOD weekend_is_not_working.
cl_abap_unit_assert=>assert_equals(
exp = abap_false
act = mo_cut->is_working_day( iv_date = c_sat_06 it_holidays = mt_no_holidays )
msg = 'Saturday is not a working day' ).
cl_abap_unit_assert=>assert_equals(
exp = abap_false
act = mo_cut->is_working_day( iv_date = c_sun_07 it_holidays = mt_no_holidays )
msg = 'Sunday is not a working day' ).
ENDMETHOD.
METHOD working_day_is_true.
cl_abap_unit_assert=>assert_equals(
exp = abap_true
act = mo_cut->is_working_day( iv_date = c_mon_08 it_holidays = mt_holidays ) ).
ENDMETHOD.
METHOD holiday_is_not_working.
cl_abap_unit_assert=>assert_equals(
exp = abap_false
act = mo_cut->is_working_day( iv_date = c_tue_09 it_holidays = mt_holidays ) ).
ENDMETHOD.
METHOD next_day_same_for_workday.
cl_abap_unit_assert=>assert_equals(
exp = c_mon_08
act = mo_cut->next_working_day( iv_date = c_mon_08 it_holidays = mt_holidays ) ).
ENDMETHOD.
METHOD next_day_from_weekend.
cl_abap_unit_assert=>assert_equals(
exp = c_mon_08
act = mo_cut->next_working_day( iv_date = c_sat_06 it_holidays = mt_no_holidays ) ).
ENDMETHOD.
METHOD next_day_skips_holidays.
cl_abap_unit_assert=>assert_equals(
exp = c_thu_11
act = mo_cut->next_working_day( iv_date = c_tue_09 it_holidays = mt_holidays ) ).
ENDMETHOD.
METHOD add_zero_days.
cl_abap_unit_assert=>assert_equals(
exp = c_sat_06
act = mo_cut->add_working_days( iv_start = c_sat_06 iv_days = 0 it_holidays = mt_holidays ) ).
ENDMETHOD.
METHOD add_days_forward.
cl_abap_unit_assert=>assert_equals(
exp = c_tue_09
act = mo_cut->add_working_days( iv_start = c_fri_05 iv_days = 2 it_holidays = mt_no_holidays ) ).
ENDMETHOD.
METHOD add_forward_skips_holiday.
cl_abap_unit_assert=>assert_equals(
exp = c_thu_11
act = mo_cut->add_working_days( iv_start = c_fri_05 iv_days = 2 it_holidays = mt_holidays ) ).
ENDMETHOD.
METHOD add_days_backward.
cl_abap_unit_assert=>assert_equals(
exp = c_fri_05
act = mo_cut->add_working_days( iv_start = c_mon_08 iv_days = -1 it_holidays = mt_no_holidays ) ).
ENDMETHOD.
METHOD count_working_days_inclusive.
cl_abap_unit_assert=>assert_equals(
exp = 5
act = mo_cut->count_working_days( iv_from = c_mon_08 iv_to = c_fri_12 it_holidays = mt_no_holidays )
msg = 'Monday to Friday without holidays' ).
cl_abap_unit_assert=>assert_equals(
exp = 3
act = mo_cut->count_working_days( iv_from = c_mon_08 iv_to = c_fri_12 it_holidays = mt_holidays )
msg = 'Two holidays in the interval' ).
cl_abap_unit_assert=>assert_equals(
exp = 1
act = mo_cut->count_working_days( iv_from = c_mon_08 iv_to = c_mon_08 it_holidays = mt_no_holidays )
msg = 'One working day' ).
cl_abap_unit_assert=>assert_equals(
exp = 0
act = mo_cut->count_working_days( iv_from = c_tue_09 iv_to = c_tue_09 it_holidays = mt_holidays )
msg = 'One holiday' ).
ENDMETHOD.
METHOD count_reversed_range_is_zero.
cl_abap_unit_assert=>assert_equals(
exp = 0
act = mo_cut->count_working_days( iv_from = c_fri_12 iv_to = c_mon_08 it_holidays = mt_holidays ) ).
ENDMETHOD.
ENDCLASS.

View File

@@ -0,0 +1,104 @@
CLASS {{p}}delivery_schedule DEFINITION PUBLIC FINAL CREATE PUBLIC.
PUBLIC SECTION.
TYPES ty_holidays TYPE STANDARD TABLE OF d WITH EMPTY KEY.
METHODS is_working_day
IMPORTING iv_date TYPE d
it_holidays TYPE ty_holidays
RETURNING VALUE(rv_working) TYPE abap_bool.
METHODS next_working_day
IMPORTING iv_date TYPE d
it_holidays TYPE ty_holidays
RETURNING VALUE(rv_date) TYPE d.
METHODS add_working_days
IMPORTING iv_start TYPE d
iv_days TYPE i
it_holidays TYPE ty_holidays
RETURNING VALUE(rv_date) TYPE d.
METHODS count_working_days
IMPORTING iv_from TYPE d
iv_to TYPE d
it_holidays TYPE ty_holidays
RETURNING VALUE(rv_count) TYPE i.
PRIVATE SECTION.
CONSTANTS c_reference_monday TYPE d VALUE '19000101'.
METHODS is_weekend
IMPORTING iv_date TYPE d
RETURNING VALUE(rv_weekend) TYPE abap_bool.
METHODS is_holiday
IMPORTING iv_date TYPE d
it_holidays TYPE ty_holidays
RETURNING VALUE(rv_holiday) TYPE abap_bool.
ENDCLASS.
CLASS {{p}}delivery_schedule IMPLEMENTATION.
METHOD is_working_day.
rv_working = xsdbool( is_weekend( iv_date ) = abap_false
AND is_holiday( iv_date = iv_date
it_holidays = it_holidays ) = abap_false ).
ENDMETHOD.
METHOD next_working_day.
rv_date = iv_date.
WHILE is_working_day( iv_date = rv_date
it_holidays = it_holidays ) = abap_false.
rv_date = rv_date + 1.
ENDWHILE.
ENDMETHOD.
METHOD add_working_days.
rv_date = iv_start.
IF iv_days = 0.
RETURN.
ENDIF.
DATA(lv_remaining) = iv_days.
DATA(lv_step) = COND i( WHEN iv_days > 0 THEN 1 ELSE -1 ).
WHILE lv_remaining <> 0.
rv_date = rv_date + lv_step.
IF is_working_day( iv_date = rv_date
it_holidays = it_holidays ) = abap_true.
lv_remaining = lv_remaining - lv_step.
ENDIF.
ENDWHILE.
ENDMETHOD.
METHOD count_working_days.
rv_count = 0.
DATA(lv_date) = iv_from.
WHILE lv_date <= iv_to.
IF is_working_day( iv_date = lv_date
it_holidays = it_holidays ) = abap_true.
rv_count = rv_count + 1.
ENDIF.
lv_date = lv_date + 1.
ENDWHILE.
ENDMETHOD.
METHOD is_weekend.
DATA(lv_days) = iv_date - c_reference_monday.
rv_weekend = xsdbool( lv_days MOD 7 >= 5 ).
ENDMETHOD.
METHOD is_holiday.
rv_holiday = abap_false.
LOOP AT it_holidays INTO DATA(lv_holiday).
IF lv_holiday = iv_date.
rv_holiday = abap_true.
RETURN.
ENDIF.
ENDLOOP.
ENDMETHOD.
ENDCLASS.

View File

@@ -0,0 +1,72 @@
CLASS ltc_delivery_schedule DEFINITION FINAL FOR TESTING
DURATION SHORT RISK LEVEL HARMLESS.
PRIVATE SECTION.
CONSTANTS:
c_fri_05 TYPE d VALUE '20240105',
c_sat_06 TYPE d VALUE '20240106',
c_sun_07 TYPE d VALUE '20240107',
c_mon_08 TYPE d VALUE '20240108',
c_tue_09 TYPE d VALUE '20240109',
c_wed_10 TYPE d VALUE '20240110',
c_thu_11 TYPE d VALUE '20240111',
c_fri_12 TYPE d VALUE '20240112'.
DATA mo_cut TYPE REF TO {{p}}delivery_schedule.
DATA mt_holidays TYPE {{p}}delivery_schedule=>ty_holidays.
DATA mt_no_holidays TYPE {{p}}delivery_schedule=>ty_holidays.
METHODS setup.
METHODS weekend_is_not_working FOR TESTING.
METHODS holiday_is_not_working FOR TESTING.
METHODS next_day_skips_weekend FOR TESTING.
METHODS add_days_forward FOR TESTING.
METHODS add_days_backward FOR TESTING.
METHODS count_working_days FOR TESTING.
ENDCLASS.
CLASS ltc_delivery_schedule IMPLEMENTATION.
METHOD setup.
mo_cut = NEW {{p}}delivery_schedule( ).
mt_holidays = VALUE #( ( c_tue_09 ) ( c_wed_10 ) ).
ENDMETHOD.
METHOD weekend_is_not_working.
cl_abap_unit_assert=>assert_equals(
exp = abap_false
act = mo_cut->is_working_day( iv_date = c_sat_06 it_holidays = mt_no_holidays ) ).
ENDMETHOD.
METHOD holiday_is_not_working.
cl_abap_unit_assert=>assert_equals(
exp = abap_false
act = mo_cut->is_working_day( iv_date = c_tue_09 it_holidays = mt_holidays ) ).
ENDMETHOD.
METHOD next_day_skips_weekend.
cl_abap_unit_assert=>assert_equals(
exp = c_mon_08
act = mo_cut->next_working_day( iv_date = c_sun_07 it_holidays = mt_holidays ) ).
ENDMETHOD.
METHOD add_days_forward.
cl_abap_unit_assert=>assert_equals(
exp = c_thu_11
act = mo_cut->add_working_days( iv_start = c_fri_05 iv_days = 2 it_holidays = mt_holidays ) ).
ENDMETHOD.
METHOD add_days_backward.
cl_abap_unit_assert=>assert_equals(
exp = c_fri_05
act = mo_cut->add_working_days( iv_start = c_mon_08 iv_days = -1 it_holidays = mt_holidays ) ).
ENDMETHOD.
METHOD count_working_days.
cl_abap_unit_assert=>assert_equals(
exp = 3
act = mo_cut->count_working_days( iv_from = c_mon_08 iv_to = c_fri_12 it_holidays = mt_holidays ) ).
ENDMETHOD.
ENDCLASS.

View File

@@ -0,0 +1,84 @@
# 1. Goal
Calculate delivery dates for sales orders. A delivery date must be a working day.
Weekends and plant holidays are not working days. A planner uses the class before
the system confirms a delivery date.
# 2. Open questions
None.
# 3. Context
- The class {{P}}DELIVERY_SCHEDULE does not exist. Create it in package $TMP.
- The caller gives the plant holidays as a table of dates. The class does not read
a factory calendar. The class does not read the system date.
# 4. Contract
- Create the class {{P}}DELIVERY_SCHEDULE in package $TMP.
- The class is public, final, and has a public constructor without parameters.
- The class has the public type TY_HOLIDAYS. TY_HOLIDAYS is a standard table of
dates (TYPE d) with an empty key.
- The class has the public methods below. Do not add other public methods.
```abap
METHODS is_working_day
IMPORTING iv_date TYPE d
it_holidays TYPE ty_holidays
RETURNING VALUE(rv_working) TYPE abap_bool.
METHODS next_working_day
IMPORTING iv_date TYPE d
it_holidays TYPE ty_holidays
RETURNING VALUE(rv_date) TYPE d.
METHODS add_working_days
IMPORTING iv_start TYPE d
iv_days TYPE i
it_holidays TYPE ty_holidays
RETURNING VALUE(rv_date) TYPE d.
METHODS count_working_days
IMPORTING iv_from TYPE d
iv_to TYPE d
it_holidays TYPE ty_holidays
RETURNING VALUE(rv_count) TYPE i.
```
# 5. Business rules
1. The days of the week are Monday, Tuesday, Wednesday, Thursday, Friday, Saturday,
Sunday.
2. A date is a working day when these two conditions are true:
- The date is not a Saturday and not a Sunday.
- The date is not in it_holidays.
3. IS_WORKING_DAY returns abap_true for a working day. It returns abap_false for
all other days.
4. NEXT_WORKING_DAY returns the first working day that is on iv_date or after
iv_date.
5. ADD_WORKING_DAYS counts working days.
- If iv_days is 0, the result is iv_start.
- If iv_days is greater than 0, the result is the date that is iv_days working
days after iv_start. Count only working days. Ignore all other days.
- If iv_days is less than 0, the result is the date that is the absolute value
of iv_days working days before iv_start. Count only working days. Ignore all
other days.
- Example: iv_start is a Friday and iv_days is 1. The result is the next Monday.
- Example: iv_start is a Monday and iv_days is -1. The result is the Friday
before.
6. COUNT_WORKING_DAYS counts the working days in the interval from iv_from to
iv_to. The interval contains iv_from and iv_to.
- If iv_from is after iv_to, the result is 0.
- If iv_from is equal to iv_to, the result is 1 for a working day and 0 for all
other days.
7. it_holidays can be empty. it_holidays can contain dates that are outside the
interval. it_holidays can contain the same date more than one time. A date that
occurs more than one time in it_holidays is a holiday.
# 6. Constraints
- Release target: 8.16.
- Coding standards: Clean ABAP. A method has less than 40 statements. No global
variables. No comment that restates the code.
- Out of scope: do not read a factory calendar. Do not read the system date. Do
not create other objects.
# 7. Acceptance
- The class is active and has no syntax error.
- The hidden tests pass.
- Write your own ABAP Unit tests for the class.

View File

@@ -0,0 +1,41 @@
{
"id": "G0002",
"category": "A",
"object_type": "CLAS",
"difficulty": 2,
"release_target": "v816",
"expected_outcome": "implement",
"budget": {
"max_tool_calls": 50,
"max_activations": 12
},
"seed": [],
"contract": [
{
"type": "CLAS",
"name": "{{P}}DELIVERY_SCHEDULE"
}
],
"out_of_scope": [],
"hidden_tests": [
{
"type": "CLAS",
"name": "{{P}}T02_HIDDEN",
"file": "hidden/t02_hidden.clas.abap",
"description": "Working day and delivery date rules"
}
],
"reference": [
{
"type": "CLAS",
"name": "{{P}}DELIVERY_SCHEDULE",
"file": "reference/delivery_schedule.clas.abap",
"description": "Delivery date calculation with weekends and plant holidays",
"testclasses_file": "reference/delivery_schedule.testclasses.abap"
}
],
"craft_checks": [
"method_length",
"no_global_variables"
]
}