From de5a40678ef849b539fa0942b6e5f4752e2ba5bd Mon Sep 17 00:00:00 2001 From: jklee Date: Sun, 14 Jun 2026 19:19:55 +0900 Subject: [PATCH] =?UTF-8?q?feat:=201=EB=8B=A8=EA=B3=84=20=E2=80=94=20JSON?= =?UTF-8?q?=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=EB=A1=9C=EB=8D=94=20?= =?UTF-8?q?+=20=EC=8A=A4=EB=AA=A8=ED=81=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScenarioLoader: JSON 시나리오/창고 레이아웃 → 런타임 ScriptableObject 변환 - warehouse_basic.json: 로케이션 마스터 (해피패스 + 존위반/용량초과 함정) - InboundManager.StartMission(scenario) 오버로드 (로더 결과 주입용) - Editor 스모크 테스트: 로더 + 입고 로직 12항목 검증 (12 PASS) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Resources/Scenarios/warehouse_basic.json | 13 ++ .../Scenarios/warehouse_basic.json.meta | 7 + Assets/Scripts/Core/ScenarioLoader.cs | 152 ++++++++++++++++++ Assets/Scripts/Core/ScenarioLoader.cs.meta | 2 + Assets/Scripts/Editor.meta | 8 + Assets/Scripts/Editor/InboundSmokeTest.cs | 132 +++++++++++++++ .../Scripts/Editor/InboundSmokeTest.cs.meta | 2 + Assets/Scripts/Inbound/InboundManager.cs | 7 + CHANGELOG.md | 7 +- 9 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 Assets/Resources/Scenarios/warehouse_basic.json create mode 100644 Assets/Resources/Scenarios/warehouse_basic.json.meta create mode 100644 Assets/Scripts/Core/ScenarioLoader.cs create mode 100644 Assets/Scripts/Core/ScenarioLoader.cs.meta create mode 100644 Assets/Scripts/Editor.meta create mode 100644 Assets/Scripts/Editor/InboundSmokeTest.cs create mode 100644 Assets/Scripts/Editor/InboundSmokeTest.cs.meta diff --git a/Assets/Resources/Scenarios/warehouse_basic.json b/Assets/Resources/Scenarios/warehouse_basic.json new file mode 100644 index 0000000..d892ccc --- /dev/null +++ b/Assets/Resources/Scenarios/warehouse_basic.json @@ -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 } + ] +} diff --git a/Assets/Resources/Scenarios/warehouse_basic.json.meta b/Assets/Resources/Scenarios/warehouse_basic.json.meta new file mode 100644 index 0000000..1cca0a1 --- /dev/null +++ b/Assets/Resources/Scenarios/warehouse_basic.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 0098b9785d1154887bbf01a69d6c560d +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Core/ScenarioLoader.cs b/Assets/Scripts/Core/ScenarioLoader.cs new file mode 100644 index 0000000..e4752be --- /dev/null +++ b/Assets/Scripts/Core/ScenarioLoader.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using SinoWms.Data; + +namespace SinoWms.Core +{ + /// + /// JSON 시나리오/창고 레이아웃을 읽어 런타임 ScriptableObject 인스턴스로 변환한다. + /// 교육 콘텐츠를 코드와 분리하기 위한 진입점. UI/매니저는 이 결과를 받아 미션을 시작한다. + /// + public static class ScenarioLoader + { + /// 로더 결과 묶음: 미션 시나리오 + 해당 창고의 로케이션 마스터. + public class LoadedScenario + { + public InboundScenario scenario; + public List locations = new List(); + } + + /// + /// Resources 폴더에서 시나리오/창고 JSON(TextAsset)을 읽어 로드한다. + /// 경로는 확장자 없이 Resources 기준 상대경로 (예: "Scenarios/inbound_basic"). + /// + public static LoadedScenario LoadFromResources(string scenarioPath, string warehousePath) + { + var scenarioAsset = Resources.Load(scenarioPath); + if (scenarioAsset == null) + throw new InvalidOperationException($"시나리오 JSON을 찾을 수 없습니다: Resources/{scenarioPath}"); + + var warehouseAsset = Resources.Load(warehousePath); + if (warehouseAsset == null) + throw new InvalidOperationException($"창고 JSON을 찾을 수 없습니다: Resources/{warehousePath}"); + + return Load(scenarioAsset.text, warehouseAsset.text); + } + + /// JSON 문자열로부터 직접 로드한다(테스트/외부 소스용). + public static LoadedScenario Load(string scenarioJson, string warehouseJson) + { + var warehouseDto = JsonUtility.FromJson(warehouseJson); + if (warehouseDto?.locations == null) + throw new InvalidOperationException("창고 JSON 파싱 실패: locations가 없습니다."); + + var result = new LoadedScenario(); + var byCode = new Dictionary(); + foreach (var dto in warehouseDto.locations) + { + var loc = ScriptableObject.CreateInstance(); + 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(scenarioJson); + if (scenarioDto?.lines == null) + throw new InvalidOperationException("시나리오 JSON 파싱 실패: lines가 없습니다."); + + var scenario = ScriptableObject.CreateInstance(); + 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(); + 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; + } + } +} diff --git a/Assets/Scripts/Core/ScenarioLoader.cs.meta b/Assets/Scripts/Core/ScenarioLoader.cs.meta new file mode 100644 index 0000000..1e05db8 --- /dev/null +++ b/Assets/Scripts/Core/ScenarioLoader.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fe252208b8d334ce181dd57a84601eb1 \ No newline at end of file diff --git a/Assets/Scripts/Editor.meta b/Assets/Scripts/Editor.meta new file mode 100644 index 0000000..2bfdc70 --- /dev/null +++ b/Assets/Scripts/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d60fd71c0f2ac4481aa3dbbab31828e9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Editor/InboundSmokeTest.cs b/Assets/Scripts/Editor/InboundSmokeTest.cs new file mode 100644 index 0000000..b0a0315 --- /dev/null +++ b/Assets/Scripts/Editor/InboundSmokeTest.cs @@ -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 +{ + /// + /// UI 없이 입고 로직(로더 + 서비스)을 검증하는 스모크 테스트. + /// 메뉴 SinoWMS/Run Inbound Smoke Test로 실행하면 콘솔에 PASS/FAIL 리포트가 찍힌다. + /// 정식 EditMode 테스트(asmdef 분리)로 대체되기 전까지의 임시 검증 수단. + /// + 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 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}"); } + } + } +} diff --git a/Assets/Scripts/Editor/InboundSmokeTest.cs.meta b/Assets/Scripts/Editor/InboundSmokeTest.cs.meta new file mode 100644 index 0000000..829604b --- /dev/null +++ b/Assets/Scripts/Editor/InboundSmokeTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 75a0eb77b8a87495cb9a80a685e0e1a8 \ No newline at end of file diff --git a/Assets/Scripts/Inbound/InboundManager.cs b/Assets/Scripts/Inbound/InboundManager.cs index 4debc79..602edca 100644 --- a/Assets/Scripts/Inbound/InboundManager.cs +++ b/Assets/Scripts/Inbound/InboundManager.cs @@ -34,6 +34,13 @@ namespace SinoWms.Inbound private set { _step = value; StepChanged?.Invoke(value); } } + /// 로더 등 외부에서 만든 시나리오로 미션 시작(인스펙터 할당 대신). + public void StartMission(InboundScenario scenarioToRun) + { + scenario = scenarioToRun; + StartMission(); + } + public void StartMission() { if (scenario == null) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc59e83..73848d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) - 채점 결과 화면