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:
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d60fd71c0f2ac4481aa3dbbab31828e9
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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
|
||||||
@@ -34,6 +34,13 @@ namespace SinoWms.Inbound
|
|||||||
private set { _step = value; StepChanged?.Invoke(value); }
|
private set { _step = value; StepChanged?.Invoke(value); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>로더 등 외부에서 만든 시나리오로 미션 시작(인스펙터 할당 대신).</summary>
|
||||||
|
public void StartMission(InboundScenario scenarioToRun)
|
||||||
|
{
|
||||||
|
scenario = scenarioToRun;
|
||||||
|
StartMission();
|
||||||
|
}
|
||||||
|
|
||||||
public void StartMission()
|
public void StartMission()
|
||||||
{
|
{
|
||||||
if (scenario == null)
|
if (scenario == null)
|
||||||
|
|||||||
+6
-1
@@ -6,8 +6,13 @@
|
|||||||
버전 표기는 [유의적 버전(SemVer)](https://semver.org/lang/ko/)을 따릅니다.
|
버전 표기는 [유의적 버전(SemVer)](https://semver.org/lang/ko/)을 따릅니다.
|
||||||
|
|
||||||
## [Unreleased]
|
## [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)
|
- PDA UI 프리팹 (UI Toolkit 또는 uGUI)
|
||||||
- 채점 결과 화면
|
- 채점 결과 화면
|
||||||
|
|||||||
Reference in New Issue
Block a user