EV 충전 플랫폼 초기 백업
This commit is contained in:
625
simulator.html
Normal file
625
simulator.html
Normal file
@@ -0,0 +1,625 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>EV 충전 시뮬레이터</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--bg-0:#06090f;--bg-1:#0c1018;--bg-2:#121824;--bg-3:#1a2236;
|
||||
--bg-card:rgba(18,24,36,0.85);
|
||||
--accent:#00d4ff;--accent-dim:rgba(0,212,255,0.12);
|
||||
--green:#10b981;--green-dim:rgba(16,185,129,0.12);
|
||||
--amber:#f59e0b;--amber-dim:rgba(245,158,11,0.12);
|
||||
--red:#ef4444;--red-dim:rgba(239,68,68,0.12);
|
||||
--purple:#8b5cf6;--purple-dim:rgba(139,92,246,0.12);
|
||||
--text:#e2e8f0;--text-2:#94a3b8;--text-3:#64748b;
|
||||
--border:rgba(255,255,255,0.06);--border-accent:rgba(0,212,255,0.15);
|
||||
--radius:12px;--radius-sm:8px;
|
||||
--font-display:'Outfit',sans-serif;--font-body:'Noto Sans KR',sans-serif;--font-mono:'JetBrains Mono',monospace;
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg-0);color:var(--text);font-family:var(--font-body);min-height:100vh}
|
||||
body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellipse 60% 40% at 15% 5%,rgba(0,212,255,0.04) 0%,transparent 60%),radial-gradient(ellipse 50% 30% at 85% 90%,rgba(139,92,246,0.03) 0%,transparent 60%);pointer-events:none}
|
||||
|
||||
.container{max-width:1100px;margin:0 auto;padding:32px 24px;position:relative;z-index:1}
|
||||
|
||||
/* 헤더 */
|
||||
.header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:32px;padding-bottom:24px;border-bottom:1px solid var(--border)}
|
||||
.header h1{font-family:var(--font-display);font-size:28px;font-weight:700;color:#fff;letter-spacing:-0.02em}
|
||||
.header small{font-family:var(--font-mono);font-size:10px;color:var(--accent);letter-spacing:0.2em;display:block;margin-top:4px}
|
||||
.header-right{text-align:right}
|
||||
.header-link{font-family:var(--font-mono);font-size:11px;color:var(--text-3);text-decoration:none;transition:color 0.15s}
|
||||
.header-link:hover{color:var(--accent)}
|
||||
|
||||
/* 레이아웃 */
|
||||
.layout{display:grid;grid-template-columns:340px 1fr;gap:24px}
|
||||
@media(max-width:800px){.layout{grid-template-columns:1fr}}
|
||||
|
||||
/* 파라미터 패널 */
|
||||
.params-panel{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);backdrop-filter:blur(12px);position:sticky;top:24px;height:fit-content}
|
||||
.params-header{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
|
||||
.params-title{font-family:var(--font-display);font-size:15px;font-weight:600;color:#fff}
|
||||
.params-body{padding:20px}
|
||||
|
||||
.param-section{margin-bottom:20px}
|
||||
.param-section-title{font-family:var(--font-mono);font-size:9px;letter-spacing:0.2em;color:var(--accent);text-transform:uppercase;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid var(--border)}
|
||||
|
||||
.param-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px}
|
||||
.param-row.full{grid-template-columns:1fr}
|
||||
|
||||
.param-group{display:flex;flex-direction:column;gap:4px}
|
||||
.param-label{font-size:11px;color:var(--text-3);font-weight:500}
|
||||
.param-input{padding:8px 10px;background:var(--bg-3);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:var(--font-mono);font-size:12px;outline:none;transition:border-color 0.15s;width:100%}
|
||||
.param-input:focus{border-color:var(--accent)}
|
||||
.param-input::placeholder{color:var(--text-3)}
|
||||
select.param-input{cursor:pointer}
|
||||
|
||||
.param-hint{font-size:10px;color:var(--text-3);font-family:var(--font-mono);margin-top:2px}
|
||||
|
||||
/* 충전 시뮬레이션 슬라이더 */
|
||||
.charge-slider-wrap{margin:12px 0}
|
||||
.charge-slider{width:100%;-webkit-appearance:none;height:6px;border-radius:3px;background:var(--bg-3);outline:none}
|
||||
.charge-slider::-webkit-slider-thumb{-webkit-appearance:none;width:18px;height:18px;border-radius:50%;background:var(--accent);cursor:pointer;box-shadow:0 0 8px rgba(0,212,255,0.3)}
|
||||
.charge-preview{display:flex;justify-content:space-between;margin-top:6px;font-family:var(--font-mono);font-size:11px;color:var(--text-2)}
|
||||
.charge-preview .val{color:var(--accent);font-weight:500}
|
||||
|
||||
/* 버튼 */
|
||||
.btn-run{width:100%;padding:14px;background:linear-gradient(135deg,rgba(0,212,255,0.15),rgba(16,185,129,0.1));border:1px solid var(--accent);border-radius:var(--radius-sm);color:var(--accent);font-family:var(--font-display);font-size:15px;font-weight:600;cursor:pointer;transition:all 0.2s;margin-top:16px;letter-spacing:0.02em}
|
||||
.btn-run:hover{background:rgba(0,212,255,0.2);box-shadow:0 0 24px rgba(0,212,255,0.1)}
|
||||
.btn-run:disabled{opacity:0.4;cursor:not-allowed}
|
||||
.btn-run.running{animation:runPulse 1.5s ease-in-out infinite}
|
||||
@keyframes runPulse{0%,100%{box-shadow:0 0 0 0 rgba(0,212,255,0.2)}50%{box-shadow:0 0 0 8px rgba(0,212,255,0)}}
|
||||
|
||||
.btn-step{width:100%;padding:10px;background:var(--bg-3);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-2);font-family:var(--font-mono);font-size:11px;cursor:pointer;transition:all 0.15s;margin-top:8px}
|
||||
.btn-step:hover{border-color:var(--amber);color:var(--amber)}
|
||||
|
||||
.btn-row{display:flex;gap:8px;margin-top:8px}
|
||||
.btn-row .btn-step{flex:1}
|
||||
|
||||
/* 결과 패널 */
|
||||
.results-panel{display:flex;flex-direction:column;gap:12px}
|
||||
|
||||
.step-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);backdrop-filter:blur(12px);overflow:hidden;transition:border-color 0.3s}
|
||||
.step-card.success{border-color:rgba(16,185,129,0.3)}
|
||||
.step-card.error{border-color:rgba(239,68,68,0.3)}
|
||||
.step-card.running{border-color:rgba(0,212,255,0.3)}
|
||||
.step-card.waiting{opacity:0.5}
|
||||
|
||||
.step-header{display:flex;align-items:center;gap:12px;padding:14px 18px;cursor:pointer;user-select:none}
|
||||
.step-num{width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:var(--font-mono);font-size:11px;font-weight:600;flex-shrink:0;border:1.5px solid var(--border);color:var(--text-3);background:var(--bg-3);transition:all 0.3s}
|
||||
.step-card.success .step-num{border-color:var(--green);color:var(--green);background:var(--green-dim)}
|
||||
.step-card.error .step-num{border-color:var(--red);color:var(--red);background:var(--red-dim)}
|
||||
.step-card.running .step-num{border-color:var(--accent);color:var(--accent);background:var(--accent-dim);animation:runPulse 1.5s infinite}
|
||||
.step-title{flex:1;font-size:13px;font-weight:500;color:#fff}
|
||||
.step-subtitle{font-family:var(--font-mono);font-size:10px;color:var(--text-3);margin-top:2px}
|
||||
.step-status{font-family:var(--font-mono);font-size:10px;padding:3px 8px;border-radius:4px}
|
||||
.step-status.ok{background:var(--green-dim);color:var(--green)}
|
||||
.step-status.fail{background:var(--red-dim);color:var(--red)}
|
||||
.step-status.run{background:var(--accent-dim);color:var(--accent)}
|
||||
.step-status.wait{background:rgba(100,116,139,0.1);color:var(--text-3)}
|
||||
.step-time{font-family:var(--font-mono);font-size:10px;color:var(--text-3);margin-left:8px}
|
||||
|
||||
.step-body{padding:0 18px 14px;display:none}
|
||||
.step-card.open .step-body{display:block}
|
||||
.step-json{background:#0a0e17;border:1px solid rgba(255,255,255,0.04);border-radius:6px;padding:12px 14px;font-family:var(--font-mono);font-size:11px;color:#8ec8e8;line-height:1.6;overflow-x:auto;max-height:300px;overflow-y:auto;white-space:pre-wrap;word-break:break-all}
|
||||
.step-json .k{color:#f472b6} .step-json .s{color:#a5f3c4} .step-json .n{color:#c4b5fd} .step-json .b{color:#fdba74}
|
||||
|
||||
/* 요약 카드 */
|
||||
.summary-card{background:linear-gradient(135deg,rgba(16,185,129,0.08),rgba(0,212,255,0.05));border:1px solid rgba(16,185,129,0.2);border-radius:var(--radius);padding:24px;text-align:center}
|
||||
.summary-card h3{font-family:var(--font-display);font-size:18px;color:#fff;margin-bottom:16px}
|
||||
.summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
|
||||
.summary-item{text-align:center}
|
||||
.summary-val{font-family:var(--font-display);font-size:24px;font-weight:700;color:var(--green);line-height:1}
|
||||
.summary-label{font-size:11px;color:var(--text-3);margin-top:4px}
|
||||
.summary-saved{margin-top:16px;font-family:var(--font-mono);font-size:13px;color:var(--amber);padding:10px;background:var(--amber-dim);border-radius:6px}
|
||||
|
||||
/* 프리셋 */
|
||||
.preset-row{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:16px}
|
||||
.preset-btn{padding:5px 10px;background:var(--bg-3);border:1px solid var(--border);border-radius:4px;color:var(--text-2);font-family:var(--font-mono);font-size:10px;cursor:pointer;transition:all 0.15s}
|
||||
.preset-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.preset-btn.active{background:var(--accent-dim);border-color:var(--accent);color:var(--accent)}
|
||||
|
||||
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
||||
.fade-in{animation:fadeIn 0.3s ease-out forwards}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>EV Charging Simulator</h1>
|
||||
<small>OCPP TEST CONSOLE</small>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a class="header-link" href="/dashboard">← 대시보드로 돌아가기</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<!-- 좌측: 파라미터 -->
|
||||
<div class="params-panel">
|
||||
<div class="params-header">
|
||||
<div class="params-title">테스트 파라미터</div>
|
||||
</div>
|
||||
<div class="params-body">
|
||||
|
||||
<!-- 프리셋 -->
|
||||
<div class="param-section">
|
||||
<div class="param-section-title">프리셋</div>
|
||||
<div class="preset-row">
|
||||
<button class="preset-btn active" onclick="applyPreset('basic')">기본 (7kW)</button>
|
||||
<button class="preset-btn" onclick="applyPreset('fast')">급속 (50kW)</button>
|
||||
<button class="preset-btn" onclick="applyPreset('slow')">완속 (3kW)</button>
|
||||
<button class="preset-btn" onclick="applyPreset('short')">단시간</button>
|
||||
<button class="preset-btn" onclick="applyPreset('full')">완충</button>
|
||||
<button class="preset-btn" onclick="applyPreset('error')">에러 테스트</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 충전기 설정 -->
|
||||
<div class="param-section">
|
||||
<div class="param-section-title">충전기</div>
|
||||
<div class="param-row">
|
||||
<div class="param-group">
|
||||
<div class="param-label">충전기 ID</div>
|
||||
<input class="param-input" id="p-charger" value="CHARGER_001">
|
||||
</div>
|
||||
<div class="param-group">
|
||||
<div class="param-label">커넥터 번호</div>
|
||||
<input class="param-input" id="p-connector" type="number" value="1" min="1" max="4">
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-row">
|
||||
<div class="param-group">
|
||||
<div class="param-label">충전기 이름</div>
|
||||
<input class="param-input" id="p-name" value="A동 주차장 1번">
|
||||
</div>
|
||||
<div class="param-group">
|
||||
<div class="param-label">출력 (kW)</div>
|
||||
<input class="param-input" id="p-power" type="number" value="7" min="1" max="350" step="0.1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-row full">
|
||||
<div class="param-group">
|
||||
<div class="param-label">설치 위치</div>
|
||||
<input class="param-input" id="p-location" value="수원시 영통구 테스트 아파트 지하1층">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 충전 설정 -->
|
||||
<div class="param-section">
|
||||
<div class="param-section-title">충전 시뮬레이션</div>
|
||||
<div class="param-row">
|
||||
<div class="param-group">
|
||||
<div class="param-label">미터 시작값 (Wh)</div>
|
||||
<input class="param-input" id="p-meter-start" type="number" value="100000" min="0" step="1000">
|
||||
</div>
|
||||
<div class="param-group">
|
||||
<div class="param-label">Transaction ID</div>
|
||||
<input class="param-input" id="p-txn-id" type="number" value="" placeholder="자동 생성">
|
||||
<div class="param-hint">비워두면 타임스탬프 기반 자동 생성</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-group" style="margin-bottom:10px">
|
||||
<div class="param-label">목표 충전량 (kWh)</div>
|
||||
<div class="charge-slider-wrap">
|
||||
<input class="charge-slider" id="p-target-kwh" type="range" min="1" max="100" value="30" oninput="updateChargePreview()">
|
||||
<div class="charge-preview">
|
||||
<span>1 kWh</span>
|
||||
<span class="val" id="charge-val">30 kWh</span>
|
||||
<span>100 kWh</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-row">
|
||||
<div class="param-group">
|
||||
<div class="param-label">MeterValues 횟수</div>
|
||||
<input class="param-input" id="p-meter-steps" type="number" value="4" min="1" max="20">
|
||||
<div class="param-hint">충전 중 보고 횟수</div>
|
||||
</div>
|
||||
<div class="param-group">
|
||||
<div class="param-label">스텝 딜레이 (ms)</div>
|
||||
<input class="param-input" id="p-delay" type="number" value="500" min="0" max="5000" step="100">
|
||||
<div class="param-hint">각 단계 사이 대기</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 결제/종료 -->
|
||||
<div class="param-section">
|
||||
<div class="param-section-title">결제 / 종료</div>
|
||||
<div class="param-row">
|
||||
<div class="param-group">
|
||||
<div class="param-label">선결제 금액 (원)</div>
|
||||
<input class="param-input" id="p-amount" type="number" value="10000" min="100" step="1000">
|
||||
</div>
|
||||
<div class="param-group">
|
||||
<div class="param-label">종료 사유</div>
|
||||
<select class="param-input" id="p-stop-reason">
|
||||
<option value="Local">Local (사용자 종료)</option>
|
||||
<option value="Remote">Remote (서버 종료)</option>
|
||||
<option value="EVDisconnected">EVDisconnected</option>
|
||||
<option value="PowerLoss">PowerLoss (정전)</option>
|
||||
<option value="EmergencyStop">EmergencyStop</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 에러 시뮬레이션 -->
|
||||
<div class="param-section">
|
||||
<div class="param-section-title">에러 시뮬레이션</div>
|
||||
<div class="param-row full">
|
||||
<div class="param-group">
|
||||
<div class="param-label">충전기 에러 코드</div>
|
||||
<select class="param-input" id="p-error-code">
|
||||
<option value="NoError">NoError (정상)</option>
|
||||
<option value="ConnectorLockFailure">ConnectorLockFailure</option>
|
||||
<option value="GroundFailure">GroundFailure</option>
|
||||
<option value="HighTemperature">HighTemperature</option>
|
||||
<option value="OverCurrentFailure">OverCurrentFailure</option>
|
||||
<option value="OverVoltage">OverVoltage</option>
|
||||
<option value="UnderVoltage">UnderVoltage</option>
|
||||
<option value="PowerMeterFailure">PowerMeterFailure</option>
|
||||
<option value="PowerSwitchFailure">PowerSwitchFailure</option>
|
||||
<option value="InternalError">InternalError</option>
|
||||
<option value="OtherError">OtherError</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 실행 버튼 -->
|
||||
<button class="btn-run" id="btn-run" onclick="runFullTest()">전체 흐름 실행</button>
|
||||
<div class="btn-row">
|
||||
<button class="btn-step" onclick="runStepByStep()">단계별 실행</button>
|
||||
<button class="btn-step" onclick="resetAll()">초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 우측: 실행 결과 -->
|
||||
<div class="results-panel" id="results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API='/api/v1';
|
||||
const STEPS=[
|
||||
{id:'health',num:'0',title:'헬스체크',sub:'서버 연결 확인'},
|
||||
{id:'register',num:'1',title:'충전기 등록',sub:'chargeBoxId 등록'},
|
||||
{id:'reset',num:'1-1',title:'세션 정리',sub:'미완료 세션 취소'},
|
||||
{id:'status',num:'2',title:'충전기 상태',sub:'Available 설정'},
|
||||
{id:'session',num:'3',title:'세션 생성',sub:'QR 스캔 시뮬레이션'},
|
||||
{id:'payment',num:'4',title:'결제 준비',sub:'orderId 발급'},
|
||||
{id:'authorize',num:'5',title:'결제 우회',sub:'AUTHORIZED 강제 설정'},
|
||||
{id:'start',num:'6',title:'StartTransaction',sub:'충전 시작'},
|
||||
{id:'meter',num:'7',title:'MeterValues',sub:'실시간 전력량 보고'},
|
||||
{id:'poll',num:'8',title:'세션 조회',sub:'충전 중 상태 확인'},
|
||||
{id:'stop',num:'9',title:'StopTransaction',sub:'충전 종료 + 정산'},
|
||||
{id:'billing',num:'10',title:'최종 정산',sub:'요금 내역'},
|
||||
{id:'dashboard',num:'11',title:'대시보드',sub:'전체 요약'},
|
||||
];
|
||||
|
||||
let running=false, stepMode=false, stepResolve=null;
|
||||
let sessionUid='', idTag='', txnId=0;
|
||||
|
||||
function getParams(){
|
||||
const targetKwh=parseFloat(document.getElementById('p-target-kwh').value);
|
||||
const meterStart=parseInt(document.getElementById('p-meter-start').value);
|
||||
const meterSteps=parseInt(document.getElementById('p-meter-steps').value);
|
||||
const stepWh=Math.round(targetKwh*1000/meterSteps);
|
||||
const txnInput=document.getElementById('p-txn-id').value;
|
||||
return {
|
||||
charger:document.getElementById('p-charger').value,
|
||||
connector:parseInt(document.getElementById('p-connector').value),
|
||||
name:document.getElementById('p-name').value,
|
||||
location:document.getElementById('p-location').value,
|
||||
power:parseFloat(document.getElementById('p-power').value),
|
||||
meterStart,
|
||||
targetKwh,
|
||||
meterSteps,
|
||||
stepWh,
|
||||
meterStop:meterStart+targetKwh*1000,
|
||||
txnId:txnInput?parseInt(txnInput):Math.floor(Date.now()/1000)%100000,
|
||||
amount:parseInt(document.getElementById('p-amount').value),
|
||||
stopReason:document.getElementById('p-stop-reason').value,
|
||||
errorCode:document.getElementById('p-error-code').value,
|
||||
delay:parseInt(document.getElementById('p-delay').value),
|
||||
}
|
||||
}
|
||||
|
||||
function updateChargePreview(){
|
||||
const v=document.getElementById('p-target-kwh').value;
|
||||
document.getElementById('charge-val').textContent=v+' kWh';
|
||||
}
|
||||
|
||||
// ── 프리셋 ──
|
||||
const PRESETS={
|
||||
basic:{power:7,targetKwh:30,meterSteps:4,amount:10000,delay:500,errorCode:'NoError',stopReason:'Local'},
|
||||
fast:{power:50,targetKwh:60,meterSteps:6,amount:30000,delay:300,errorCode:'NoError',stopReason:'Local'},
|
||||
slow:{power:3,targetKwh:10,meterSteps:3,amount:5000,delay:800,errorCode:'NoError',stopReason:'Local'},
|
||||
short:{power:7,targetKwh:5,meterSteps:2,amount:2000,delay:300,errorCode:'NoError',stopReason:'Local'},
|
||||
full:{power:11,targetKwh:80,meterSteps:8,amount:50000,delay:400,errorCode:'NoError',stopReason:'Local'},
|
||||
error:{power:7,targetKwh:15,meterSteps:3,amount:10000,delay:500,errorCode:'OverCurrentFailure',stopReason:'EmergencyStop'},
|
||||
};
|
||||
function applyPreset(name){
|
||||
const p=PRESETS[name];
|
||||
document.getElementById('p-power').value=p.power;
|
||||
document.getElementById('p-target-kwh').value=p.targetKwh;
|
||||
document.getElementById('p-meter-steps').value=p.meterSteps;
|
||||
document.getElementById('p-amount').value=p.amount;
|
||||
document.getElementById('p-delay').value=p.delay;
|
||||
document.getElementById('p-error-code').value=p.errorCode;
|
||||
document.getElementById('p-stop-reason').value=p.stopReason;
|
||||
updateChargePreview();
|
||||
document.querySelectorAll('.preset-btn').forEach(b=>b.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
|
||||
// ── API 호출 ──
|
||||
async function api(path,opt={}){
|
||||
const r=await fetch(API+path,{headers:{'Content-Type':'application/json'},...opt});
|
||||
const data=await r.json().catch(()=>({error:'응답 파싱 실패',status:r.status}));
|
||||
return {ok:r.ok,status:r.status,data};
|
||||
}
|
||||
|
||||
// ── UI ──
|
||||
function renderSteps(){
|
||||
document.getElementById('results').innerHTML=STEPS.map(s=>`
|
||||
<div class="step-card waiting" id="step-${s.id}" onclick="toggleStep('${s.id}')">
|
||||
<div class="step-header">
|
||||
<div class="step-num">${s.num}</div>
|
||||
<div style="flex:1"><div class="step-title">${s.title}</div><div class="step-subtitle">${s.sub}</div></div>
|
||||
<span class="step-status wait" id="status-${s.id}">대기</span>
|
||||
<span class="step-time" id="time-${s.id}"></span>
|
||||
</div>
|
||||
<div class="step-body"><div class="step-json" id="json-${s.id}"></div></div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function setStepState(id,state,data,ms){
|
||||
const card=document.getElementById('step-'+id);
|
||||
const status=document.getElementById('status-'+id);
|
||||
const time=document.getElementById('time-'+id);
|
||||
const json=document.getElementById('json-'+id);
|
||||
card.className='step-card '+state+(state!=='waiting'?' open':'');
|
||||
const labels={success:'성공',error:'실패',running:'실행중',waiting:'대기'};
|
||||
const classes={success:'ok',error:'fail',running:'run',waiting:'wait'};
|
||||
status.className='step-status '+classes[state];
|
||||
status.textContent=labels[state];
|
||||
if(ms!==undefined)time.textContent=ms+'ms';
|
||||
if(data!==undefined)json.innerHTML=syntaxHL(JSON.stringify(data,null,2));
|
||||
card.scrollIntoView({behavior:'smooth',block:'nearest'});
|
||||
}
|
||||
|
||||
function syntaxHL(s){
|
||||
return s.replace(/(".*?")\s*:/g,'<span class="k">$1</span>:')
|
||||
.replace(/:\s*(".*?")/g,': <span class="s">$1</span>')
|
||||
.replace(/:\s*(\d+\.?\d*)/g,': <span class="n">$1</span>')
|
||||
.replace(/:\s*(true|false|null)/g,': <span class="b">$1</span>');
|
||||
}
|
||||
|
||||
function toggleStep(id){
|
||||
document.getElementById('step-'+id).classList.toggle('open');
|
||||
}
|
||||
|
||||
function addSummary(billing){
|
||||
const el=document.createElement('div');
|
||||
el.className='summary-card fade-in';
|
||||
el.innerHTML=`<h3>충전 완료</h3>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-item"><div class="summary-val">${billing.charged_kwh}</div><div class="summary-label">kWh 충전</div></div>
|
||||
<div class="summary-item"><div class="summary-val">${billing.total_bill.toLocaleString()}</div><div class="summary-label">원 요금</div></div>
|
||||
<div class="summary-item"><div class="summary-val">${billing.saved_vs_cpo.toLocaleString()}</div><div class="summary-label">원 절감</div></div>
|
||||
</div>
|
||||
<div class="summary-saved">CPO 대비 ${billing.saved_vs_cpo.toLocaleString()}원 절감 (전기 ${billing.electricity_cost.toLocaleString()}원 + 서비스 ${billing.service_fee.toLocaleString()}원)</div>`;
|
||||
document.getElementById('results').appendChild(el);
|
||||
}
|
||||
|
||||
// ── 딜레이 + 스텝 ──
|
||||
function wait(ms){return new Promise(r=>setTimeout(r,ms))}
|
||||
function waitStep(){return stepMode?new Promise(r=>{stepResolve=r}):Promise.resolve()}
|
||||
|
||||
// ── 실행 ──
|
||||
async function runFullTest(){
|
||||
if(running)return;
|
||||
stepMode=false;
|
||||
await execute();
|
||||
}
|
||||
|
||||
async function runStepByStep(){
|
||||
if(running)return;
|
||||
stepMode=true;
|
||||
const btn=document.querySelector('.btn-step');
|
||||
btn.textContent='다음 단계 ▶';
|
||||
btn.onclick=()=>{if(stepResolve){stepResolve();stepResolve=null}};
|
||||
await execute();
|
||||
btn.textContent='단계별 실행';
|
||||
btn.onclick=()=>runStepByStep();
|
||||
}
|
||||
|
||||
async function execute(){
|
||||
running=true;
|
||||
const btn=document.getElementById('btn-run');
|
||||
btn.disabled=true;btn.classList.add('running');btn.textContent='실행 중...';
|
||||
|
||||
const P=getParams();
|
||||
renderSteps();
|
||||
|
||||
async function step(id,fn){
|
||||
await waitStep();
|
||||
setStepState(id,'running');
|
||||
const t0=performance.now();
|
||||
try{
|
||||
const result=await fn();
|
||||
const ms=Math.round(performance.now()-t0);
|
||||
setStepState(id,'success',result,ms);
|
||||
await wait(P.delay);
|
||||
return result;
|
||||
}catch(e){
|
||||
const ms=Math.round(performance.now()-t0);
|
||||
setStepState(id,'error',{error:e.message||e,detail:e.data||null},ms);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
try{
|
||||
// 0. 헬스체크
|
||||
await step('health',async()=>{
|
||||
const r=await api('/../health');
|
||||
if(!r.ok)throw{message:'서버 연결 실패',data:r.data};
|
||||
return r.data;
|
||||
});
|
||||
|
||||
// 1. 충전기 등록
|
||||
await step('register',async()=>{
|
||||
const r=await api('/chargers/',{method:'POST',body:JSON.stringify({
|
||||
charge_box_id:P.charger,name:P.name,location:P.location,
|
||||
connector_count:1,power_kw:P.power,
|
||||
})});
|
||||
if(r.status===409)return{message:'이미 등록됨',charge_box_id:P.charger};
|
||||
if(!r.ok)throw{message:'등록 실패',data:r.data};
|
||||
return r.data;
|
||||
});
|
||||
|
||||
// 1-1. 세션 정리
|
||||
await step('reset',async()=>{
|
||||
const r=await api('/sessions/reset/'+P.charger,{method:'POST'});
|
||||
return r.data;
|
||||
});
|
||||
|
||||
// 2. 상태 업데이트
|
||||
const statusVal=P.errorCode==='NoError'?'Available':'Faulted';
|
||||
await step('status',async()=>{
|
||||
const r=await api('/ocpp/status',{method:'POST',body:JSON.stringify({
|
||||
charge_box_id:P.charger,connector_id:P.connector,
|
||||
status:statusVal,error_code:P.errorCode,
|
||||
})});
|
||||
if(!r.ok)throw{message:'상태 업데이트 실패',data:r.data};
|
||||
return r.data;
|
||||
});
|
||||
|
||||
// 에러 시뮬레이션 시 여기서 중단
|
||||
if(P.errorCode!=='NoError'){
|
||||
addSummary({charged_kwh:0,total_bill:0,saved_vs_cpo:0,electricity_cost:0,service_fee:0});
|
||||
const remaining=['session','payment','authorize','start','meter','poll','stop','billing','dashboard'];
|
||||
remaining.forEach(id=>setStepState(id,'error',{message:'에러 상태에서 충전 불가',errorCode:P.errorCode}));
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 세션 생성
|
||||
const session=await step('session',async()=>{
|
||||
const r=await api('/sessions/',{method:'POST',body:JSON.stringify({
|
||||
charge_box_id:P.charger,connector_id:P.connector,
|
||||
})});
|
||||
if(!r.ok)throw{message:'세션 생성 실패',data:r.data};
|
||||
sessionUid=r.data.session_uid;
|
||||
idTag=r.data.id_tag;
|
||||
return r.data;
|
||||
});
|
||||
|
||||
// 4. 결제 준비
|
||||
await step('payment',async()=>{
|
||||
const r=await api('/payments/prepare',{method:'POST',body:JSON.stringify({
|
||||
session_uid:sessionUid,amount:P.amount,
|
||||
})});
|
||||
if(!r.ok)throw{message:'결제 준비 실패',data:r.data};
|
||||
return r.data;
|
||||
});
|
||||
|
||||
// 5. 결제 우회
|
||||
await step('authorize',async()=>{
|
||||
const r=await api('/sessions/'+sessionUid+'/force-authorize',{method:'POST'});
|
||||
if(!r.ok)throw{message:'인증 실패',data:r.data};
|
||||
return r.data;
|
||||
});
|
||||
|
||||
// 6. StartTransaction
|
||||
txnId=P.txnId;
|
||||
await step('start',async()=>{
|
||||
const r=await api('/ocpp/start-transaction',{method:'POST',body:JSON.stringify({
|
||||
charge_box_id:P.charger,connector_id:P.connector,
|
||||
id_tag:idTag,meter_start:P.meterStart,transaction_id:txnId,
|
||||
})});
|
||||
if(!r.ok)throw{message:'StartTransaction 실패',data:r.data};
|
||||
return r.data;
|
||||
});
|
||||
|
||||
// 7. MeterValues
|
||||
await step('meter',async()=>{
|
||||
const results=[];
|
||||
for(let i=1;i<=P.meterSteps;i++){
|
||||
const wh=P.meterStart+Math.round(P.stepWh*i);
|
||||
const r=await api('/ocpp/meter-values',{method:'POST',body:JSON.stringify({
|
||||
charge_box_id:P.charger,connector_id:P.connector,
|
||||
transaction_id:txnId,value:wh,
|
||||
})});
|
||||
results.push({step:i,wh,kwh:((wh-P.meterStart)/1000).toFixed(1),status:r.data?.status});
|
||||
await wait(Math.max(100,P.delay/2));
|
||||
}
|
||||
return {meter_reports:results,total_reports:P.meterSteps};
|
||||
});
|
||||
|
||||
// 8. 세션 조회
|
||||
await step('poll',async()=>{
|
||||
const r=await api('/sessions/'+sessionUid);
|
||||
return r.data;
|
||||
});
|
||||
|
||||
// 9. StopTransaction
|
||||
const stopResult=await step('stop',async()=>{
|
||||
const r=await api('/ocpp/stop-transaction',{method:'POST',body:JSON.stringify({
|
||||
charge_box_id:P.charger,transaction_id:txnId,
|
||||
meter_stop:P.meterStop,reason:P.stopReason,
|
||||
})});
|
||||
if(!r.ok)throw{message:'StopTransaction 실패',data:r.data};
|
||||
return r.data;
|
||||
});
|
||||
|
||||
// 10. 정산
|
||||
const billing=await step('billing',async()=>{
|
||||
const r=await api('/sessions/'+sessionUid+'/billing');
|
||||
if(!r.ok)throw{message:'정산 조회 실패',data:r.data};
|
||||
return r.data;
|
||||
});
|
||||
|
||||
// 11. 대시보드
|
||||
await step('dashboard',async()=>{
|
||||
const r=await api('/dashboard/summary');
|
||||
return r.data;
|
||||
});
|
||||
|
||||
addSummary(billing);
|
||||
|
||||
}catch(e){
|
||||
console.error('테스트 중단:',e);
|
||||
}finally{
|
||||
running=false;
|
||||
btn.disabled=false;btn.classList.remove('running');btn.textContent='전체 흐름 실행';
|
||||
}
|
||||
}
|
||||
|
||||
function resetAll(){
|
||||
running=false;stepMode=false;stepResolve=null;
|
||||
sessionUid='';idTag='';txnId=0;
|
||||
document.getElementById('results').innerHTML=`
|
||||
<div style="text-align:center;padding:60px 20px;color:var(--text-3)">
|
||||
<div style="font-size:40px;margin-bottom:16px;opacity:0.2">⚡</div>
|
||||
<div style="font-size:14px">파라미터를 설정하고 실행 버튼을 누르세요</div>
|
||||
<div style="font-family:var(--font-mono);font-size:11px;margin-top:8px">전체 흐름 또는 단계별 실행 가능</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded',()=>{resetAll();updateChargePreview()});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user