feat: 1단계 — JSON 시나리오 로더 + 스모크 테스트

- ScenarioLoader: JSON 시나리오/창고 레이아웃 → 런타임 ScriptableObject 변환
- warehouse_basic.json: 로케이션 마스터 (해피패스 + 존위반/용량초과 함정)
- InboundManager.StartMission(scenario) 오버로드 (로더 결과 주입용)
- Editor 스모크 테스트: 로더 + 입고 로직 12항목 검증 (12 PASS)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jklee
2026-06-14 19:19:55 +09:00
parent 1051958641
commit de5a40678e
9 changed files with 329 additions and 1 deletions
@@ -0,0 +1,13 @@
{
"warehouseId": "WH-BASIC",
"title": "기본 교육 창고 레이아웃",
"locations": [
{ "locationCode": "A-01-01", "zone": "Ambient", "capacity": 50, "gridX": 0, "gridY": 0 },
{ "locationCode": "A-02-01", "zone": "Ambient", "capacity": 50, "gridX": 1, "gridY": 0 },
{ "locationCode": "A-03-02", "zone": "Ambient", "capacity": 50, "gridX": 2, "gridY": 0 },
{ "locationCode": "A-99-01", "zone": "Ambient", "capacity": 5, "gridX": 3, "gridY": 0 },
{ "locationCode": "C-01-01", "zone": "Chilled", "capacity": 40, "gridX": 0, "gridY": 2 },
{ "locationCode": "C-02-03", "zone": "Chilled", "capacity": 40, "gridX": 1, "gridY": 2 },
{ "locationCode": "F-01-01", "zone": "Frozen", "capacity": 30, "gridX": 0, "gridY": 4 }
]
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 0098b9785d1154887bbf01a69d6c560d
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+152
View File
@@ -0,0 +1,152 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using SinoWms.Data;
namespace SinoWms.Core
{
/// <summary>
/// JSON 시나리오/창고 레이아웃을 읽어 런타임 ScriptableObject 인스턴스로 변환한다.
/// 교육 콘텐츠를 코드와 분리하기 위한 진입점. UI/매니저는 이 결과를 받아 미션을 시작한다.
/// </summary>
public static class ScenarioLoader
{
/// <summary>로더 결과 묶음: 미션 시나리오 + 해당 창고의 로케이션 마스터.</summary>
public class LoadedScenario
{
public InboundScenario scenario;
public List<LocationDefinition> locations = new List<LocationDefinition>();
}
/// <summary>
/// Resources 폴더에서 시나리오/창고 JSON(TextAsset)을 읽어 로드한다.
/// 경로는 확장자 없이 Resources 기준 상대경로 (예: "Scenarios/inbound_basic").
/// </summary>
public static LoadedScenario LoadFromResources(string scenarioPath, string warehousePath)
{
var scenarioAsset = Resources.Load<TextAsset>(scenarioPath);
if (scenarioAsset == null)
throw new InvalidOperationException($"시나리오 JSON을 찾을 수 없습니다: Resources/{scenarioPath}");
var warehouseAsset = Resources.Load<TextAsset>(warehousePath);
if (warehouseAsset == null)
throw new InvalidOperationException($"창고 JSON을 찾을 수 없습니다: Resources/{warehousePath}");
return Load(scenarioAsset.text, warehouseAsset.text);
}
/// <summary>JSON 문자열로부터 직접 로드한다(테스트/외부 소스용).</summary>
public static LoadedScenario Load(string scenarioJson, string warehouseJson)
{
var warehouseDto = JsonUtility.FromJson<WarehouseDto>(warehouseJson);
if (warehouseDto?.locations == null)
throw new InvalidOperationException("창고 JSON 파싱 실패: locations가 없습니다.");
var result = new LoadedScenario();
var byCode = new Dictionary<string, LocationDefinition>();
foreach (var dto in warehouseDto.locations)
{
var loc = ScriptableObject.CreateInstance<LocationDefinition>();
loc.name = dto.locationCode;
loc.locationCode = dto.locationCode;
loc.zone = ParseZone(dto.zone, dto.locationCode);
loc.capacity = dto.capacity;
loc.gridPosition = new Vector2Int(dto.gridX, dto.gridY);
result.locations.Add(loc);
byCode[loc.locationCode] = loc;
}
var scenarioDto = JsonUtility.FromJson<ScenarioDto>(scenarioJson);
if (scenarioDto?.lines == null)
throw new InvalidOperationException("시나리오 JSON 파싱 실패: lines가 없습니다.");
var scenario = ScriptableObject.CreateInstance<InboundScenario>();
scenario.name = scenarioDto.scenarioId;
scenario.scenarioId = scenarioDto.scenarioId;
scenario.title = scenarioDto.title;
scenario.timeLimitSeconds = scenarioDto.timeLimitSeconds;
foreach (var lineDto in scenarioDto.lines)
{
var item = ScriptableObject.CreateInstance<ItemDefinition>();
item.name = lineDto.sku;
item.sku = lineDto.sku;
item.displayName = lineDto.displayName;
item.barcode = lineDto.barcode;
item.requiredZone = ParseZone(lineDto.requiredZone, lineDto.sku);
item.unitVolume = lineDto.unitVolume;
LocationDefinition suggested = null;
if (!string.IsNullOrEmpty(lineDto.suggestedLocation))
{
if (!byCode.TryGetValue(lineDto.suggestedLocation, out suggested))
Debug.LogWarning($"[ScenarioLoader] {lineDto.sku}의 정답 로케이션 " +
$"'{lineDto.suggestedLocation}'이(가) 창고에 없습니다.");
}
scenario.lines.Add(new InboundLine
{
item = item,
expectedQty = lineDto.expectedQty,
actualQty = lineDto.actualQty,
suggestedLocation = suggested
});
}
result.scenario = scenario;
return result;
}
private static StorageZone ParseZone(string raw, string ownerForLog)
{
if (Enum.TryParse(raw, ignoreCase: true, out StorageZone zone))
return zone;
Debug.LogWarning($"[ScenarioLoader] 알 수 없는 존 '{raw}' ({ownerForLog}) → Ambient로 대체.");
return StorageZone.Ambient;
}
// ---- JSON 매핑용 DTO (JsonUtility는 public 필드 + [Serializable] 필요) ----
[Serializable]
private class ScenarioDto
{
public string scenarioId;
public string title;
public float timeLimitSeconds;
public LineDto[] lines;
}
[Serializable]
private class LineDto
{
public string sku;
public string displayName;
public string barcode;
public string requiredZone;
public int unitVolume;
public int expectedQty;
public int actualQty;
public string suggestedLocation;
}
[Serializable]
private class WarehouseDto
{
public string warehouseId;
public string title;
public LocationDto[] locations;
}
[Serializable]
private class LocationDto
{
public string locationCode;
public string zone;
public int capacity;
public int gridX;
public int gridY;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fe252208b8d334ce181dd57a84601eb1
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d60fd71c0f2ac4481aa3dbbab31828e9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+132
View File
@@ -0,0 +1,132 @@
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
using SinoWms.Core;
using SinoWms.Data;
using SinoWms.Inbound;
namespace SinoWms.EditorTools
{
/// <summary>
/// UI 없이 입고 로직(로더 + 서비스)을 검증하는 스모크 테스트.
/// 메뉴 <b>SinoWMS/Run Inbound Smoke Test</b>로 실행하면 콘솔에 PASS/FAIL 리포트가 찍힌다.
/// 정식 EditMode 테스트(asmdef 분리)로 대체되기 전까지의 임시 검증 수단.
/// </summary>
public static class InboundSmokeTest
{
private static int _pass;
private static int _fail;
[MenuItem("SinoWMS/Run Inbound Smoke Test")]
public static void Run()
{
_pass = 0;
_fail = 0;
Debug.Log("===== 입고 스모크 테스트 시작 =====");
var loaded = ScenarioLoader.LoadFromResources(
"Scenarios/inbound_basic", "Scenarios/warehouse_basic");
TestLoader(loaded);
TestHappyPath(loaded);
TestUnknownBarcode(loaded);
TestPutawayBeforeInspect(loaded);
TestZoneMismatch(loaded);
TestOverCapacity(loaded);
var summary = $"===== 결과: {_pass} PASS / {_fail} FAIL =====";
if (_fail == 0) Debug.Log(summary);
else Debug.LogError(summary);
}
// ---- 개별 테스트 ----
private static void TestLoader(ScenarioLoader.LoadedScenario l)
{
Check("로더: 시나리오 ID", l.scenario.scenarioId == "INB-001");
Check("로더: 라인 3건", l.scenario.lines.Count == 3);
Check("로더: 로케이션 7건", l.locations.Count == 7);
Check("로더: 우유 존=Chilled",
Line(l, "SKU-2002").item.requiredZone == StorageZone.Chilled);
Check("로더: 정답 로케이션 해석(생수→A-01-01)",
Line(l, "SKU-1001").suggestedLocation?.locationCode == "A-01-01");
Check("로더: 수량 차이 함정(우유 예정8/실제6)",
Line(l, "SKU-2002").actualQty == 6 && Line(l, "SKU-2002").expectedQty == 8);
}
private static void TestHappyPath(ScenarioLoader.LoadedScenario l)
{
var progress = NewProgress(l);
var scan = new ScanService(progress);
var putaway = new PutawayService();
var lp = progress.First(p => p.line.item.sku == "SKU-1001");
var r1 = scan.Scan(lp.line.item.barcode, out _);
Check("해피: 정상 스캔", r1 == ScanResult.Ok && lp.scanned);
lp.countedQty = lp.line.actualQty;
lp.inspected = true;
var r2 = putaway.Commit(lp, Loc(l, "A-01-01"));
Check("해피: 정상 적치", r2 == PutawayResult.Ok && lp.putaway);
}
private static void TestUnknownBarcode(ScenarioLoader.LoadedScenario l)
{
var scan = new ScanService(NewProgress(l));
var r = scan.Scan("0000000000000", out var matched);
Check("함정: 미등록 바코드 → UnknownBarcode",
r == ScanResult.UnknownBarcode && matched == null);
}
private static void TestPutawayBeforeInspect(ScenarioLoader.LoadedScenario l)
{
var progress = NewProgress(l);
var putaway = new PutawayService();
var lp = progress.First(p => p.line.item.sku == "SKU-1001");
lp.scanned = true; // 검수는 생략
var r = putaway.Validate(lp, Loc(l, "A-01-01"));
Check("함정: 검수 전 적치 → NotInspected", r == PutawayResult.NotInspected);
}
private static void TestZoneMismatch(ScenarioLoader.LoadedScenario l)
{
var progress = NewProgress(l);
var putaway = new PutawayService();
var lp = progress.First(p => p.line.item.sku == "SKU-1001"); // Ambient 품목
lp.countedQty = lp.line.actualQty;
lp.inspected = true;
var r = putaway.Validate(lp, Loc(l, "C-02-03")); // Chilled 존
Check("함정: 존 위반 → ZoneMismatch", r == PutawayResult.ZoneMismatch);
}
private static void TestOverCapacity(ScenarioLoader.LoadedScenario l)
{
var progress = NewProgress(l);
var putaway = new PutawayService();
var lp = progress.First(p => p.line.item.sku == "SKU-1001"); // 10개 × 부피2 = 20
lp.countedQty = lp.line.actualQty;
lp.inspected = true;
var r = putaway.Validate(lp, Loc(l, "A-99-01")); // 용량 5
Check("함정: 용량 초과 → OverCapacity", r == PutawayResult.OverCapacity);
}
// ---- 헬퍼 ----
private static List<LineProgress> NewProgress(ScenarioLoader.LoadedScenario l)
=> l.scenario.lines.Select(line => new LineProgress(line)).ToList();
private static InboundLine Line(ScenarioLoader.LoadedScenario l, string sku)
=> l.scenario.lines.First(line => line.item.sku == sku);
private static LocationDefinition Loc(ScenarioLoader.LoadedScenario l, string code)
=> l.locations.First(loc => loc.locationCode == code);
private static void Check(string label, bool ok)
{
if (ok) { _pass++; Debug.Log($" ✅ {label}"); }
else { _fail++; Debug.LogError($" ❌ {label}"); }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 75a0eb77b8a87495cb9a80a685e0e1a8
+7
View File
@@ -34,6 +34,13 @@ namespace SinoWms.Inbound
private set { _step = value; StepChanged?.Invoke(value); }
}
/// <summary>로더 등 외부에서 만든 시나리오로 미션 시작(인스펙터 할당 대신).</summary>
public void StartMission(InboundScenario scenarioToRun)
{
scenario = scenarioToRun;
StartMission();
}
public void StartMission()
{
if (scenario == null)
+6 -1
View File
@@ -6,8 +6,13 @@
버전 표기는 [유의적 버전(SemVer)](https://semver.org/lang/ko/)을 따릅니다.
## [Unreleased]
### 추가됨 (Added)
- `ScenarioLoader` — JSON 시나리오/창고 레이아웃을 런타임 ScriptableObject로 변환
- 창고 레이아웃 데이터 `Assets/Resources/Scenarios/warehouse_basic.json` (로케이션 마스터)
- `InboundManager.StartMission(InboundScenario)` 오버로드 (로더 결과 주입용)
- 입고 로직 스모크 테스트 (`SinoWMS/Run Inbound Smoke Test` 메뉴 — 해피패스 + 함정 5종 검증)
### 예정
- JSON → ScriptableObject 로더 연결
- 탑다운 카메라 + 창고 그리드 씬
- PDA UI 프리팹 (UI Toolkit 또는 uGUI)
- 채점 결과 화면