feat: establish legacy parity migration baseline
This commit is contained in:
@@ -28,6 +28,9 @@
|
|||||||
<ApplicationDisplayVersion>1.0.5</ApplicationDisplayVersion>
|
<ApplicationDisplayVersion>1.0.5</ApplicationDisplayVersion>
|
||||||
<ApplicationVersion>6</ApplicationVersion>
|
<ApplicationVersion>6</ApplicationVersion>
|
||||||
<Version>1.0.5</Version>
|
<Version>1.0.5</Version>
|
||||||
|
|
||||||
|
<!-- The root prototype must not ingest XAML/resources from nested SDK projects. -->
|
||||||
|
<DefaultItemExcludes>$(DefaultItemExcludes);src\**;tests\**;tools\**</DefaultItemExcludes>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -26,9 +26,23 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.Playout.T
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.PlayoutSmoke", "tools\MBN_STOCK_WEBVIEW.PlayoutSmoke\MBN_STOCK_WEBVIEW.PlayoutSmoke.csproj", "{515E56D2-8F90-45A0-83BC-44BD541B10A6}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.PlayoutSmoke", "tools\MBN_STOCK_WEBVIEW.PlayoutSmoke\MBN_STOCK_WEBVIEW.PlayoutSmoke.csproj", "{515E56D2-8F90-45A0-83BC-44BD541B10A6}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.LegacyApplication", "src\MBN_STOCK_WEBVIEW.LegacyApplication\MBN_STOCK_WEBVIEW.LegacyApplication.csproj", "{BD45A78B-3C97-4C55-A917-B46927F00311}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.LegacyBridge", "src\MBN_STOCK_WEBVIEW.LegacyBridge\MBN_STOCK_WEBVIEW.LegacyBridge.csproj", "{7C8D7811-8902-482B-9EF3-67107A3CB676}"
|
||||||
|
EndProject
|
||||||
|
Project("{84322810-C106-461C-892A-5A3631226260}") = "MBN_STOCK_WEBVIEW.LegacyParityApp", "src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj", "{0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.LegacyApplication.Tests", "tests\MBN_STOCK_WEBVIEW.LegacyApplication.Tests\MBN_STOCK_WEBVIEW.LegacyApplication.Tests.csproj", "{60A364DD-1283-4DA1-8660-791B5CB4F86F}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.LegacyBridge.Tests", "tests\MBN_STOCK_WEBVIEW.LegacyBridge.Tests\MBN_STOCK_WEBVIEW.LegacyBridge.Tests.csproj", "{8F28CB75-54A4-4C27-A7F8-88A30D485B19}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.LegacyWeb.Tests", "tests\MBN_STOCK_WEBVIEW.LegacyWeb.Tests\MBN_STOCK_WEBVIEW.LegacyWeb.Tests.csproj", "{9333727F-7367-4AF8-BA9D-EF159A3148D9}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|x64 = Debug|x64
|
Debug|x64 = Debug|x64
|
||||||
|
ParityDebug|x64 = ParityDebug|x64
|
||||||
|
PrototypeDebug|x64 = PrototypeDebug|x64
|
||||||
Release|x64 = Release|x64
|
Release|x64 = Release|x64
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
@@ -70,6 +84,90 @@ Global
|
|||||||
{515E56D2-8F90-45A0-83BC-44BD541B10A6}.Debug|x64.Build.0 = Debug|x64
|
{515E56D2-8F90-45A0-83BC-44BD541B10A6}.Debug|x64.Build.0 = Debug|x64
|
||||||
{515E56D2-8F90-45A0-83BC-44BD541B10A6}.Release|x64.ActiveCfg = Release|x64
|
{515E56D2-8F90-45A0-83BC-44BD541B10A6}.Release|x64.ActiveCfg = Release|x64
|
||||||
{515E56D2-8F90-45A0-83BC-44BD541B10A6}.Release|x64.Build.0 = Release|x64
|
{515E56D2-8F90-45A0-83BC-44BD541B10A6}.Release|x64.Build.0 = Release|x64
|
||||||
|
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.ParityDebug|x64.ActiveCfg = Debug|x64
|
||||||
|
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.PrototypeDebug|x64.ActiveCfg = Debug|x64
|
||||||
|
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.PrototypeDebug|x64.Build.0 = Debug|x64
|
||||||
|
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.PrototypeDebug|x64.Deploy.0 = Debug|x64
|
||||||
|
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.ParityDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.ParityDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.PrototypeDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.ParityDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.ParityDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.PrototypeDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{040E8620-035C-4F62-B408-C05C5BDC90FE}.ParityDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{040E8620-035C-4F62-B408-C05C5BDC90FE}.ParityDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{040E8620-035C-4F62-B408-C05C5BDC90FE}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{040E8620-035C-4F62-B408-C05C5BDC90FE}.PrototypeDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.ParityDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.ParityDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.PrototypeDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.ParityDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.ParityDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.PrototypeDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{8275871E-4ECB-465B-AA77-D09E61CC1D84}.ParityDebug|x64.ActiveCfg = Debug|x64
|
||||||
|
{8275871E-4ECB-465B-AA77-D09E61CC1D84}.ParityDebug|x64.Build.0 = Debug|x64
|
||||||
|
{8275871E-4ECB-465B-AA77-D09E61CC1D84}.PrototypeDebug|x64.ActiveCfg = Debug|x64
|
||||||
|
{8275871E-4ECB-465B-AA77-D09E61CC1D84}.PrototypeDebug|x64.Build.0 = Debug|x64
|
||||||
|
{74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}.ParityDebug|x64.ActiveCfg = Debug|x64
|
||||||
|
{74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}.ParityDebug|x64.Build.0 = Debug|x64
|
||||||
|
{74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}.PrototypeDebug|x64.ActiveCfg = Debug|x64
|
||||||
|
{74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}.PrototypeDebug|x64.Build.0 = Debug|x64
|
||||||
|
{515E56D2-8F90-45A0-83BC-44BD541B10A6}.ParityDebug|x64.ActiveCfg = Debug|x64
|
||||||
|
{515E56D2-8F90-45A0-83BC-44BD541B10A6}.ParityDebug|x64.Build.0 = Debug|x64
|
||||||
|
{515E56D2-8F90-45A0-83BC-44BD541B10A6}.PrototypeDebug|x64.ActiveCfg = Debug|x64
|
||||||
|
{515E56D2-8F90-45A0-83BC-44BD541B10A6}.PrototypeDebug|x64.Build.0 = Debug|x64
|
||||||
|
{BD45A78B-3C97-4C55-A917-B46927F00311}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{BD45A78B-3C97-4C55-A917-B46927F00311}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{BD45A78B-3C97-4C55-A917-B46927F00311}.ParityDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{BD45A78B-3C97-4C55-A917-B46927F00311}.ParityDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{BD45A78B-3C97-4C55-A917-B46927F00311}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{BD45A78B-3C97-4C55-A917-B46927F00311}.PrototypeDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{BD45A78B-3C97-4C55-A917-B46927F00311}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{BD45A78B-3C97-4C55-A917-B46927F00311}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{7C8D7811-8902-482B-9EF3-67107A3CB676}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{7C8D7811-8902-482B-9EF3-67107A3CB676}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{7C8D7811-8902-482B-9EF3-67107A3CB676}.ParityDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{7C8D7811-8902-482B-9EF3-67107A3CB676}.ParityDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{7C8D7811-8902-482B-9EF3-67107A3CB676}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{7C8D7811-8902-482B-9EF3-67107A3CB676}.PrototypeDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{7C8D7811-8902-482B-9EF3-67107A3CB676}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{7C8D7811-8902-482B-9EF3-67107A3CB676}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.Debug|x64.ActiveCfg = Debug|x64
|
||||||
|
{0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.Debug|x64.Build.0 = Debug|x64
|
||||||
|
{0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.ParityDebug|x64.ActiveCfg = Debug|x64
|
||||||
|
{0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.ParityDebug|x64.Build.0 = Debug|x64
|
||||||
|
{0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.ParityDebug|x64.Deploy.0 = Debug|x64
|
||||||
|
{0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.PrototypeDebug|x64.ActiveCfg = Debug|x64
|
||||||
|
{0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.Release|x64.ActiveCfg = Release|x64
|
||||||
|
{0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.Release|x64.Build.0 = Release|x64
|
||||||
|
{60A364DD-1283-4DA1-8660-791B5CB4F86F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{60A364DD-1283-4DA1-8660-791B5CB4F86F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{60A364DD-1283-4DA1-8660-791B5CB4F86F}.ParityDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{60A364DD-1283-4DA1-8660-791B5CB4F86F}.ParityDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{60A364DD-1283-4DA1-8660-791B5CB4F86F}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{60A364DD-1283-4DA1-8660-791B5CB4F86F}.PrototypeDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{60A364DD-1283-4DA1-8660-791B5CB4F86F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{60A364DD-1283-4DA1-8660-791B5CB4F86F}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{8F28CB75-54A4-4C27-A7F8-88A30D485B19}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{8F28CB75-54A4-4C27-A7F8-88A30D485B19}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{8F28CB75-54A4-4C27-A7F8-88A30D485B19}.ParityDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{8F28CB75-54A4-4C27-A7F8-88A30D485B19}.ParityDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{8F28CB75-54A4-4C27-A7F8-88A30D485B19}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{8F28CB75-54A4-4C27-A7F8-88A30D485B19}.PrototypeDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{8F28CB75-54A4-4C27-A7F8-88A30D485B19}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{8F28CB75-54A4-4C27-A7F8-88A30D485B19}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{9333727F-7367-4AF8-BA9D-EF159A3148D9}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{9333727F-7367-4AF8-BA9D-EF159A3148D9}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{9333727F-7367-4AF8-BA9D-EF159A3148D9}.ParityDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{9333727F-7367-4AF8-BA9D-EF159A3148D9}.ParityDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{9333727F-7367-4AF8-BA9D-EF159A3148D9}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{9333727F-7367-4AF8-BA9D-EF159A3148D9}.PrototypeDebug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{9333727F-7367-4AF8-BA9D-EF159A3148D9}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{9333727F-7367-4AF8-BA9D-EF159A3148D9}.Release|x64.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -83,6 +181,12 @@ Global
|
|||||||
{8275871E-4ECB-465B-AA77-D09E61CC1D84} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{8275871E-4ECB-465B-AA77-D09E61CC1D84} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{74DE0671-7705-4EA9-8BCB-BA98FD86C6B2} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
{74DE0671-7705-4EA9-8BCB-BA98FD86C6B2} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
{515E56D2-8F90-45A0-83BC-44BD541B10A6} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
|
{515E56D2-8F90-45A0-83BC-44BD541B10A6} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
|
||||||
|
{BD45A78B-3C97-4C55-A917-B46927F00311} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{7C8D7811-8902-482B-9EF3-67107A3CB676} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{60A364DD-1283-4DA1-8660-791B5CB4F86F} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
|
{8F28CB75-54A4-4C27-A7F8-88A30D485B19} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
|
{9333727F-7367-4AF8-BA9D-EF159A3148D9} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {58E98A35-4C8C-4C80-9227-1063B345AD07}
|
SolutionGuid = {58E98A35-4C8C-4C80-9227-1063B345AD07}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
기존 `MBN_STOCK_N` WinForms 애플리케이션을 단계적으로 대체하기 위한 Windows 데스크톱 프로젝트입니다.
|
기존 `MBN_STOCK_N` WinForms 애플리케이션을 단계적으로 대체하기 위한 Windows 데스크톱 프로젝트입니다.
|
||||||
|
|
||||||
|
> 현재 개발 경로는 둘로 분리되어 있습니다. 아래의 기존 구현 범위는 비교용 루트 프로토타입을 설명합니다. 고객 요구처럼 원본 UI와 클릭 이벤트를 먼저 1:1 이관하는 새 병렬 앱의 범위와 실행 방법은 [원본 호환 마이그레이션 기준선](docs/LEGACY_PARITY_REBASELINE.md)을 참조하세요.
|
||||||
|
|
||||||
현재 기준 기술 스택은 다음과 같습니다.
|
현재 기준 기술 스택은 다음과 같습니다.
|
||||||
|
|
||||||
- .NET 8 / C#
|
- .NET 8 / C#
|
||||||
|
|||||||
101
docs/LEGACY_PARITY_REBASELINE.md
Normal file
101
docs/LEGACY_PARITY_REBASELINE.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# 원본 호환 마이그레이션 기준선
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
기존 저장소와 검증된 `Core / Infrastructure / Playout`은 유지한다. 기존 루트 WinUI 앱은 비교용 프로토타입으로 보존하고, 원본 UI와 이벤트를 먼저 재현하는 `MBN_STOCK_WEBVIEW.LegacyParityApp`을 같은 솔루션에서 병행 개발한다.
|
||||||
|
|
||||||
|
WebView는 HTML 컨트롤 표시와 사용자 입력 전달만 담당한다. 검색 정책, 선택 상태, 플레이리스트 필드, 장면 alias, PageN 및 송출 상태의 권위는 C# Application 계층에 둔다.
|
||||||
|
|
||||||
|
## 기준 원본
|
||||||
|
|
||||||
|
- 읽기 전용 저장소: `C:\Users\MD\source\repos\MBN_STOCK_N`
|
||||||
|
- 조사 시 HEAD: `309286309eaf20b58172247263f575753a860700`
|
||||||
|
- 실제 실행 컷 목록: `MBN_STOCK_N\bin\Debug\Res\종목.ini`
|
||||||
|
- 실행 컷 목록 SHA-256: `45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20`
|
||||||
|
- 실행 컷 목록은 구분행 2개를 포함한 33개 물리행이며 실제 컷은 31개다.
|
||||||
|
- 3번 컷은 고객 화면과 실행본을 따라 `1열판기본_시간외단일가`를 사용한다. 소스 `Res`의 `시간외종가`는 기준으로 사용하지 않는다.
|
||||||
|
|
||||||
|
원본 작업 트리에는 기존 수정이 있으므로 커밋 SHA만으로 동등성을 판단하지 않는다. 원본은 계속 읽기 전용으로 취급한다.
|
||||||
|
|
||||||
|
## 신규 프로젝트
|
||||||
|
|
||||||
|
| 프로젝트 | 책임 |
|
||||||
|
|---|---|
|
||||||
|
| `MBN_STOCK_WEBVIEW.LegacyApplication` | 원본 MainForm/UC 상태, 이벤트 및 업무 규칙 |
|
||||||
|
| `MBN_STOCK_WEBVIEW.LegacyBridge` | 허용된 WebView 입력 intent와 표시 snapshot 계약 |
|
||||||
|
| `MBN_STOCK_WEBVIEW.LegacyParityApp` | WinUI 3 창, WebView2, DB composition root, 별도 개발 MSIX |
|
||||||
|
| `LegacyApplication.Tests` | 원본 이벤트 및 결과 DTO 동등성 테스트 |
|
||||||
|
| `LegacyBridge.Tests` | strict JSON과 Web 권한 경계 테스트 |
|
||||||
|
| `LegacyWeb.Tests` | Visual Studio에서 항상 실행되는 Web thin-client 계약 테스트 |
|
||||||
|
|
||||||
|
개발 패키지 identity는 `Wickedness.MBNStockWebView.LegacyParity`다. 운영 identity `Wickedness.MBNStockWebView`를 대체하지 않는다.
|
||||||
|
|
||||||
|
## 첫 수직 흐름 상태
|
||||||
|
|
||||||
|
구현됨:
|
||||||
|
|
||||||
|
- 검색 버튼의 정확한 빈 문자열 no-op과 Enter 빈 검색 허용
|
||||||
|
- KOSPI → KOSDAQ → NXT KOSPI → NXT KOSDAQ 순서 및 DB 반환 순서 유지
|
||||||
|
- trim, 전체 정렬, 결과 제한 및 wildcard escape를 하지 않는 원본 호환 검색
|
||||||
|
- 바인드 파라미터를 통한 SQL injection 방지
|
||||||
|
- master snapshot을 이름으로 다시 순회하고 마지막 일치를 채택하는 `jongmokCode` 규칙
|
||||||
|
- 33개 물리 컷 행과 31개 display 번호
|
||||||
|
- 원본 `ClientSize 1905 x 1015`, `FixedSingle`, 최대화 불가 창 계약
|
||||||
|
- 일반/Ctrl/Shift/Ctrl+Shift 선택을 C# 상태로 처리
|
||||||
|
- 0.5초 이내, 동일 행, 동일 좌표 조건의 원본 더블클릭 판정
|
||||||
|
- 선택 컷 drag/drop과 물리 index 오름차순 추가
|
||||||
|
- 원본 7필드 플레이리스트 행과 중복 추가 허용
|
||||||
|
- NXT `1열판기본_현재가`만 허용하고 나머지는 원본 경고 표시
|
||||||
|
- PREPARE 직전 `LegacyPlaylistEntry`로 변환할 수 있는 31개 컷 행의 alias 매핑
|
||||||
|
|
||||||
|
아직 이관하지 않음:
|
||||||
|
|
||||||
|
- 좌측 수동 입력 GraphE 4개 모달
|
||||||
|
- 중앙 UC1~UC7과 모든 탭 이벤트
|
||||||
|
- 플레이리스트 선택/삭제/정렬/DB 저장·불러오기
|
||||||
|
- 배경, 이동평균, Fade/Update 시간 컨트롤
|
||||||
|
- PREPARE/TAKE IN/NEXT/TAKE OUT 실제 연결
|
||||||
|
- 운영 DB write와 Tornado2/PGM 검증
|
||||||
|
|
||||||
|
미이관 버튼은 현재 disabled 상태이며, 연결 상태는 `미연결`로 명시한다. 새 앱은 Tornado2에 연결하지 않는다.
|
||||||
|
|
||||||
|
## Visual Studio 실행
|
||||||
|
|
||||||
|
1. `MBN_STOCK_WEBVIEW.sln`을 연다.
|
||||||
|
2. 솔루션 구성을 `ParityDebug | x64`로 선택한다.
|
||||||
|
3. `MBN_STOCK_WEBVIEW.LegacyParityApp`을 시작 프로젝트로 지정한다.
|
||||||
|
4. 실행한다.
|
||||||
|
|
||||||
|
`PrototypeDebug | x64`는 기존 루트 앱을 배포하는 비교용 구성이다. 두 앱은 서로 다른 MSIX identity와 single-instance key를 사용한다.
|
||||||
|
|
||||||
|
기존 `Release | x64`의 자동 배포 대상은 비교용 루트 앱으로 유지한다. 호환 앱 Release는 두 패키지를 동시에 배포하지 않도록 `MBN_STOCK_WEBVIEW.LegacyParityApp` 프로젝트의 MSIX 생성 명령으로 명시적으로 만든다.
|
||||||
|
|
||||||
|
## 2026-07-15 기준선 검증
|
||||||
|
|
||||||
|
- `ParityDebug | x64`와 `Release | x64` 솔루션 빌드: 경고 0, 오류 0
|
||||||
|
- Debug/Release 각각 C# 테스트 1,980개 통과
|
||||||
|
- Core 1,383 / Infrastructure 173 / Playout 393
|
||||||
|
- LegacyApplication 17 / LegacyBridge 7 / LegacyWeb 7
|
||||||
|
- Node 기반 JavaScript syntax 및 Web 계약 테스트 7개 통과
|
||||||
|
- Release x64 개발 MSIX `0.1.0.0` 생성 및 `CN=Comtrophy` 서명 검증
|
||||||
|
- MSIX SHA-256: `D3FF9CF1F63E6BC68AA147C0ED47690FE130CF425C0276B3201F763EC5C50A4B`
|
||||||
|
- 패키지 76개 항목 중 인증서, 비밀번호, 라이선스, `.t2s`, 운영 설정 파일 0개
|
||||||
|
- 패키지의 `Web/index.html`, `styles.css`, `app.js`가 소스와 byte 단위로 일치
|
||||||
|
- 별도 패키지 전체 이름 `Wickedness.MBNStockWebView.LegacyParity_0.1.0.0_x64__qbv3jkvsn3aj0`, package family `Wickedness.MBNStockWebView.LegacyParity_qbv3jkvsn3aj0` 설치·실행 성공
|
||||||
|
- 패키지 창의 실제 WebView client bounds `1905 x 1015` 확인
|
||||||
|
- 격리 WinForms ListView trace로 최초 Shift의 native 선택 `0..현재행`과 별도 pointer 목록의 `-1..현재행`, 같은 행 Shift의 native 선택 유지와 pointer 목록 clear를 확인하고 두 상태를 분리
|
||||||
|
- 실제 DB read-only 경로로 `삼성` 검색 → `삼성출판사` 선택 → `현재가` 동일 좌표 더블클릭 → 원본 7필드 플레이리스트 행 생성 확인
|
||||||
|
- 검색 시작 직후 정상 창 닫기 경합 검증에서 10초 이내 종료 및 Windows Application 오류 이벤트 0개
|
||||||
|
|
||||||
|
이 검증에서는 DB write, PREPARE, TAKE IN, NEXT, TAKE OUT 및 Tornado2 연결을 실행하지 않았다. 화면의 송출 버튼도 계속 비활성 상태다.
|
||||||
|
|
||||||
|
## 다음 이관 순서
|
||||||
|
|
||||||
|
1. 플레이리스트 row 선택·Ctrl/Shift·삭제·전체/개별 enabled
|
||||||
|
2. 중앙 탭과 UC1부터 원본 이벤트 단위로 수직 이관
|
||||||
|
3. 수동 입력 Form과 격리 DB write/read-back
|
||||||
|
4. C# playlist authority를 기존 `LegacyPlayoutWorkflow`에 연결한 DryRun PREPARE
|
||||||
|
5. 35개 scene/PageN differential trace
|
||||||
|
6. 승인된 실제 Tornado2/PGM 검증
|
||||||
|
|
||||||
|
각 기능은 원본 입력과 신규 입력의 snapshot, DB 파라미터, 플레이리스트 7필드 및 K3D trace를 비교한 뒤 완료 처리한다.
|
||||||
45
scripts/Test-LegacyParityWeb.ps1
Normal file
45
scripts/Test-LegacyParityWeb.ps1
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param([string] $NodePath)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($NodePath)) {
|
||||||
|
$command = Get-Command node -CommandType Application -ErrorAction SilentlyContinue |
|
||||||
|
Select-Object -First 1
|
||||||
|
if ($null -ne $command) {
|
||||||
|
$NodePath = $command.Source
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$bundledNode = Join-Path $env:USERPROFILE `
|
||||||
|
'.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe'
|
||||||
|
if (Test-Path -LiteralPath $bundledNode -PathType Leaf) {
|
||||||
|
$NodePath = $bundledNode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($NodePath)) {
|
||||||
|
throw 'Node.js was not found. Pass an absolute executable path with -NodePath.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not [IO.Path]::IsPathRooted($NodePath) -or
|
||||||
|
-not (Test-Path -LiteralPath $NodePath -PathType Leaf)) {
|
||||||
|
throw 'NodePath must identify an existing absolute node executable.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||||
|
$appScript = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\Web\app.js'
|
||||||
|
$testFile = Join-Path $repositoryRoot 'tests\LegacyParityWeb\thin-client.test.cjs'
|
||||||
|
|
||||||
|
& $NodePath --check $appScript
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Legacy parity JavaScript syntax validation failed with exit code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
|
||||||
|
& $NodePath --test $testFile
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Legacy parity Web tests failed with exit code $LASTEXITCODE."
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||||
|
|
||||||
|
public enum LegacySearchTrigger
|
||||||
|
{
|
||||||
|
Button,
|
||||||
|
Enter
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum LegacyOperatorStatusKind
|
||||||
|
{
|
||||||
|
Neutral,
|
||||||
|
Information,
|
||||||
|
Warning,
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record LegacyStockResultRow(int Index, string DisplayName);
|
||||||
|
|
||||||
|
public sealed record LegacyCutViewRow(
|
||||||
|
int PhysicalIndex,
|
||||||
|
int? DisplayOrdinal,
|
||||||
|
string RawLabel,
|
||||||
|
bool IsSeparator,
|
||||||
|
bool IsSelected);
|
||||||
|
|
||||||
|
public sealed record LegacyDialogState(string Message);
|
||||||
|
|
||||||
|
public sealed record LegacyOperatorSnapshot(
|
||||||
|
long Revision,
|
||||||
|
string SearchText,
|
||||||
|
IReadOnlyList<LegacyStockResultRow> SearchResults,
|
||||||
|
int? SelectedStockIndex,
|
||||||
|
IReadOnlyList<LegacyCutViewRow> CutRows,
|
||||||
|
IReadOnlyList<LegacyOperatorPlaylistRow> Playlist,
|
||||||
|
LegacyDialogState? Dialog,
|
||||||
|
string StatusMessage,
|
||||||
|
LegacyOperatorStatusKind StatusKind);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authoritative C# state for the first legacy operator vertical slice. The WebView sends
|
||||||
|
/// input intent only; it never derives stock identity, cut aliases or playlist fields.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LegacyOperatorController
|
||||||
|
{
|
||||||
|
private const long DoubleClickWindowMilliseconds = 500;
|
||||||
|
|
||||||
|
private readonly ILegacyStockLookup _stockLookup;
|
||||||
|
private readonly SortedSet<int> _legacyPointerRows = [];
|
||||||
|
private readonly SortedSet<int> _nativeSelectedCutRows = [];
|
||||||
|
private readonly List<LegacyOperatorPlaylistRow> _playlist = [];
|
||||||
|
private Queue<int>? _pendingDropCutRows;
|
||||||
|
private LegacyStockSearchData _searchData = new(string.Empty, [], []);
|
||||||
|
private int? _selectedStockIndex;
|
||||||
|
private int? _cutAnchorIndex;
|
||||||
|
private int _nativeSelectionAnchorIndex;
|
||||||
|
private int? _previousPointerRow;
|
||||||
|
private int _previousPointerX;
|
||||||
|
private int _previousPointerY;
|
||||||
|
private long? _previousPointerTimestamp;
|
||||||
|
private long _revision;
|
||||||
|
private long _playlistSequence;
|
||||||
|
private string _searchText = string.Empty;
|
||||||
|
private string _statusMessage = string.Empty;
|
||||||
|
private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
|
||||||
|
private LegacyDialogState? _dialog;
|
||||||
|
|
||||||
|
public LegacyOperatorController(ILegacyStockLookup stockLookup)
|
||||||
|
{
|
||||||
|
_stockLookup = stockLookup ?? throw new ArgumentNullException(nameof(stockLookup));
|
||||||
|
}
|
||||||
|
|
||||||
|
public LegacyOperatorSnapshot Current => CreateSnapshot();
|
||||||
|
|
||||||
|
public async Task<LegacyOperatorSnapshot> SearchStocksAsync(
|
||||||
|
string text,
|
||||||
|
LegacySearchTrigger trigger,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(text);
|
||||||
|
_searchText = text;
|
||||||
|
|
||||||
|
// button9_Click returned before calling searchStock only for an exactly empty string.
|
||||||
|
// txtSearch_KeyPress did not have that guard, so blank Enter still queries all rows.
|
||||||
|
if (trigger == LegacySearchTrigger.Button && text.Length == 0)
|
||||||
|
{
|
||||||
|
Touch();
|
||||||
|
return CreateSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
_searchData = await _stockLookup.SearchAsync(text, cancellationToken).ConfigureAwait(false);
|
||||||
|
_selectedStockIndex = null;
|
||||||
|
_statusMessage = string.Empty;
|
||||||
|
_statusKind = LegacyOperatorStatusKind.Neutral;
|
||||||
|
_dialog = null;
|
||||||
|
_pendingDropCutRows = null;
|
||||||
|
Touch();
|
||||||
|
return CreateSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
public LegacyOperatorSnapshot SelectStock(int resultIndex)
|
||||||
|
{
|
||||||
|
if (resultIndex < 0 || resultIndex >= _searchData.DisplayNames.Count)
|
||||||
|
{
|
||||||
|
return CreateSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedStockIndex = resultIndex;
|
||||||
|
Touch();
|
||||||
|
return CreateSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
public LegacyOperatorSnapshot CutPointerDown(
|
||||||
|
int physicalIndex,
|
||||||
|
bool control,
|
||||||
|
bool shift,
|
||||||
|
int x,
|
||||||
|
int y,
|
||||||
|
long timestampMilliseconds)
|
||||||
|
{
|
||||||
|
if (_selectedStockIndex is null || !IsCutIndex(physicalIndex))
|
||||||
|
{
|
||||||
|
return CreateSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
var gap = _previousPointerTimestamp is long previousTimestamp
|
||||||
|
? timestampMilliseconds - previousTimestamp
|
||||||
|
: long.MaxValue;
|
||||||
|
if (_previousPointerTimestamp is not null &&
|
||||||
|
gap >= 0 && gap < DoubleClickWindowMilliseconds &&
|
||||||
|
_previousPointerRow == physicalIndex &&
|
||||||
|
_previousPointerX == x &&
|
||||||
|
_previousPointerY == y &&
|
||||||
|
_legacyPointerRows.Count == 1)
|
||||||
|
{
|
||||||
|
TryAppendCut(_legacyPointerRows.Min);
|
||||||
|
_previousPointerTimestamp = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_previousPointerTimestamp = timestampMilliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
_previousPointerRow = physicalIndex;
|
||||||
|
_previousPointerX = x;
|
||||||
|
_previousPointerY = y;
|
||||||
|
|
||||||
|
ApplyLegacyPointerSelection(physicalIndex, control, shift);
|
||||||
|
ApplyNativeListSelection(physicalIndex, control, shift);
|
||||||
|
|
||||||
|
_cutAnchorIndex = physicalIndex;
|
||||||
|
_nativeSelectionAnchorIndex = physicalIndex;
|
||||||
|
Touch();
|
||||||
|
return CreateSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
public LegacyOperatorSnapshot DropSelectedCutsOnPlaylist()
|
||||||
|
{
|
||||||
|
if (_selectedStockIndex is null || _dialog is not null)
|
||||||
|
{
|
||||||
|
return CreateSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingDropCutRows = new Queue<int>(_nativeSelectedCutRows);
|
||||||
|
ContinuePendingDrop();
|
||||||
|
|
||||||
|
Touch();
|
||||||
|
return CreateSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
public LegacyOperatorSnapshot SetPlaylistRowEnabled(string rowId, bool enabled)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(rowId);
|
||||||
|
var index = _playlist.FindIndex(row =>
|
||||||
|
string.Equals(row.RowId, rowId, StringComparison.Ordinal));
|
||||||
|
if (index >= 0)
|
||||||
|
{
|
||||||
|
_playlist[index] = _playlist[index] with { IsEnabled = enabled };
|
||||||
|
Touch();
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreateSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
public LegacyOperatorSnapshot DismissDialog()
|
||||||
|
{
|
||||||
|
if (_dialog is not null)
|
||||||
|
{
|
||||||
|
_dialog = null;
|
||||||
|
ContinuePendingDrop();
|
||||||
|
Touch();
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreateSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
public LegacyOperatorSnapshot ReportError(string message)
|
||||||
|
{
|
||||||
|
_statusMessage = message ?? string.Empty;
|
||||||
|
_statusKind = LegacyOperatorStatusKind.Error;
|
||||||
|
Touch();
|
||||||
|
return CreateSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ContinuePendingDrop()
|
||||||
|
{
|
||||||
|
while (_pendingDropCutRows is { Count: > 0 })
|
||||||
|
{
|
||||||
|
var physicalIndex = _pendingDropCutRows.Dequeue();
|
||||||
|
if (!TryAppendCut(physicalIndex))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingDropCutRows = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryAppendCut(int physicalIndex)
|
||||||
|
{
|
||||||
|
if (_selectedStockIndex is not int stockIndex ||
|
||||||
|
stockIndex < 0 || stockIndex >= _searchData.DisplayNames.Count ||
|
||||||
|
!IsCutIndex(physicalIndex))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cut = LegacyStockCutCatalog.Rows[physicalIndex];
|
||||||
|
var stockName = _searchData.DisplayNames[stockIndex];
|
||||||
|
var isNxt = stockName.Contains("(NXT)", StringComparison.Ordinal);
|
||||||
|
if (isNxt && !string.Equals(
|
||||||
|
cut.RawLabel,
|
||||||
|
LegacyStockCutCatalog.NxtAllowedLabel,
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_dialog = new LegacyDialogState("NXT는 제공하지 않습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cut.IsSeparator)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity = _searchData.ResolveLastByDisplayName(stockName);
|
||||||
|
var rowId = $"legacy-stock-{++_playlistSequence:D8}";
|
||||||
|
_playlist.Add(LegacyStockCutCatalog.CreatePlaylistRow(
|
||||||
|
rowId,
|
||||||
|
stockName,
|
||||||
|
identity,
|
||||||
|
cut));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyLegacyPointerSelection(int physicalIndex, bool control, bool shift)
|
||||||
|
{
|
||||||
|
if (control && shift)
|
||||||
|
{
|
||||||
|
AddLegacyRangeFromAnchor(physicalIndex);
|
||||||
|
}
|
||||||
|
else if (control)
|
||||||
|
{
|
||||||
|
if (!_legacyPointerRows.Add(physicalIndex))
|
||||||
|
{
|
||||||
|
_legacyPointerRows.Remove(physicalIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (shift)
|
||||||
|
{
|
||||||
|
_legacyPointerRows.Clear();
|
||||||
|
AddLegacyRangeFromAnchor(physicalIndex);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_legacyPointerRows.Clear();
|
||||||
|
_legacyPointerRows.Add(physicalIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyNativeListSelection(int physicalIndex, bool control, bool shift)
|
||||||
|
{
|
||||||
|
if (control && shift)
|
||||||
|
{
|
||||||
|
AddInclusiveRange(
|
||||||
|
_nativeSelectedCutRows,
|
||||||
|
_nativeSelectionAnchorIndex,
|
||||||
|
physicalIndex);
|
||||||
|
}
|
||||||
|
else if (control)
|
||||||
|
{
|
||||||
|
if (!_nativeSelectedCutRows.Add(physicalIndex))
|
||||||
|
{
|
||||||
|
_nativeSelectedCutRows.Remove(physicalIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (shift)
|
||||||
|
{
|
||||||
|
_nativeSelectedCutRows.Clear();
|
||||||
|
AddInclusiveRange(
|
||||||
|
_nativeSelectedCutRows,
|
||||||
|
_nativeSelectionAnchorIndex,
|
||||||
|
physicalIndex);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_nativeSelectedCutRows.Clear();
|
||||||
|
_nativeSelectedCutRows.Add(physicalIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddLegacyRangeFromAnchor(int physicalIndex)
|
||||||
|
{
|
||||||
|
// MainForm used beforeRow = -1 and skipped the range when the previous
|
||||||
|
// and current rows were equal. The sentinel also affected its double-click count.
|
||||||
|
var anchor = _cutAnchorIndex ?? -1;
|
||||||
|
if (anchor == physicalIndex)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var first = Math.Min(anchor, physicalIndex);
|
||||||
|
var last = Math.Max(anchor, physicalIndex);
|
||||||
|
for (var index = first; index <= last; index++)
|
||||||
|
{
|
||||||
|
_legacyPointerRows.Add(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddInclusiveRange(
|
||||||
|
ISet<int> target,
|
||||||
|
int anchor,
|
||||||
|
int physicalIndex)
|
||||||
|
{
|
||||||
|
var first = Math.Min(anchor, physicalIndex);
|
||||||
|
var last = Math.Max(anchor, physicalIndex);
|
||||||
|
for (var index = first; index <= last; index++)
|
||||||
|
{
|
||||||
|
target.Add(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private LegacyOperatorSnapshot CreateSnapshot()
|
||||||
|
{
|
||||||
|
var searchRows = _searchData.DisplayNames
|
||||||
|
.Select((name, index) => new LegacyStockResultRow(index, name))
|
||||||
|
.ToArray();
|
||||||
|
var cutRows = LegacyStockCutCatalog.Rows
|
||||||
|
.Select(row => new LegacyCutViewRow(
|
||||||
|
row.PhysicalIndex,
|
||||||
|
row.DisplayOrdinal,
|
||||||
|
row.RawLabel,
|
||||||
|
row.IsSeparator,
|
||||||
|
_nativeSelectedCutRows.Contains(row.PhysicalIndex)))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new LegacyOperatorSnapshot(
|
||||||
|
_revision,
|
||||||
|
_searchText,
|
||||||
|
Array.AsReadOnly(searchRows),
|
||||||
|
_selectedStockIndex,
|
||||||
|
Array.AsReadOnly(cutRows),
|
||||||
|
Array.AsReadOnly(_playlist.ToArray()),
|
||||||
|
_dialog,
|
||||||
|
_statusMessage,
|
||||||
|
_statusKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsCutIndex(int physicalIndex) =>
|
||||||
|
physicalIndex >= 0 && physicalIndex < LegacyStockCutCatalog.Rows.Count;
|
||||||
|
|
||||||
|
private void Touch() => _revision++;
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
using System.Data;
|
||||||
|
using System.Globalization;
|
||||||
|
using MMoneyCoderSharp.Data;
|
||||||
|
|
||||||
|
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compatibility search for the operator screen. Unlike the modern lookup it intentionally
|
||||||
|
/// keeps blank-enter, LIKE wildcard, provider ordering and unlimited-result semantics.
|
||||||
|
/// Values remain bound parameters so parity does not reintroduce SQL concatenation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LegacyParityStockSearchService : ILegacyStockLookup
|
||||||
|
{
|
||||||
|
public const int LegacyTextBoxMaximumLength = 32_767;
|
||||||
|
|
||||||
|
private static readonly SearchProfile[] Profiles =
|
||||||
|
[
|
||||||
|
new(
|
||||||
|
LegacyDomesticStockMarket.Kospi,
|
||||||
|
DataSourceKind.Oracle,
|
||||||
|
"LEGACY_PARITY_KOSPI",
|
||||||
|
"SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_STOCK WHERE F_MKT_HALT = 'N' AND UPPER(F_STOCK_WANNAME) LIKE '%' || :search_term || '%'",
|
||||||
|
"SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_STOCK WHERE F_MKT_HALT = 'N'"),
|
||||||
|
new(
|
||||||
|
LegacyDomesticStockMarket.Kosdaq,
|
||||||
|
DataSourceKind.Oracle,
|
||||||
|
"LEGACY_PARITY_KOSDAQ",
|
||||||
|
"SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_KOSDAQ_STOCK WHERE F_MKT_HALT = 'N' AND UPPER(F_STOCK_WANNAME) LIKE '%' || :search_term || '%'",
|
||||||
|
"SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_KOSDAQ_STOCK WHERE F_MKT_HALT = 'N'"),
|
||||||
|
new(
|
||||||
|
LegacyDomesticStockMarket.NxtKospi,
|
||||||
|
DataSourceKind.MariaDb,
|
||||||
|
"LEGACY_PARITY_NXT_KOSPI",
|
||||||
|
"SELECT CONCAT(a.F_STOCK_NAME, '(NXT)') AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE FROM N_STOCK a, N_ONLINE b WHERE a.F_STOP_GUBUN = 'N' AND a.F_STOCK_CODE = b.F_STOCK_CODE AND UPPER(a.F_STOCK_NAME) LIKE CONCAT('%', @search_term, '%')",
|
||||||
|
"SELECT CONCAT(F_STOCK_NAME, '(NXT)') AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM N_STOCK WHERE F_STOP_GUBUN = 'N'"),
|
||||||
|
new(
|
||||||
|
LegacyDomesticStockMarket.NxtKosdaq,
|
||||||
|
DataSourceKind.MariaDb,
|
||||||
|
"LEGACY_PARITY_NXT_KOSDAQ",
|
||||||
|
"SELECT CONCAT(a.F_STOCK_NAME, '(NXT)') AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE FROM N_KOSDAQ_STOCK a, N_KOSDAQ_ONLINE b WHERE a.F_STOP_GUBUN = 'N' AND a.F_STOCK_CODE = b.F_STOCK_CODE AND UPPER(a.F_STOCK_NAME) LIKE CONCAT('%', @search_term, '%')",
|
||||||
|
"SELECT CONCAT(F_STOCK_NAME, '(NXT)') AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM N_KOSDAQ_STOCK WHERE F_STOP_GUBUN = 'N'")
|
||||||
|
];
|
||||||
|
|
||||||
|
private readonly IDataQueryExecutor _executor;
|
||||||
|
private readonly SemaphoreSlim _masterLoadGate = new(1, 1);
|
||||||
|
private IReadOnlyList<LegacyStockIdentity>? _masterCatalog;
|
||||||
|
|
||||||
|
public LegacyParityStockSearchService(IDataQueryExecutor executor)
|
||||||
|
{
|
||||||
|
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LegacyStockSearchData> SearchAsync(
|
||||||
|
string query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
if (query.Length > LegacyTextBoxMaximumLength)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(query),
|
||||||
|
$"The legacy search text cannot exceed {LegacyTextBoxMaximumLength} characters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var masterCatalog = await GetMasterCatalogAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var searchTerm = query.ToUpper(CultureInfo.CurrentCulture);
|
||||||
|
var names = new List<string>();
|
||||||
|
|
||||||
|
foreach (var profile in Profiles)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var parameter = new DataQueryParameter("search_term", searchTerm, DbType.String);
|
||||||
|
var spec = new DataQuerySpec(profile.SearchSql, [parameter]);
|
||||||
|
var table = await _executor
|
||||||
|
.ExecuteAsync(profile.Source, profile.SearchTableName, spec, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
AppendNames(table, profile.SearchTableName, names);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LegacyStockSearchData(query, names, masterCatalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<LegacyStockIdentity>> GetMasterCatalogAsync(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cached = Volatile.Read(ref _masterCatalog);
|
||||||
|
if (cached is not null)
|
||||||
|
{
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _masterLoadGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cached = Volatile.Read(ref _masterCatalog);
|
||||||
|
if (cached is not null)
|
||||||
|
{
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = new List<LegacyStockIdentity>();
|
||||||
|
foreach (var profile in Profiles)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var table = await _executor
|
||||||
|
.ExecuteAsync(
|
||||||
|
profile.Source,
|
||||||
|
$"{profile.SearchTableName}_MASTER",
|
||||||
|
new DataQuerySpec(profile.MasterSql),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
AppendMaster(table, profile, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
cached = Array.AsReadOnly(items.ToArray());
|
||||||
|
Volatile.Write(ref _masterCatalog, cached);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_masterLoadGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendNames(DataTable? table, string sourceName, ICollection<string> target)
|
||||||
|
{
|
||||||
|
ValidateTable(table, sourceName);
|
||||||
|
foreach (DataRow row in table!.Rows)
|
||||||
|
{
|
||||||
|
target.Add(Convert.ToString(row[0], CultureInfo.CurrentCulture) ?? string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendMaster(
|
||||||
|
DataTable? table,
|
||||||
|
SearchProfile profile,
|
||||||
|
ICollection<LegacyStockIdentity> target)
|
||||||
|
{
|
||||||
|
ValidateTable(table, $"{profile.SearchTableName}_MASTER");
|
||||||
|
foreach (DataRow row in table!.Rows)
|
||||||
|
{
|
||||||
|
target.Add(new LegacyStockIdentity(
|
||||||
|
profile.Market,
|
||||||
|
Convert.ToString(row[0], CultureInfo.CurrentCulture) ?? string.Empty,
|
||||||
|
Convert.ToString(row[1], CultureInfo.CurrentCulture) ?? string.Empty));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateTable(DataTable? table, string sourceName)
|
||||||
|
{
|
||||||
|
if (table is null || table.Columns.Count < 2)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Legacy stock data from {sourceName} does not contain two columns.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record SearchProfile(
|
||||||
|
LegacyDomesticStockMarket Market,
|
||||||
|
DataSourceKind Source,
|
||||||
|
string SearchTableName,
|
||||||
|
string SearchSql,
|
||||||
|
string MasterSql);
|
||||||
|
}
|
||||||
197
src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs
Normal file
197
src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||||
|
|
||||||
|
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||||
|
|
||||||
|
public sealed record LegacyCutCatalogRow(
|
||||||
|
int PhysicalIndex,
|
||||||
|
int? DisplayOrdinal,
|
||||||
|
string RawLabel,
|
||||||
|
bool IsSeparator);
|
||||||
|
|
||||||
|
public sealed record LegacyOperatorPlaylistRow(
|
||||||
|
string RowId,
|
||||||
|
bool IsEnabled,
|
||||||
|
string MarketText,
|
||||||
|
string StockName,
|
||||||
|
string GraphicType,
|
||||||
|
string Subtype,
|
||||||
|
string PageText,
|
||||||
|
string StockCode,
|
||||||
|
string RawCutLabel,
|
||||||
|
int FadeDuration)
|
||||||
|
{
|
||||||
|
public LegacyPlaylistEntry ToPlayoutEntry()
|
||||||
|
{
|
||||||
|
var isNxt = StockName.Contains("(NXT)", StringComparison.Ordinal);
|
||||||
|
var cutCode = LegacyStockCutCatalog.ResolveCutAlias(RawCutLabel, isNxt);
|
||||||
|
return new LegacyPlaylistEntry(
|
||||||
|
RowId,
|
||||||
|
cutCode,
|
||||||
|
IsEnabled,
|
||||||
|
FadeDuration,
|
||||||
|
new LegacySceneSelection(
|
||||||
|
MarketText,
|
||||||
|
StockName,
|
||||||
|
GraphicType,
|
||||||
|
Subtype,
|
||||||
|
StockCode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class LegacyStockCutCatalog
|
||||||
|
{
|
||||||
|
public const string RuntimeAssetSha256 =
|
||||||
|
"45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20";
|
||||||
|
public const string NxtAllowedLabel = "1열판기본_현재가";
|
||||||
|
|
||||||
|
private static readonly string?[] RuntimeRows =
|
||||||
|
[
|
||||||
|
"1열판기본_예상체결가",
|
||||||
|
"1열판기본_현재가",
|
||||||
|
"1열판기본_시간외단일가",
|
||||||
|
null,
|
||||||
|
"1열판상세_시가",
|
||||||
|
"1열판상세_액면가",
|
||||||
|
"1열판상세_PBR",
|
||||||
|
"1열판상세_거래량",
|
||||||
|
null,
|
||||||
|
"수익률그래프_5일",
|
||||||
|
"수익률그래프_20일",
|
||||||
|
"수익률그래프_60일",
|
||||||
|
"수익률그래프_120일",
|
||||||
|
"수익률그래프_240일",
|
||||||
|
"캔들그래프_일봉",
|
||||||
|
"캔들그래프_5일",
|
||||||
|
"캔들그래프_20일",
|
||||||
|
"캔들그래프_60일",
|
||||||
|
"캔들그래프_120일",
|
||||||
|
"캔들그래프_240일",
|
||||||
|
"캔들그래프(거래량)_일봉",
|
||||||
|
"캔들그래프(거래량)_5일",
|
||||||
|
"캔들그래프(거래량)_20일",
|
||||||
|
"캔들그래프(거래량)_60일",
|
||||||
|
"캔들그래프(거래량)_120일",
|
||||||
|
"캔들그래프(거래량)_240일",
|
||||||
|
"캔들그래프(예상체결가)_5일",
|
||||||
|
"캔들그래프(예상체결가)_20일",
|
||||||
|
"캔들그래프(예상체결가)_60일",
|
||||||
|
"캔들그래프(예상체결가)_120일",
|
||||||
|
"캔들그래프(예상체결가)_240일",
|
||||||
|
"호가창_표그래프",
|
||||||
|
"거래원_표그래프"
|
||||||
|
];
|
||||||
|
|
||||||
|
public static IReadOnlyList<LegacyCutCatalogRow> Rows { get; } = CreateRows();
|
||||||
|
|
||||||
|
public static string ResolveCutAlias(string rawLabel, bool isNxt)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(rawLabel);
|
||||||
|
if (isNxt)
|
||||||
|
{
|
||||||
|
if (!string.Equals(rawLabel, NxtAllowedLabel, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("NXT는 1열판기본_현재가만 지원합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return "N5001";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawLabel.StartsWith("1열판기본_", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "5001";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(rawLabel, "1열판상세_시가", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "5006";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawLabel.StartsWith("1열판상세_", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "5011";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawLabel.StartsWith("수익률그래프_", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "5086";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawLabel.StartsWith("캔들그래프", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var split = rawLabel.LastIndexOf('_');
|
||||||
|
var period = split >= 0 ? rawLabel[(split + 1)..] : string.Empty;
|
||||||
|
return period switch
|
||||||
|
{
|
||||||
|
"일봉" => "8035",
|
||||||
|
"5일" => "8061",
|
||||||
|
"20일" => "8040",
|
||||||
|
"60일" => "8046",
|
||||||
|
"120일" => "8051",
|
||||||
|
"240일" => "8056",
|
||||||
|
_ => throw UnknownCut(rawLabel)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawLabel switch
|
||||||
|
{
|
||||||
|
"호가창_표그래프" => "8003",
|
||||||
|
"거래원_표그래프" => "5037",
|
||||||
|
_ => throw UnknownCut(rawLabel)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LegacyOperatorPlaylistRow CreatePlaylistRow(
|
||||||
|
string rowId,
|
||||||
|
string stockName,
|
||||||
|
LegacyStockIdentity? identity,
|
||||||
|
LegacyCutCatalogRow cut,
|
||||||
|
int fadeDuration = 6)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(rowId);
|
||||||
|
ArgumentNullException.ThrowIfNull(stockName);
|
||||||
|
ArgumentNullException.ThrowIfNull(cut);
|
||||||
|
if (cut.IsSeparator || string.IsNullOrEmpty(cut.RawLabel))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("A separator cannot become a playlist row.", nameof(cut));
|
||||||
|
}
|
||||||
|
|
||||||
|
var split = cut.RawLabel.IndexOf('_');
|
||||||
|
if (split <= 0 || split == cut.RawLabel.Length - 1)
|
||||||
|
{
|
||||||
|
throw UnknownCut(cut.RawLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LegacyOperatorPlaylistRow(
|
||||||
|
rowId,
|
||||||
|
true,
|
||||||
|
identity?.MarketText ?? string.Empty,
|
||||||
|
stockName,
|
||||||
|
cut.RawLabel[..split],
|
||||||
|
cut.RawLabel[(split + 1)..],
|
||||||
|
"1/1",
|
||||||
|
identity?.StockCode ?? string.Empty,
|
||||||
|
cut.RawLabel,
|
||||||
|
fadeDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<LegacyCutCatalogRow> CreateRows()
|
||||||
|
{
|
||||||
|
var displayOrdinal = 0;
|
||||||
|
var rows = new LegacyCutCatalogRow[RuntimeRows.Length];
|
||||||
|
for (var index = 0; index < RuntimeRows.Length; index++)
|
||||||
|
{
|
||||||
|
var label = RuntimeRows[index];
|
||||||
|
var separator = label is null;
|
||||||
|
rows[index] = new LegacyCutCatalogRow(
|
||||||
|
index,
|
||||||
|
separator ? null : ++displayOrdinal,
|
||||||
|
label ?? string.Empty,
|
||||||
|
separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.AsReadOnly(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InvalidOperationException UnknownCut(string label) =>
|
||||||
|
new($"The legacy stock cut is unknown: {label}");
|
||||||
|
}
|
||||||
77
src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockModels.cs
Normal file
77
src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockModels.cs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||||
|
|
||||||
|
public enum LegacyDomesticStockMarket
|
||||||
|
{
|
||||||
|
Kospi,
|
||||||
|
Kosdaq,
|
||||||
|
NxtKospi,
|
||||||
|
NxtKosdaq
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record LegacyStockIdentity(
|
||||||
|
LegacyDomesticStockMarket Market,
|
||||||
|
string DisplayName,
|
||||||
|
string StockCode)
|
||||||
|
{
|
||||||
|
public bool IsNxt => Market is
|
||||||
|
LegacyDomesticStockMarket.NxtKospi or LegacyDomesticStockMarket.NxtKosdaq;
|
||||||
|
|
||||||
|
public string MarketText => Market switch
|
||||||
|
{
|
||||||
|
LegacyDomesticStockMarket.Kospi => "코스피",
|
||||||
|
LegacyDomesticStockMarket.Kosdaq => "코스닥",
|
||||||
|
LegacyDomesticStockMarket.NxtKospi => "코스피_NXT",
|
||||||
|
LegacyDomesticStockMarket.NxtKosdaq => "코스닥_NXT",
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LegacyStockSearchData
|
||||||
|
{
|
||||||
|
private readonly LegacyStockIdentity[] _masterCatalog;
|
||||||
|
|
||||||
|
public LegacyStockSearchData(
|
||||||
|
string query,
|
||||||
|
IEnumerable<string> displayNames,
|
||||||
|
IEnumerable<LegacyStockIdentity> masterCatalog)
|
||||||
|
{
|
||||||
|
Query = query ?? throw new ArgumentNullException(nameof(query));
|
||||||
|
ArgumentNullException.ThrowIfNull(displayNames);
|
||||||
|
ArgumentNullException.ThrowIfNull(masterCatalog);
|
||||||
|
|
||||||
|
DisplayNames = Array.AsReadOnly(displayNames.ToArray());
|
||||||
|
_masterCatalog = masterCatalog.ToArray();
|
||||||
|
MasterCatalog = Array.AsReadOnly(_masterCatalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Query { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<string> DisplayNames { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<LegacyStockIdentity> MasterCatalog { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MainForm.jongmokCode scanned all four cached master tables without breaking.
|
||||||
|
/// Keeping the final match therefore reproduces its duplicate-name behaviour.
|
||||||
|
/// </summary>
|
||||||
|
public LegacyStockIdentity? ResolveLastByDisplayName(string displayName)
|
||||||
|
{
|
||||||
|
LegacyStockIdentity? match = null;
|
||||||
|
foreach (var item in _masterCatalog)
|
||||||
|
{
|
||||||
|
if (string.Equals(item.DisplayName, displayName, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
match = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ILegacyStockLookup
|
||||||
|
{
|
||||||
|
Task<LegacyStockSearchData> SearchAsync(
|
||||||
|
string query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<RootNamespace>MBN_STOCK_WEBVIEW.LegacyApplication</RootNamespace>
|
||||||
|
<AssemblyName>MBN_STOCK_WEBVIEW.LegacyApplication</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
299
src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs
Normal file
299
src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||||
|
|
||||||
|
namespace MBN_STOCK_WEBVIEW.LegacyBridge;
|
||||||
|
|
||||||
|
public abstract record LegacyUiIntent;
|
||||||
|
|
||||||
|
public sealed record LegacyReadyIntent : LegacyUiIntent;
|
||||||
|
|
||||||
|
public sealed record LegacySearchStocksIntent(string Text, LegacySearchTrigger Trigger)
|
||||||
|
: LegacyUiIntent;
|
||||||
|
|
||||||
|
public sealed record LegacySelectStockIntent(int ResultIndex) : LegacyUiIntent;
|
||||||
|
|
||||||
|
public sealed record LegacyCutPointerDownIntent(
|
||||||
|
int PhysicalIndex,
|
||||||
|
bool Control,
|
||||||
|
bool Shift,
|
||||||
|
int X,
|
||||||
|
int Y,
|
||||||
|
long TimestampMilliseconds) : LegacyUiIntent;
|
||||||
|
|
||||||
|
public sealed record LegacyDropSelectedCutsIntent : LegacyUiIntent;
|
||||||
|
|
||||||
|
public sealed record LegacySetPlaylistEnabledIntent(string RowId, bool Enabled)
|
||||||
|
: LegacyUiIntent;
|
||||||
|
|
||||||
|
public sealed record LegacyDismissDialogIntent : LegacyUiIntent;
|
||||||
|
|
||||||
|
public static class LegacyBridgeProtocol
|
||||||
|
{
|
||||||
|
public const string TrustedHost = "legacy-parity.mbn.local";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions SerializerOptions = CreateSerializerOptions();
|
||||||
|
|
||||||
|
public static bool IsTrustedSource(string? source)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(source, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
string.Equals(uri.Host, TrustedHost, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
uri.IsDefaultPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryParseIntent(
|
||||||
|
string json,
|
||||||
|
out LegacyUiIntent? intent,
|
||||||
|
out string? error)
|
||||||
|
{
|
||||||
|
intent = null;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(json, new JsonDocumentOptions
|
||||||
|
{
|
||||||
|
AllowTrailingCommas = false,
|
||||||
|
CommentHandling = JsonCommentHandling.Disallow,
|
||||||
|
MaxDepth = 8
|
||||||
|
});
|
||||||
|
var root = document.RootElement;
|
||||||
|
if (root.ValueKind != JsonValueKind.Object ||
|
||||||
|
!HasOnlyProperties(root, "type", "payload") ||
|
||||||
|
!TryString(root, "type", 64, out var type) ||
|
||||||
|
!root.TryGetProperty("payload", out var payload) ||
|
||||||
|
payload.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
error = "올바르지 않은 UI 요청입니다.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
intent = type switch
|
||||||
|
{
|
||||||
|
"ready" => ParseReady(payload),
|
||||||
|
"search-stocks" => ParseSearch(payload),
|
||||||
|
"select-stock" => ParseStockSelection(payload),
|
||||||
|
"cut-pointer-down" => ParseCutPointer(payload),
|
||||||
|
"drop-selected-cuts" => ParseDrop(payload),
|
||||||
|
"set-playlist-enabled" => ParseEnabled(payload),
|
||||||
|
"dismiss-dialog" => ParseDismiss(payload),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (intent is null)
|
||||||
|
{
|
||||||
|
error = "지원하지 않는 UI 요청입니다.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
error = "UI 요청 JSON을 읽을 수 없습니다.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string SerializeState(
|
||||||
|
LegacyOperatorSnapshot snapshot,
|
||||||
|
bool isBusy = false)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshot);
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
snapshot.Revision,
|
||||||
|
snapshot.SearchText,
|
||||||
|
snapshot.SearchResults,
|
||||||
|
snapshot.SelectedStockIndex,
|
||||||
|
snapshot.CutRows,
|
||||||
|
playlist = snapshot.Playlist.Select(row => new
|
||||||
|
{
|
||||||
|
row.RowId,
|
||||||
|
row.IsEnabled,
|
||||||
|
row.MarketText,
|
||||||
|
row.StockName,
|
||||||
|
row.GraphicType,
|
||||||
|
row.Subtype,
|
||||||
|
row.PageText
|
||||||
|
}),
|
||||||
|
snapshot.Dialog,
|
||||||
|
snapshot.StatusMessage,
|
||||||
|
snapshot.StatusKind,
|
||||||
|
isBusy
|
||||||
|
};
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(new { type = "state", payload }, SerializerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LegacyUiIntent? ParseReady(JsonElement payload) =>
|
||||||
|
HasOnlyProperties(payload) ? new LegacyReadyIntent() : null;
|
||||||
|
|
||||||
|
private static LegacyUiIntent? ParseSearch(JsonElement payload)
|
||||||
|
{
|
||||||
|
if (!HasOnlyProperties(payload, "text", "trigger") ||
|
||||||
|
!TryString(
|
||||||
|
payload,
|
||||||
|
"text",
|
||||||
|
LegacyParityStockSearchService.LegacyTextBoxMaximumLength,
|
||||||
|
out var text) ||
|
||||||
|
!TryString(payload, "trigger", 16, out var triggerText))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trigger = triggerText switch
|
||||||
|
{
|
||||||
|
"button" => LegacySearchTrigger.Button,
|
||||||
|
"enter" => LegacySearchTrigger.Enter,
|
||||||
|
_ => (LegacySearchTrigger?)null
|
||||||
|
};
|
||||||
|
return trigger.HasValue ? new LegacySearchStocksIntent(text, trigger.Value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LegacyUiIntent? ParseStockSelection(JsonElement payload) =>
|
||||||
|
HasOnlyProperties(payload, "resultIndex") &&
|
||||||
|
TryInt32(payload, "resultIndex", 0, int.MaxValue, out var resultIndex)
|
||||||
|
? new LegacySelectStockIntent(resultIndex)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static LegacyUiIntent? ParseCutPointer(JsonElement payload)
|
||||||
|
{
|
||||||
|
if (!HasOnlyProperties(
|
||||||
|
payload,
|
||||||
|
"physicalIndex",
|
||||||
|
"control",
|
||||||
|
"shift",
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"timestampMilliseconds") ||
|
||||||
|
!TryInt32(payload, "physicalIndex", 0, 32, out var physicalIndex) ||
|
||||||
|
!TryBoolean(payload, "control", out var control) ||
|
||||||
|
!TryBoolean(payload, "shift", out var shift) ||
|
||||||
|
!TryInt32(payload, "x", 0, 16_384, out var x) ||
|
||||||
|
!TryInt32(payload, "y", 0, 16_384, out var y) ||
|
||||||
|
!TryInt64(payload, "timestampMilliseconds", 0, long.MaxValue, out var timestamp))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LegacyCutPointerDownIntent(
|
||||||
|
physicalIndex,
|
||||||
|
control,
|
||||||
|
shift,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LegacyUiIntent? ParseDrop(JsonElement payload) =>
|
||||||
|
HasOnlyProperties(payload) ? new LegacyDropSelectedCutsIntent() : null;
|
||||||
|
|
||||||
|
private static LegacyUiIntent? ParseEnabled(JsonElement payload)
|
||||||
|
{
|
||||||
|
if (!HasOnlyProperties(payload, "rowId", "enabled") ||
|
||||||
|
!TryString(payload, "rowId", 128, out var rowId) ||
|
||||||
|
!IsSafeIdentifier(rowId) ||
|
||||||
|
!TryBoolean(payload, "enabled", out var enabled))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LegacySetPlaylistEnabledIntent(rowId, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LegacyUiIntent? ParseDismiss(JsonElement payload) =>
|
||||||
|
HasOnlyProperties(payload) ? new LegacyDismissDialogIntent() : null;
|
||||||
|
|
||||||
|
private static bool HasOnlyProperties(JsonElement element, params string[] names)
|
||||||
|
{
|
||||||
|
var expected = new HashSet<string>(names, StringComparer.Ordinal);
|
||||||
|
var count = 0;
|
||||||
|
foreach (var property in element.EnumerateObject())
|
||||||
|
{
|
||||||
|
count++;
|
||||||
|
if (!expected.Contains(property.Name))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count == expected.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryString(
|
||||||
|
JsonElement element,
|
||||||
|
string propertyName,
|
||||||
|
int maximumLength,
|
||||||
|
out string value)
|
||||||
|
{
|
||||||
|
value = string.Empty;
|
||||||
|
return element.TryGetProperty(propertyName, out var property) &&
|
||||||
|
property.ValueKind == JsonValueKind.String &&
|
||||||
|
(value = property.GetString() ?? string.Empty).Length <= maximumLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryBoolean(JsonElement element, string name, out bool value)
|
||||||
|
{
|
||||||
|
value = false;
|
||||||
|
if (!element.TryGetProperty(name, out var property) ||
|
||||||
|
(property.ValueKind != JsonValueKind.True && property.ValueKind != JsonValueKind.False))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = property.GetBoolean();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryInt32(
|
||||||
|
JsonElement element,
|
||||||
|
string name,
|
||||||
|
int minimum,
|
||||||
|
int maximum,
|
||||||
|
out int value)
|
||||||
|
{
|
||||||
|
value = 0;
|
||||||
|
return element.TryGetProperty(name, out var property) &&
|
||||||
|
property.ValueKind == JsonValueKind.Number &&
|
||||||
|
property.TryGetInt32(out value) &&
|
||||||
|
value >= minimum && value <= maximum;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryInt64(
|
||||||
|
JsonElement element,
|
||||||
|
string name,
|
||||||
|
long minimum,
|
||||||
|
long maximum,
|
||||||
|
out long value)
|
||||||
|
{
|
||||||
|
value = 0;
|
||||||
|
return element.TryGetProperty(name, out var property) &&
|
||||||
|
property.ValueKind == JsonValueKind.Number &&
|
||||||
|
property.TryGetInt64(out value) &&
|
||||||
|
value >= minimum && value <= maximum;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSafeIdentifier(string value)
|
||||||
|
{
|
||||||
|
if (value.Length == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.All(character =>
|
||||||
|
char.IsAsciiLetterOrDigit(character) || character is '-' or '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonSerializerOptions CreateSerializerOptions()
|
||||||
|
{
|
||||||
|
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||||
|
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<RootNamespace>MBN_STOCK_WEBVIEW.LegacyBridge</RootNamespace>
|
||||||
|
<AssemblyName>MBN_STOCK_WEBVIEW.LegacyBridge</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.LegacyApplication\MBN_STOCK_WEBVIEW.LegacyApplication.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
14
src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml
Normal file
14
src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<Application
|
||||||
|
x:Class="MBN_STOCK_WEBVIEW.LegacyParityApp.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">
|
||||||
|
<Application.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
48
src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs
Normal file
48
src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
using Microsoft.Windows.AppLifecycle;
|
||||||
|
|
||||||
|
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
|
||||||
|
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
private const string MainInstanceKey = "Wickedness.MBNStockWebView.LegacyParity.Main";
|
||||||
|
|
||||||
|
private Window? _window;
|
||||||
|
private AppInstance? _mainInstance;
|
||||||
|
|
||||||
|
public App()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async void OnLaunched(LaunchActivatedEventArgs args)
|
||||||
|
{
|
||||||
|
var registeredInstance = AppInstance.FindOrRegisterForKey(MainInstanceKey);
|
||||||
|
if (!registeredInstance.IsCurrent)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var activation = AppInstance.GetCurrent().GetActivatedEventArgs();
|
||||||
|
if (activation is not null)
|
||||||
|
{
|
||||||
|
await registeredInstance.RedirectActivationToAsync(activation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_mainInstance = registeredInstance;
|
||||||
|
_mainInstance.Activated += OnMainInstanceActivated;
|
||||||
|
_window = new MainWindow();
|
||||||
|
_window.Activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMainInstanceActivated(object? sender, AppActivationArguments args)
|
||||||
|
{
|
||||||
|
_window?.DispatcherQueue.TryEnqueue(_window.Activate);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/MBN_STOCK_WEBVIEW.LegacyParityApp/Imports.cs
Normal file
2
src/MBN_STOCK_WEBVIEW.LegacyParityApp/Imports.cs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
global using Microsoft.UI.Xaml;
|
||||||
|
global using Microsoft.UI.Xaml.Controls;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||||
|
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<UseWinUI>true</UseWinUI>
|
||||||
|
<EnableMsixTooling>true</EnableMsixTooling>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>MBN_STOCK_WEBVIEW.LegacyParityApp</RootNamespace>
|
||||||
|
<AssemblyName>MBN_STOCK_WEBVIEW.LegacyParityApp</AssemblyName>
|
||||||
|
<UseRidGraph>true</UseRidGraph>
|
||||||
|
<Platforms>x64</Platforms>
|
||||||
|
<PlatformTarget>x64</PlatformTarget>
|
||||||
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
|
<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
|
<PublishReadyToRun>false</PublishReadyToRun>
|
||||||
|
<ApplicationDisplayVersion>0.1.0</ApplicationDisplayVersion>
|
||||||
|
<ApplicationVersion>1</ApplicationVersion>
|
||||||
|
<Version>0.1.0</Version>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\..\Assets\LockScreenLogo.scale-200.png" Link="Assets\LockScreenLogo.scale-200.png" />
|
||||||
|
<Content Include="..\..\Assets\SplashScreen.scale-200.png" Link="Assets\SplashScreen.scale-200.png" />
|
||||||
|
<Content Include="..\..\Assets\Square150x150Logo.scale-200.png" Link="Assets\Square150x150Logo.scale-200.png" />
|
||||||
|
<Content Include="..\..\Assets\Square44x44Logo.scale-200.png" Link="Assets\Square44x44Logo.scale-200.png" />
|
||||||
|
<Content Include="..\..\Assets\Square44x44Logo.targetsize-24_altform-unplated.png" Link="Assets\Square44x44Logo.targetsize-24_altform-unplated.png" />
|
||||||
|
<Content Include="..\..\Assets\StoreLogo.png" Link="Assets\StoreLogo.png" />
|
||||||
|
<Content Include="..\..\Assets\Wide310x150Logo.scale-200.png" Link="Assets\Wide310x150Logo.scale-200.png" />
|
||||||
|
<Content Include="Web\**\*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
</Content>
|
||||||
|
<Manifest Include="$(ApplicationManifest)" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" />
|
||||||
|
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3967.48" />
|
||||||
|
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.7705" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
|
||||||
|
<ProjectCapability Include="Msix" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.LegacyApplication\MBN_STOCK_WEBVIEW.LegacyApplication.csproj" />
|
||||||
|
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.LegacyBridge\MBN_STOCK_WEBVIEW.LegacyBridge.csproj" />
|
||||||
|
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.Infrastructure\MBN_STOCK_WEBVIEW.Infrastructure.csproj" />
|
||||||
|
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.Playout\MBN_STOCK_WEBVIEW.Playout.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
|
||||||
|
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
30
src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml
Normal file
30
src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<Window
|
||||||
|
x:Class="MBN_STOCK_WEBVIEW.LegacyParityApp.MainWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="V-Stock 증권정보송출시스템 for 매일경제TV (26.03.26)">
|
||||||
|
<Grid x:Name="Root" Background="#FFF0F0F0">
|
||||||
|
<WebView2 x:Name="Browser" />
|
||||||
|
|
||||||
|
<Grid x:Name="LoadingOverlay" Background="#FFF0F0F0">
|
||||||
|
<StackPanel
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Spacing="12">
|
||||||
|
<ProgressRing Width="32" Height="32" IsActive="True" />
|
||||||
|
<TextBlock Text="원본 호환 화면을 준비하고 있습니다." />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<InfoBar
|
||||||
|
x:Name="ErrorBar"
|
||||||
|
Margin="12"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
IsClosable="True"
|
||||||
|
IsOpen="False"
|
||||||
|
Severity="Error"
|
||||||
|
Title="화면을 초기화하지 못했습니다." />
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
353
src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs
Normal file
353
src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||||
|
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||||
|
using MBN_STOCK_WEBVIEW.LegacyBridge;
|
||||||
|
using Microsoft.UI.Windowing;
|
||||||
|
using Microsoft.Web.WebView2.Core;
|
||||||
|
using MMoneyCoderSharp.Data;
|
||||||
|
using Windows.Graphics;
|
||||||
|
using WinRT.Interop;
|
||||||
|
|
||||||
|
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
|
||||||
|
|
||||||
|
public sealed partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
private readonly CancellationTokenSource _lifetimeCancellation = new();
|
||||||
|
private readonly SemaphoreSlim _intentGate = new(1, 1);
|
||||||
|
private readonly LegacyOperatorController _controller;
|
||||||
|
private DatabaseRuntime? _databaseRuntime;
|
||||||
|
private bool _closing;
|
||||||
|
private bool _intentBusy;
|
||||||
|
private bool _webViewReady;
|
||||||
|
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
ILegacyStockLookup stockLookup;
|
||||||
|
string? initializationError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_databaseRuntime = DatabaseRuntime.CreateDefault();
|
||||||
|
DataQueryExecutor.Configure(_databaseRuntime.Executor);
|
||||||
|
stockLookup = new LegacyParityStockSearchService(_databaseRuntime.Executor);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
initializationError =
|
||||||
|
"데이터베이스가 설정되지 않았습니다. 로컬 설정을 확인하세요.";
|
||||||
|
stockLookup = new UnavailableStockLookup(initializationError);
|
||||||
|
DataQueryExecutor.Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
_controller = new LegacyOperatorController(stockLookup);
|
||||||
|
if (initializationError is not null)
|
||||||
|
{
|
||||||
|
_controller.ReportError(initializationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
Root.Loaded += OnRootLoaded;
|
||||||
|
Closed += OnClosed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnRootLoaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Root.Loaded -= OnRootLoaded;
|
||||||
|
if (_closing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigureWindow();
|
||||||
|
await InitializeWebViewAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ConfigureWindow()
|
||||||
|
{
|
||||||
|
var windowHandle = WindowNative.GetWindowHandle(this);
|
||||||
|
var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle);
|
||||||
|
var appWindow = AppWindow.GetFromWindowId(windowId);
|
||||||
|
appWindow.Title = "V-Stock 증권정보송출시스템 for 매일경제TV (26.03.26)";
|
||||||
|
|
||||||
|
if (appWindow.Presenter is OverlappedPresenter presenter)
|
||||||
|
{
|
||||||
|
presenter.IsMaximizable = false;
|
||||||
|
presenter.IsResizable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WinForms stored 1905 x 1015 as ClientSize. AppWindow.Resize would include
|
||||||
|
// the caption and borders and silently make the WebView viewport smaller.
|
||||||
|
appWindow.ResizeClient(new SizeInt32(1905, 1015));
|
||||||
|
var displayArea = DisplayArea.GetFromWindowId(windowId, DisplayAreaFallback.Primary);
|
||||||
|
appWindow.Move(new PointInt32(displayArea.WorkArea.X, displayArea.WorkArea.Y));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InitializeWebViewAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var webRoot = Path.Combine(AppContext.BaseDirectory, "Web");
|
||||||
|
var startPage = Path.Combine(webRoot, "index.html");
|
||||||
|
if (!File.Exists(startPage))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException("The parity Web UI was not found.", startPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Browser.EnsureCoreWebView2Async();
|
||||||
|
if (_closing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var coreWebView = Browser.CoreWebView2 ??
|
||||||
|
throw new InvalidOperationException("WebView2 was not initialized.");
|
||||||
|
coreWebView.SetVirtualHostNameToFolderMapping(
|
||||||
|
LegacyBridgeProtocol.TrustedHost,
|
||||||
|
webRoot,
|
||||||
|
CoreWebView2HostResourceAccessKind.DenyCors);
|
||||||
|
coreWebView.Settings.AreDevToolsEnabled =
|
||||||
|
System.Diagnostics.Debugger.IsAttached;
|
||||||
|
coreWebView.Settings.AreDefaultContextMenusEnabled = false;
|
||||||
|
coreWebView.Settings.IsStatusBarEnabled = false;
|
||||||
|
coreWebView.NavigationStarting += OnNavigationStarting;
|
||||||
|
coreWebView.NavigationCompleted += OnNavigationCompleted;
|
||||||
|
coreWebView.NewWindowRequested += OnNewWindowRequested;
|
||||||
|
coreWebView.WebMessageReceived += OnWebMessageReceived;
|
||||||
|
coreWebView.ProcessFailed += OnProcessFailed;
|
||||||
|
|
||||||
|
_webViewReady = true;
|
||||||
|
Browser.Source = new Uri(
|
||||||
|
$"https://{LegacyBridgeProtocol.TrustedHost}/index.html");
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
if (!_closing)
|
||||||
|
{
|
||||||
|
ShowError("WebView2 초기화에 실패했습니다.", exception.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs args)
|
||||||
|
{
|
||||||
|
if (!LegacyBridgeProtocol.IsTrustedSource(args.Uri) &&
|
||||||
|
!string.Equals(args.Uri, "about:blank", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
args.Cancel = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs args)
|
||||||
|
{
|
||||||
|
if (_closing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadingOverlay.Visibility = Visibility.Collapsed;
|
||||||
|
if (!args.IsSuccess)
|
||||||
|
{
|
||||||
|
ShowError("원본 호환 화면을 불러오지 못했습니다.", args.WebErrorStatus.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnNewWindowRequested(
|
||||||
|
object? sender,
|
||||||
|
CoreWebView2NewWindowRequestedEventArgs args)
|
||||||
|
{
|
||||||
|
args.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnWebMessageReceived(
|
||||||
|
object? sender,
|
||||||
|
CoreWebView2WebMessageReceivedEventArgs args)
|
||||||
|
{
|
||||||
|
if (_closing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LegacyUiIntent? intent = null;
|
||||||
|
string? parseError = null;
|
||||||
|
if (!LegacyBridgeProtocol.IsTrustedSource(args.Source) ||
|
||||||
|
!LegacyBridgeProtocol.TryParseIntent(
|
||||||
|
args.WebMessageAsJson,
|
||||||
|
out intent,
|
||||||
|
out parseError) ||
|
||||||
|
intent is null)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(parseError))
|
||||||
|
{
|
||||||
|
PostState(_controller.ReportError(parseError));
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = ProcessIntentAsync(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessIntentAsync(LegacyUiIntent intent)
|
||||||
|
{
|
||||||
|
var gateEntered = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// MainForm performed database search synchronously. The busy snapshot
|
||||||
|
// freezes the Web controls, while this zero-timeout gate prevents an
|
||||||
|
// already-posted duplicate from growing an unbounded DB request queue.
|
||||||
|
gateEntered = await _intentGate.WaitAsync(
|
||||||
|
0,
|
||||||
|
_lifetimeCancellation.Token);
|
||||||
|
if (!gateEntered)
|
||||||
|
{
|
||||||
|
PostState(_controller.Current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_closing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent is LegacySearchStocksIntent searchIntent &&
|
||||||
|
!(searchIntent.Trigger == LegacySearchTrigger.Button &&
|
||||||
|
searchIntent.Text.Length == 0))
|
||||||
|
{
|
||||||
|
_intentBusy = true;
|
||||||
|
PostState(_controller.Current);
|
||||||
|
}
|
||||||
|
|
||||||
|
var state = await HandleIntentAsync(intent, _lifetimeCancellation.Token);
|
||||||
|
if (!_closing)
|
||||||
|
{
|
||||||
|
_intentBusy = false;
|
||||||
|
PostState(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch when (_closing)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_intentBusy = false;
|
||||||
|
PostState(_controller.ReportError(
|
||||||
|
"요청을 처리하지 못했습니다. 데이터베이스 연결 상태를 확인하세요."));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (_closing)
|
||||||
|
{
|
||||||
|
_intentBusy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gateEntered)
|
||||||
|
{
|
||||||
|
_intentGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<LegacyOperatorSnapshot> HandleIntentAsync(
|
||||||
|
LegacyUiIntent intent,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return intent switch
|
||||||
|
{
|
||||||
|
LegacyReadyIntent => _controller.Current,
|
||||||
|
LegacySearchStocksIntent search => await _controller.SearchStocksAsync(
|
||||||
|
search.Text,
|
||||||
|
search.Trigger,
|
||||||
|
cancellationToken),
|
||||||
|
LegacySelectStockIntent selection =>
|
||||||
|
_controller.SelectStock(selection.ResultIndex),
|
||||||
|
LegacyCutPointerDownIntent pointer => _controller.CutPointerDown(
|
||||||
|
pointer.PhysicalIndex,
|
||||||
|
pointer.Control,
|
||||||
|
pointer.Shift,
|
||||||
|
pointer.X,
|
||||||
|
pointer.Y,
|
||||||
|
pointer.TimestampMilliseconds),
|
||||||
|
LegacyDropSelectedCutsIntent => _controller.DropSelectedCutsOnPlaylist(),
|
||||||
|
LegacySetPlaylistEnabledIntent enabled =>
|
||||||
|
_controller.SetPlaylistRowEnabled(enabled.RowId, enabled.Enabled),
|
||||||
|
LegacyDismissDialogIntent => _controller.DismissDialog(),
|
||||||
|
_ => _controller.ReportError("지원하지 않는 화면 요청입니다.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PostState(LegacyOperatorSnapshot snapshot)
|
||||||
|
{
|
||||||
|
if (!_closing && _webViewReady && Browser.CoreWebView2 is not null)
|
||||||
|
{
|
||||||
|
Browser.CoreWebView2.PostWebMessageAsJson(
|
||||||
|
LegacyBridgeProtocol.SerializeState(snapshot, _intentBusy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnProcessFailed(object? sender, CoreWebView2ProcessFailedEventArgs args)
|
||||||
|
{
|
||||||
|
if (!_closing)
|
||||||
|
{
|
||||||
|
ShowError("WebView2 프로세스가 중단되었습니다.", args.ProcessFailedKind.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowError(string title, string message)
|
||||||
|
{
|
||||||
|
if (_closing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorBar.Title = title;
|
||||||
|
ErrorBar.Message = message;
|
||||||
|
ErrorBar.IsOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClosed(object sender, WindowEventArgs args)
|
||||||
|
{
|
||||||
|
if (_closing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_closing = true;
|
||||||
|
Root.Loaded -= OnRootLoaded;
|
||||||
|
Closed -= OnClosed;
|
||||||
|
_lifetimeCancellation.Cancel();
|
||||||
|
DataQueryExecutor.Reset();
|
||||||
|
|
||||||
|
if (Browser.CoreWebView2 is not null)
|
||||||
|
{
|
||||||
|
Browser.CoreWebView2.NavigationStarting -= OnNavigationStarting;
|
||||||
|
Browser.CoreWebView2.NavigationCompleted -= OnNavigationCompleted;
|
||||||
|
Browser.CoreWebView2.NewWindowRequested -= OnNewWindowRequested;
|
||||||
|
Browser.CoreWebView2.WebMessageReceived -= OnWebMessageReceived;
|
||||||
|
Browser.CoreWebView2.ProcessFailed -= OnProcessFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
_webViewReady = false;
|
||||||
|
|
||||||
|
// These process-lifetime guards deliberately remain undisposed. An in-flight
|
||||||
|
// provider call may resume after Closed and must still be able to observe
|
||||||
|
// cancellation and release the gate without racing disposed objects.
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class UnavailableStockLookup : ILegacyStockLookup
|
||||||
|
{
|
||||||
|
private readonly string _message;
|
||||||
|
|
||||||
|
public UnavailableStockLookup(string message)
|
||||||
|
{
|
||||||
|
_message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<LegacyStockSearchData> SearchAsync(
|
||||||
|
string query,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromException<LegacyStockSearchData>(
|
||||||
|
new InvalidOperationException(_message));
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/MBN_STOCK_WEBVIEW.LegacyParityApp/Package.appxmanifest
Normal file
43
src/MBN_STOCK_WEBVIEW.LegacyParityApp/Package.appxmanifest
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Package
|
||||||
|
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||||
|
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
|
||||||
|
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||||
|
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||||
|
IgnorableNamespaces="uap rescap">
|
||||||
|
<Identity
|
||||||
|
Name="Wickedness.MBNStockWebView.LegacyParity"
|
||||||
|
Publisher="CN=Comtrophy"
|
||||||
|
Version="0.1.0.0" />
|
||||||
|
<mp:PhoneIdentity PhoneProductId="8f62f17b-a9a4-42cc-a696-a1987b5588f1" PhonePublisherId="00000000-0000-0000-0000-000000000000" />
|
||||||
|
<Properties>
|
||||||
|
<DisplayName>MBN Stock WebView (Legacy Parity)</DisplayName>
|
||||||
|
<PublisherDisplayName>Wickedness</PublisherDisplayName>
|
||||||
|
<Logo>Assets\StoreLogo.png</Logo>
|
||||||
|
</Properties>
|
||||||
|
<Dependencies>
|
||||||
|
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
|
||||||
|
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
|
||||||
|
</Dependencies>
|
||||||
|
<Resources>
|
||||||
|
<Resource Language="x-generate" />
|
||||||
|
</Resources>
|
||||||
|
<Applications>
|
||||||
|
<Application Id="LegacyParityApp" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
|
||||||
|
<uap:VisualElements
|
||||||
|
DisplayName="MBN Stock WebView (Legacy Parity)"
|
||||||
|
Description="매일경제TV 증권정보송출시스템 원본 호환 이관판"
|
||||||
|
BackgroundColor="transparent"
|
||||||
|
Square150x150Logo="Assets\Square150x150Logo.png"
|
||||||
|
Square44x44Logo="Assets\Square44x44Logo.png">
|
||||||
|
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
|
||||||
|
<uap:SplashScreen Image="Assets\SplashScreen.png" />
|
||||||
|
</uap:VisualElements>
|
||||||
|
</Application>
|
||||||
|
</Applications>
|
||||||
|
<Capabilities>
|
||||||
|
<Capability Name="internetClient" />
|
||||||
|
<Capability Name="privateNetworkClientServer" />
|
||||||
|
<rescap:Capability Name="runFullTrust" />
|
||||||
|
</Capabilities>
|
||||||
|
</Package>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"MBN_STOCK_WEBVIEW.LegacyParityApp (Package)": {
|
||||||
|
"commandName": "MsixPackage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
232
src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js
Normal file
232
src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const webview = window.chrome && window.chrome.webview;
|
||||||
|
const searchInput = document.getElementById("stock-search");
|
||||||
|
const searchButton = document.getElementById("stock-search-button");
|
||||||
|
const stockResults = document.getElementById("stock-results");
|
||||||
|
const cutList = document.getElementById("cut-list");
|
||||||
|
const playlistRows = document.getElementById("playlist-rows");
|
||||||
|
const playlistDropZone = document.getElementById("playlist-drop-zone");
|
||||||
|
const status = document.getElementById("operator-status");
|
||||||
|
const dialog = document.getElementById("legacy-dialog");
|
||||||
|
const dialogMessage = document.getElementById("legacy-dialog-message");
|
||||||
|
const dialogOk = document.getElementById("legacy-dialog-ok");
|
||||||
|
let uiBusy = false;
|
||||||
|
|
||||||
|
function send(type, payload) {
|
||||||
|
if (webview && !uiBusy) webview.postMessage({ type: type, payload: payload || {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStockResultElement(row) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.dataset.resultIndex = String(row.index);
|
||||||
|
button.setAttribute("role", "option");
|
||||||
|
button.addEventListener("click", function () {
|
||||||
|
send("select-stock", { resultIndex: Number(button.dataset.resultIndex) });
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStockResults(state) {
|
||||||
|
state.searchResults.forEach(function (row, position) {
|
||||||
|
let button = stockResults.children.item(position);
|
||||||
|
if (!button || button.dataset.resultIndex !== String(row.index)) {
|
||||||
|
button = createStockResultElement(row);
|
||||||
|
if (position < stockResults.children.length) {
|
||||||
|
stockResults.replaceChild(button, stockResults.children.item(position));
|
||||||
|
} else {
|
||||||
|
stockResults.appendChild(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button.className = "stock-result" +
|
||||||
|
(state.selectedStockIndex === row.index ? " selected" : "");
|
||||||
|
button.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false");
|
||||||
|
button.textContent = row.displayName;
|
||||||
|
button.setAttribute("aria-selected", state.selectedStockIndex === row.index ? "true" : "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
while (stockResults.children.length > state.searchResults.length) {
|
||||||
|
stockResults.lastElementChild.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerPosition(event) {
|
||||||
|
const bounds = cutList.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.round(event.clientX - bounds.left)),
|
||||||
|
y: Math.max(0, Math.round(event.clientY - bounds.top))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCutElement(row) {
|
||||||
|
const element = document.createElement("div");
|
||||||
|
element.setAttribute("role", "option");
|
||||||
|
element.dataset.physicalIndex = String(row.physicalIndex);
|
||||||
|
element.draggable = true;
|
||||||
|
|
||||||
|
const number = document.createElement("span");
|
||||||
|
number.className = "cut-number";
|
||||||
|
number.textContent = row.displayOrdinal === null ? "" : String(row.displayOrdinal);
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.textContent = row.rawLabel;
|
||||||
|
element.append(number, label);
|
||||||
|
|
||||||
|
element.addEventListener("pointerdown", function (event) {
|
||||||
|
const position = pointerPosition(event);
|
||||||
|
send("cut-pointer-down", {
|
||||||
|
physicalIndex: row.physicalIndex,
|
||||||
|
control: event.ctrlKey,
|
||||||
|
shift: event.shiftKey,
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
timestampMilliseconds: Math.max(0, Math.round(event.timeStamp))
|
||||||
|
});
|
||||||
|
});
|
||||||
|
element.addEventListener("dragstart", function (event) {
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData("text/plain", "legacy-selected-cuts");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCuts(state) {
|
||||||
|
state.cutRows.forEach(function (row, index) {
|
||||||
|
let element = cutList.children.item(index);
|
||||||
|
if (!element || element.dataset.physicalIndex !== String(row.physicalIndex)) {
|
||||||
|
element = createCutElement(row);
|
||||||
|
if (index < cutList.children.length) {
|
||||||
|
cutList.replaceChild(element, cutList.children.item(index));
|
||||||
|
} else {
|
||||||
|
cutList.appendChild(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
element.className = "cut-row" +
|
||||||
|
(row.isSelected ? " selected" : "") +
|
||||||
|
(row.isSeparator ? " separator" : "");
|
||||||
|
element.setAttribute("aria-selected", row.isSelected ? "true" : "false");
|
||||||
|
element.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false");
|
||||||
|
element.draggable = state.isBusy !== true;
|
||||||
|
});
|
||||||
|
|
||||||
|
while (cutList.children.length > state.cutRows.length) {
|
||||||
|
cutList.lastElementChild.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlaylistElement(row) {
|
||||||
|
const element = document.createElement("div");
|
||||||
|
element.className = "playlist-row playlist-data-row";
|
||||||
|
element.dataset.rowId = row.rowId;
|
||||||
|
|
||||||
|
const enabledCell = document.createElement("div");
|
||||||
|
enabledCell.className = "enabled-cell";
|
||||||
|
const enabled = document.createElement("input");
|
||||||
|
enabled.type = "checkbox";
|
||||||
|
enabled.addEventListener("change", function () {
|
||||||
|
send("set-playlist-enabled", {
|
||||||
|
rowId: element.dataset.rowId,
|
||||||
|
enabled: enabled.checked
|
||||||
|
});
|
||||||
|
});
|
||||||
|
enabledCell.appendChild(enabled);
|
||||||
|
element.appendChild(enabledCell);
|
||||||
|
|
||||||
|
for (let index = 0; index < 5; index += 1) {
|
||||||
|
element.appendChild(document.createElement("div"));
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlaylist(state) {
|
||||||
|
state.playlist.forEach(function (row, position) {
|
||||||
|
let element = playlistRows.children.item(position);
|
||||||
|
if (!element || element.dataset.rowId !== row.rowId) {
|
||||||
|
element = createPlaylistElement(row);
|
||||||
|
if (position < playlistRows.children.length) {
|
||||||
|
playlistRows.replaceChild(element, playlistRows.children.item(position));
|
||||||
|
} else {
|
||||||
|
playlistRows.appendChild(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = element.querySelector("input");
|
||||||
|
enabled.checked = row.isEnabled;
|
||||||
|
enabled.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false");
|
||||||
|
[row.marketText, row.stockName, row.graphicType, row.subtype, row.pageText]
|
||||||
|
.forEach(function (text, cellIndex) {
|
||||||
|
const cell = element.children.item(cellIndex + 1);
|
||||||
|
cell.textContent = text;
|
||||||
|
cell.title = text;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
while (playlistRows.children.length > state.playlist.length) {
|
||||||
|
playlistRows.lastElementChild.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDialog(state) {
|
||||||
|
if (state.dialog && state.dialog.message) {
|
||||||
|
dialogMessage.textContent = state.dialog.message;
|
||||||
|
dialog.hidden = false;
|
||||||
|
dialogOk.focus();
|
||||||
|
} else {
|
||||||
|
dialog.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(state) {
|
||||||
|
const isBusy = state.isBusy === true;
|
||||||
|
uiBusy = isBusy;
|
||||||
|
document.body.classList.toggle("busy", isBusy);
|
||||||
|
document.body.setAttribute("aria-busy", isBusy ? "true" : "false");
|
||||||
|
searchInput.readOnly = isBusy;
|
||||||
|
searchButton.setAttribute("aria-disabled", isBusy ? "true" : "false");
|
||||||
|
if (document.activeElement !== searchInput) searchInput.value = state.searchText || "";
|
||||||
|
renderStockResults(state);
|
||||||
|
renderCuts(state);
|
||||||
|
renderPlaylist(state);
|
||||||
|
renderDialog(state);
|
||||||
|
status.textContent = isBusy ? "종목을 검색하고 있습니다." : (state.statusMessage || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
searchButton.addEventListener("click", function () {
|
||||||
|
send("search-stocks", { text: searchInput.value, trigger: "button" });
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener("keypress", function (event) {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
send("search-stocks", { text: searchInput.value, trigger: "enter" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
playlistDropZone.addEventListener("dragover", function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
||||||
|
});
|
||||||
|
playlistDropZone.addEventListener("drop", function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
send("drop-selected-cuts", {});
|
||||||
|
});
|
||||||
|
dialogOk.addEventListener("click", function () {
|
||||||
|
send("dismiss-dialog", {});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (webview) {
|
||||||
|
webview.addEventListener("message", function (event) {
|
||||||
|
if (event.data && event.data.type === "state" && event.data.payload) {
|
||||||
|
render(event.data.payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
send("ready", {});
|
||||||
|
} else {
|
||||||
|
status.textContent = "WebView2 브리지를 사용할 수 없습니다.";
|
||||||
|
}
|
||||||
|
}());
|
||||||
114
src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html
Normal file
114
src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>V-Stock 증권정보송출시스템</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="operator-shell" aria-label="V-Stock 증권정보송출시스템">
|
||||||
|
<section class="left-panel" aria-label="종목 및 컷 선택">
|
||||||
|
<header class="brand-line">
|
||||||
|
<div class="brand"><span>매일경제TV</span><strong>VRi</strong></div>
|
||||||
|
<div class="connection-state" aria-label="Tornado2 연결 상태">미연결</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="search-line">
|
||||||
|
<label for="stock-search">종목</label>
|
||||||
|
<input id="stock-search" maxlength="32767" autocomplete="off" spellcheck="false">
|
||||||
|
<button id="stock-search-button" type="button"><span aria-hidden="true">⌕</span> 검색</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="stock-results" class="stock-results" role="listbox" aria-label="종목명"></div>
|
||||||
|
|
||||||
|
<div class="manual-actions" aria-label="수동 입력">
|
||||||
|
<button type="button" disabled>주요매출 구성</button>
|
||||||
|
<button type="button" disabled>성장성 지표</button>
|
||||||
|
<button type="button" disabled>매출액</button>
|
||||||
|
<button type="button" disabled>영업이익</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cut-list" class="cut-list" role="listbox" aria-label="종목 컷" aria-multiselectable="true"></div>
|
||||||
|
<div id="operator-status" class="operator-status" aria-live="polite"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="catalog-panel" aria-label="분류별 그래픽">
|
||||||
|
<nav class="market-tabs" aria-label="시장 분류">
|
||||||
|
<button class="active" type="button" disabled>해외</button>
|
||||||
|
<button type="button" disabled>환율</button>
|
||||||
|
<button type="button" disabled>지수</button>
|
||||||
|
<button type="button" disabled>코스피</button>
|
||||||
|
<button type="button" disabled>코스닥</button>
|
||||||
|
<button type="button" disabled>비교</button>
|
||||||
|
<button type="button" disabled>테마</button>
|
||||||
|
<button type="button" disabled>해외종목</button>
|
||||||
|
<button type="button" disabled>전문가</button>
|
||||||
|
<button type="button" disabled>정지</button>
|
||||||
|
</nav>
|
||||||
|
<label class="expand-line"><input type="checkbox" checked disabled> Expand</label>
|
||||||
|
<div class="category-title">Category</div>
|
||||||
|
<div class="category-tree" aria-label="원본 카테고리">
|
||||||
|
<div class="tree-root">⊖ <strong>[1열판기본]</strong></div>
|
||||||
|
<div class="tree-item">▸ 다우</div>
|
||||||
|
<div class="tree-item">▸ 나스닥</div>
|
||||||
|
<div class="tree-item">▸ S&P</div>
|
||||||
|
<div class="tree-item">▸ 독일</div>
|
||||||
|
<div class="tree-item">▸ 영국</div>
|
||||||
|
<div class="tree-item">▸ 프랑스</div>
|
||||||
|
<div class="tree-item">▸ 니케이</div>
|
||||||
|
<div class="tree-item">▸ 중국 상해</div>
|
||||||
|
<div class="tree-item">▸ 홍콩 항셍</div>
|
||||||
|
<div class="tree-item">▸ 대만 가권</div>
|
||||||
|
<div class="tree-item">▸ 싱가포르 지수</div>
|
||||||
|
<div class="tree-item">▸ 필리핀 지수</div>
|
||||||
|
<div class="tree-item">▸ 말레이시아 지수</div>
|
||||||
|
<div class="tree-root second">⊖ <strong>[2열판-]</strong></div>
|
||||||
|
<div class="tree-item">▸ 공산품 [원면, 목화(면화)]</div>
|
||||||
|
<div class="tree-item">▸ 원자재 [국제 금, 국제 은]</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="playlist-panel" aria-label="플레이리스트">
|
||||||
|
<div class="playlist-toolbar">
|
||||||
|
<label><input type="checkbox" checked disabled> 전체</label>
|
||||||
|
<label><input type="checkbox" checked disabled> 5일선</label>
|
||||||
|
<label><input type="checkbox" checked disabled> 20일선</label>
|
||||||
|
<button type="button" disabled>↑</button>
|
||||||
|
<button type="button" disabled>↓</button>
|
||||||
|
<span class="toolbar-spacer"></span>
|
||||||
|
<label><input type="checkbox" disabled> 배경없음(F3)</label>
|
||||||
|
<button type="button" disabled>배경파일(F2)</button>
|
||||||
|
<input class="background-name" value="기본.vrv" readonly>
|
||||||
|
<button type="button" disabled>DB 저장</button>
|
||||||
|
<button type="button" disabled>DB 불러오기</button>
|
||||||
|
<button type="button" disabled>선택삭제</button>
|
||||||
|
<button type="button" disabled>전체삭제</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="playlist-drop-zone" class="playlist-grid">
|
||||||
|
<div class="playlist-header playlist-row">
|
||||||
|
<div>송출</div><div>종류</div><div>종목</div><div>컷파일</div><div>세부사항</div><div>Page</div>
|
||||||
|
</div>
|
||||||
|
<div id="playlist-rows" class="playlist-rows"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="playout-actions">
|
||||||
|
<button class="take-in" type="button" disabled>● TAKE IN [F8]</button>
|
||||||
|
<button class="next" type="button" disabled>▶ NEXT</button>
|
||||||
|
<button class="take-out" type="button" disabled>✖ TAKE OUT[Esc]</button>
|
||||||
|
</div>
|
||||||
|
<div class="playout-status">원본 호환 UI 기준선 · 송출 연결 안 함</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="legacy-dialog" class="dialog-backdrop" hidden>
|
||||||
|
<div class="legacy-dialog" role="alertdialog" aria-modal="true" aria-labelledby="legacy-dialog-message">
|
||||||
|
<div id="legacy-dialog-message"></div>
|
||||||
|
<button id="legacy-dialog-ok" type="button">확인</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
92
src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css
Normal file
92
src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family: "NanumGothic", "맑은 고딕", sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #efefef;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
||||||
|
body.busy { cursor: wait; }
|
||||||
|
body.busy .operator-shell { pointer-events: none; }
|
||||||
|
button, input { font: inherit; }
|
||||||
|
button { border: 1px solid #a8a8a8; background: linear-gradient(#fff, #e9e9e9); color: #111; }
|
||||||
|
button:not(:disabled) { cursor: pointer; }
|
||||||
|
button:disabled { color: #555; opacity: 1; }
|
||||||
|
|
||||||
|
.operator-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 430px 500px minmax(760px, 1fr);
|
||||||
|
width: 100%;
|
||||||
|
min-width: 1690px;
|
||||||
|
height: 100vh;
|
||||||
|
background: #efefef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel, .catalog-panel, .playlist-panel { min-height: 0; border-right: 1px solid #bbb; }
|
||||||
|
.left-panel { display: flex; flex-direction: column; padding: 6px 15px 4px 20px; background: #f5f5f5; }
|
||||||
|
|
||||||
|
.brand-line { height: 68px; display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.brand { display: flex; align-items: center; gap: 12px; white-space: nowrap; }
|
||||||
|
.brand span { color: #ed671f; font-size: 28px; font-weight: 800; letter-spacing: -2px; }
|
||||||
|
.brand strong { font: italic 900 38px Arial, sans-serif; letter-spacing: -5px; }
|
||||||
|
.connection-state { padding: 10px 12px; background: #c40000; color: white; font: 700 18px Consolas, monospace; }
|
||||||
|
|
||||||
|
.search-line { display: grid; grid-template-columns: 40px 162px 1fr 117px; align-items: center; gap: 3px; height: 38px; }
|
||||||
|
.search-line label { font-weight: 700; }
|
||||||
|
.search-line input { grid-column: 2; height: 27px; padding: 2px 7px; border: 1px solid #888; font-size: 16px; }
|
||||||
|
.search-line button { grid-column: 4; height: 31px; font-size: 14px; }
|
||||||
|
.search-line button span { font-size: 21px; margin-right: 20px; }
|
||||||
|
|
||||||
|
.stock-results { height: 174px; border: 1px solid #999; background: #fff; overflow-y: auto; }
|
||||||
|
.stock-result { display: block; width: 100%; height: 20px; padding: 1px 5px; border: 0; background: #fff; text-align: left; font-size: 16px; line-height: 18px; }
|
||||||
|
.stock-result.selected { background: #696969; color: #fff; }
|
||||||
|
|
||||||
|
.manual-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: 9px 0; }
|
||||||
|
.manual-actions button { height: 34px; font-size: 12px; font-weight: 700; }
|
||||||
|
|
||||||
|
.cut-list { flex: 1; min-height: 0; border: 1px solid #999; background: #fff; overflow-y: auto; user-select: none; }
|
||||||
|
.cut-row { display: grid; grid-template-columns: 62px 1fr; min-height: 23px; border-bottom: 1px solid #ddd; background: #fff; font-size: 15px; }
|
||||||
|
.cut-row > span { padding: 2px 7px; pointer-events: none; }
|
||||||
|
.cut-row .cut-number { border-right: 1px solid #ddd; text-align: center; color: #145ab4; }
|
||||||
|
.cut-row.selected { background: #dcdcdc; }
|
||||||
|
.cut-row.separator { background: #fff; }
|
||||||
|
.operator-status { min-height: 18px; padding-top: 2px; color: #a40000; font-size: 11px; }
|
||||||
|
|
||||||
|
.catalog-panel { display: flex; flex-direction: column; padding: 0 8px; background: #f3f3f3; }
|
||||||
|
.market-tabs { display: flex; height: 29px; border-bottom: 1px solid #999; }
|
||||||
|
.market-tabs button { min-width: 48px; padding: 3px 8px; border-width: 0 1px 0 0; background: #efefef; }
|
||||||
|
.market-tabs button.active { background: #fff; border-top: 2px solid #e8781d; }
|
||||||
|
.expand-line { height: 31px; padding: 7px 8px 0; }
|
||||||
|
.category-title { height: 27px; border-bottom: 1px solid #777; font-size: 12px; }
|
||||||
|
.category-tree { flex: 1; overflow: auto; padding: 7px 12px; background: #fff; border-left: 1px solid #aaa; border-right: 1px solid #aaa; }
|
||||||
|
.tree-root { margin: 5px 0 2px; color: #168000; font-size: 16px; }
|
||||||
|
.tree-root.second { margin-top: 15px; }
|
||||||
|
.tree-item { padding: 3px 0 3px 29px; font-size: 15px; }
|
||||||
|
|
||||||
|
.playlist-panel { display: grid; grid-template-rows: 58px minmax(0, 1fr) 76px 42px; padding: 0 8px; background: #efefef; }
|
||||||
|
.playlist-toolbar { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||||||
|
.playlist-toolbar button { height: 25px; padding: 2px 9px; }
|
||||||
|
.playlist-toolbar .toolbar-spacer { flex: 1; }
|
||||||
|
.background-name { width: 95px; height: 25px; border: 1px solid #888; background: #fff; }
|
||||||
|
|
||||||
|
.playlist-grid { min-height: 0; border: 1px solid #888; background: #d5d5d5; overflow: auto; }
|
||||||
|
.playlist-row { display: grid; grid-template-columns: 40px 100px 260px 200px minmax(255px, 1fr) 52px; min-width: 907px; }
|
||||||
|
.playlist-header { position: sticky; top: 0; z-index: 2; height: 29px; background: #efefef; border-bottom: 1px solid #999; }
|
||||||
|
.playlist-header > div { display: flex; align-items: center; justify-content: center; border-right: 1px solid #aaa; }
|
||||||
|
.playlist-data-row { min-height: 25px; background: #fff; border-bottom: 1px solid #ddd; }
|
||||||
|
.playlist-data-row > div { padding: 3px 6px; border-right: 1px solid #ddd; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.playlist-data-row .enabled-cell { text-align: center; }
|
||||||
|
|
||||||
|
.playout-actions { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
|
||||||
|
.playout-actions button { height: 51px; background: #fff; font-size: 25px; font-weight: 700; }
|
||||||
|
.take-in { width: 215px; }
|
||||||
|
.next { width: 180px; }
|
||||||
|
.take-out { width: 212px; }
|
||||||
|
.playout-status { border-top: 1px solid #bbb; display: flex; align-items: center; justify-content: center; color: #666; }
|
||||||
|
|
||||||
|
.dialog-backdrop { position: fixed; inset: 0; z-index: 20; background: rgba(0, 0, 0, .25); }
|
||||||
|
.dialog-backdrop[hidden] { display: none; }
|
||||||
|
.legacy-dialog { position: absolute; left: 50%; top: 50%; width: 320px; padding: 24px; transform: translate(-50%, -50%); border: 1px solid #777; background: #fff; box-shadow: 0 8px 30px rgba(0, 0, 0, .3); text-align: center; }
|
||||||
|
.legacy-dialog button { min-width: 80px; margin-top: 24px; padding: 6px 15px; }
|
||||||
14
src/MBN_STOCK_WEBVIEW.LegacyParityApp/app.manifest
Normal file
14
src/MBN_STOCK_WEBVIEW.LegacyParityApp/app.manifest
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="MBN_STOCK_WEBVIEW.LegacyParityApp" />
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
70
tests/LegacyParityWeb/thin-client.test.cjs
Normal file
70
tests/LegacyParityWeb/thin-client.test.cjs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const test = require("node:test");
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(__dirname, "..", "..");
|
||||||
|
const webRoot = path.join(repositoryRoot, "src", "MBN_STOCK_WEBVIEW.LegacyParityApp", "Web");
|
||||||
|
const app = fs.readFileSync(path.join(webRoot, "app.js"), "utf8");
|
||||||
|
const markup = fs.readFileSync(path.join(webRoot, "index.html"), "utf8");
|
||||||
|
|
||||||
|
test("legacy shell exposes the original stock, cut, playlist and playout controls", () => {
|
||||||
|
for (const id of [
|
||||||
|
"stock-search",
|
||||||
|
"stock-search-button",
|
||||||
|
"stock-results",
|
||||||
|
"cut-list",
|
||||||
|
"playlist-drop-zone",
|
||||||
|
"playlist-rows",
|
||||||
|
"legacy-dialog"
|
||||||
|
]) {
|
||||||
|
assert.match(markup, new RegExp("id=\\\"" + id + "\\\""));
|
||||||
|
}
|
||||||
|
assert.match(markup, /TAKE IN/);
|
||||||
|
assert.match(markup, /TAKE OUT/);
|
||||||
|
assert.match(markup, /NEXT/);
|
||||||
|
assert.match(markup, /class="connection-state"[^>]*>미연결<\/div>/);
|
||||||
|
assert.match(markup, /class="active" type="button" disabled>해외<\/button>/);
|
||||||
|
assert.match(markup, /type="button" disabled>정지<\/button>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("web layer sends intent and does not own scene, DB identity or playlist state", () => {
|
||||||
|
assert.doesNotMatch(app, /(?:sceneCode|cutCode|stockCode|dataCode|builderKey)/);
|
||||||
|
assert.doesNotMatch(app, /(?:localStorage|sessionStorage|playlist\.push|fetch\s*\()/);
|
||||||
|
assert.doesNotMatch(app, /\b(?:5001|N5001|5006|5011|5086|8035|8061|8040|8046|8051|8056|8003|5037)\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy double click is represented by pointer-down facts, not browser dblclick policy", () => {
|
||||||
|
assert.match(app, /cut-pointer-down/);
|
||||||
|
assert.match(app, /timestampMilliseconds/);
|
||||||
|
assert.match(app, /event\.ctrlKey/);
|
||||||
|
assert.match(app, /event\.shiftKey/);
|
||||||
|
assert.doesNotMatch(app, /dblclick/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cut rows are reconciled in place so a state reply cannot cancel native drag", () => {
|
||||||
|
assert.match(app, /dataset\.physicalIndex/);
|
||||||
|
assert.match(app, /function createCutElement/);
|
||||||
|
assert.doesNotMatch(app, /cutList\.replaceChildren/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("state rendering preserves stock and playlist focus like native controls", () => {
|
||||||
|
assert.match(app, /dataset\.resultIndex/);
|
||||||
|
assert.match(app, /dataset\.rowId/);
|
||||||
|
assert.doesNotMatch(app, /stockResults\.replaceChildren/);
|
||||||
|
assert.doesNotMatch(app, /playlistRows\.replaceChildren/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("separator rows preserve the original drag gesture", () => {
|
||||||
|
assert.match(app, /element\.draggable = true/);
|
||||||
|
assert.doesNotMatch(app, /draggable = !row\.isSeparator/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a C# busy snapshot freezes controls instead of silently accepting queued input", () => {
|
||||||
|
const styles = fs.readFileSync(path.join(webRoot, "styles.css"), "utf8");
|
||||||
|
assert.match(app, /state\.isBusy === true/);
|
||||||
|
assert.match(app, /classList\.toggle\("busy"/);
|
||||||
|
assert.match(styles, /body\.busy \.operator-shell \{ pointer-events: none; \}/);
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
global using Xunit;
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||||
|
|
||||||
|
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||||
|
|
||||||
|
public sealed class LegacyOperatorControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task EmptyButtonIsNoOpButEmptyEnterSearches()
|
||||||
|
{
|
||||||
|
var lookup = Lookup();
|
||||||
|
var controller = new LegacyOperatorController(lookup);
|
||||||
|
|
||||||
|
await controller.SearchStocksAsync(string.Empty, LegacySearchTrigger.Button);
|
||||||
|
Assert.Equal(0, lookup.CallCount);
|
||||||
|
|
||||||
|
await controller.SearchStocksAsync(string.Empty, LegacySearchTrigger.Enter);
|
||||||
|
Assert.Equal(1, lookup.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExactPointerDoubleClick_AppendsOneLegacyRow()
|
||||||
|
{
|
||||||
|
var controller = new LegacyOperatorController(Lookup());
|
||||||
|
await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
|
||||||
|
controller.SelectStock(0);
|
||||||
|
|
||||||
|
controller.CutPointerDown(1, false, false, 20, 45, 1_000);
|
||||||
|
var state = controller.CutPointerDown(1, false, false, 20, 45, 1_400);
|
||||||
|
|
||||||
|
var row = Assert.Single(state.Playlist);
|
||||||
|
Assert.Equal("코스피", row.MarketText);
|
||||||
|
Assert.Equal("삼성전자", row.StockName);
|
||||||
|
Assert.Equal("1열판기본", row.GraphicType);
|
||||||
|
Assert.Equal("현재가", row.Subtype);
|
||||||
|
Assert.Equal("005930", row.StockCode);
|
||||||
|
Assert.Equal("5001", row.ToPlayoutEntry().CutCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExactPointerDoubleClick_AllowsZeroBasedWebTimestamp()
|
||||||
|
{
|
||||||
|
var controller = new LegacyOperatorController(Lookup());
|
||||||
|
await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
|
||||||
|
controller.SelectStock(0);
|
||||||
|
|
||||||
|
controller.CutPointerDown(1, false, false, 20, 45, 0);
|
||||||
|
var state = controller.CutPointerDown(1, false, false, 20, 45, 499);
|
||||||
|
|
||||||
|
Assert.Single(state.Playlist);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DifferentPointerPosition_DoesNotCountAsLegacyDoubleClick()
|
||||||
|
{
|
||||||
|
var controller = new LegacyOperatorController(Lookup());
|
||||||
|
await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
|
||||||
|
controller.SelectStock(0);
|
||||||
|
|
||||||
|
controller.CutPointerDown(1, false, false, 20, 45, 1_000);
|
||||||
|
var state = controller.CutPointerDown(1, false, false, 21, 45, 1_200);
|
||||||
|
|
||||||
|
Assert.Empty(state.Playlist);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NativeSelectionAndLegacyDoubleClickRowsStaySeparateAtShiftEdges()
|
||||||
|
{
|
||||||
|
var controller = new LegacyOperatorController(Lookup());
|
||||||
|
await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
|
||||||
|
controller.SelectStock(0);
|
||||||
|
|
||||||
|
var firstShift = controller.CutPointerDown(2, false, true, 20, 69, 1_000);
|
||||||
|
Assert.Equal(
|
||||||
|
[0, 1, 2],
|
||||||
|
firstShift.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex));
|
||||||
|
|
||||||
|
var noDoubleClick = controller.CutPointerDown(2, false, false, 20, 69, 1_400);
|
||||||
|
Assert.Empty(noDoubleClick.Playlist);
|
||||||
|
|
||||||
|
var sameRowShift = controller.CutPointerDown(2, false, true, 21, 69, 2_000);
|
||||||
|
Assert.Equal(
|
||||||
|
[2],
|
||||||
|
sameRowShift.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex));
|
||||||
|
|
||||||
|
var stillNoDoubleClick = controller.CutPointerDown(2, false, false, 21, 69, 2_400);
|
||||||
|
Assert.Empty(stillNoDoubleClick.Playlist);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ControlAndShiftSelection_DropsInPhysicalOrderAndKeepsDuplicates()
|
||||||
|
{
|
||||||
|
var controller = new LegacyOperatorController(Lookup());
|
||||||
|
await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
|
||||||
|
controller.SelectStock(0);
|
||||||
|
controller.CutPointerDown(0, false, false, 1, 1, 1_000);
|
||||||
|
controller.CutPointerDown(2, true, false, 1, 47, 2_000);
|
||||||
|
controller.CutPointerDown(1, true, true, 1, 24, 3_000);
|
||||||
|
|
||||||
|
var first = controller.DropSelectedCutsOnPlaylist();
|
||||||
|
var second = controller.DropSelectedCutsOnPlaylist();
|
||||||
|
|
||||||
|
Assert.Equal(3, first.Playlist.Count);
|
||||||
|
Assert.Equal(6, second.Playlist.Count);
|
||||||
|
Assert.Equal(
|
||||||
|
["예상체결가", "현재가", "시간외단일가"],
|
||||||
|
first.Playlist.Select(row => row.Subtype));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SameRowShiftStillDropsTheNativeListViewSelection()
|
||||||
|
{
|
||||||
|
var controller = new LegacyOperatorController(Lookup());
|
||||||
|
await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
|
||||||
|
controller.SelectStock(0);
|
||||||
|
controller.CutPointerDown(1, false, false, 1, 24, 1_000);
|
||||||
|
controller.CutPointerDown(1, false, true, 2, 24, 2_000);
|
||||||
|
|
||||||
|
var state = controller.DropSelectedCutsOnPlaylist();
|
||||||
|
|
||||||
|
Assert.Equal("현재가", Assert.Single(state.Playlist).Subtype);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NxtOnlyAllowsCurrentAndShowsOriginalWarning()
|
||||||
|
{
|
||||||
|
var controller = new LegacyOperatorController(Lookup());
|
||||||
|
await controller.SearchStocksAsync("넥스트", LegacySearchTrigger.Button);
|
||||||
|
controller.SelectStock(1);
|
||||||
|
controller.CutPointerDown(0, false, false, 1, 1, 1_000);
|
||||||
|
var warning = controller.DropSelectedCutsOnPlaylist();
|
||||||
|
|
||||||
|
Assert.Empty(warning.Playlist);
|
||||||
|
Assert.Equal("NXT는 제공하지 않습니다.", warning.Dialog?.Message);
|
||||||
|
|
||||||
|
controller.DismissDialog();
|
||||||
|
controller.CutPointerDown(1, false, false, 1, 24, 2_000);
|
||||||
|
var accepted = controller.DropSelectedCutsOnPlaylist();
|
||||||
|
Assert.Equal("N5001", Assert.Single(accepted.Playlist).ToPlayoutEntry().CutCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NxtMultiSelection_PausesAtEachOriginalModalBeforeContinuing()
|
||||||
|
{
|
||||||
|
var controller = new LegacyOperatorController(Lookup());
|
||||||
|
await controller.SearchStocksAsync("넥스트", LegacySearchTrigger.Button);
|
||||||
|
controller.SelectStock(1);
|
||||||
|
controller.CutPointerDown(0, false, false, 1, 1, 1_000);
|
||||||
|
controller.CutPointerDown(1, true, false, 1, 24, 2_000);
|
||||||
|
controller.CutPointerDown(2, true, false, 1, 47, 3_000);
|
||||||
|
|
||||||
|
var firstWarning = controller.DropSelectedCutsOnPlaylist();
|
||||||
|
Assert.Empty(firstWarning.Playlist);
|
||||||
|
Assert.Equal("NXT는 제공하지 않습니다.", firstWarning.Dialog?.Message);
|
||||||
|
|
||||||
|
var secondWarning = controller.DismissDialog();
|
||||||
|
Assert.Equal("N5001", Assert.Single(secondWarning.Playlist).ToPlayoutEntry().CutCode);
|
||||||
|
Assert.Equal("NXT는 제공하지 않습니다.", secondWarning.Dialog?.Message);
|
||||||
|
|
||||||
|
var finished = controller.DismissDialog();
|
||||||
|
Assert.Single(finished.Playlist);
|
||||||
|
Assert.Null(finished.Dialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NxtSeparatorStillShowsOriginalWarning()
|
||||||
|
{
|
||||||
|
var controller = new LegacyOperatorController(Lookup());
|
||||||
|
await controller.SearchStocksAsync("넥스트", LegacySearchTrigger.Button);
|
||||||
|
controller.SelectStock(1);
|
||||||
|
controller.CutPointerDown(3, false, false, 1, 70, 1_000);
|
||||||
|
|
||||||
|
var warning = controller.DropSelectedCutsOnPlaylist();
|
||||||
|
|
||||||
|
Assert.Empty(warning.Playlist);
|
||||||
|
Assert.Equal("NXT는 제공하지 않습니다.", warning.Dialog?.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NewSearchClearsStockSelectionButNotCutCatalogOrPlaylist()
|
||||||
|
{
|
||||||
|
var controller = new LegacyOperatorController(Lookup());
|
||||||
|
await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button);
|
||||||
|
controller.SelectStock(0);
|
||||||
|
controller.CutPointerDown(1, false, false, 1, 24, 1_000);
|
||||||
|
controller.DropSelectedCutsOnPlaylist();
|
||||||
|
|
||||||
|
var state = await controller.SearchStocksAsync("다시", LegacySearchTrigger.Button);
|
||||||
|
|
||||||
|
Assert.Null(state.SelectedStockIndex);
|
||||||
|
Assert.Equal(33, state.CutRows.Count);
|
||||||
|
Assert.Single(state.Playlist);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FakeLookup Lookup() => new(new LegacyStockSearchData(
|
||||||
|
"query",
|
||||||
|
["삼성전자", "넥스트(NXT)"],
|
||||||
|
[
|
||||||
|
new LegacyStockIdentity(LegacyDomesticStockMarket.Kospi, "삼성전자", "005930"),
|
||||||
|
new LegacyStockIdentity(LegacyDomesticStockMarket.NxtKospi, "넥스트(NXT)", "A00001")
|
||||||
|
]));
|
||||||
|
|
||||||
|
private sealed class FakeLookup : ILegacyStockLookup
|
||||||
|
{
|
||||||
|
private readonly LegacyStockSearchData _result;
|
||||||
|
|
||||||
|
public FakeLookup(LegacyStockSearchData result)
|
||||||
|
{
|
||||||
|
_result = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CallCount { get; private set; }
|
||||||
|
|
||||||
|
public Task<LegacyStockSearchData> SearchAsync(
|
||||||
|
string query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
CallCount++;
|
||||||
|
return Task.FromResult(_result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using System.Data;
|
||||||
|
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||||
|
using MMoneyCoderSharp.Data;
|
||||||
|
|
||||||
|
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||||
|
|
||||||
|
public sealed class LegacyParityStockSearchServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Search_PreservesProviderAndRowOrderWithoutLimitOrSort()
|
||||||
|
{
|
||||||
|
var executor = new RecordingExecutor(new Dictionary<string, DataTable>
|
||||||
|
{
|
||||||
|
["LEGACY_PARITY_KOSPI_MASTER"] = Table(("중복", "000001")),
|
||||||
|
["LEGACY_PARITY_KOSDAQ_MASTER"] = Table(("중복", "100001")),
|
||||||
|
["LEGACY_PARITY_NXT_KOSPI_MASTER"] = Table(("중복(NXT)", "200001")),
|
||||||
|
["LEGACY_PARITY_NXT_KOSDAQ_MASTER"] = Table(("중복", "300001")),
|
||||||
|
["LEGACY_PARITY_KOSPI"] = Table(("가-2", "000002"), ("가-1", "000001")),
|
||||||
|
["LEGACY_PARITY_KOSDAQ"] = Table(("나", "100001")),
|
||||||
|
["LEGACY_PARITY_NXT_KOSPI"] = Table(("다(NXT)", "200001")),
|
||||||
|
["LEGACY_PARITY_NXT_KOSDAQ"] = Table(("라(NXT)", "300001"))
|
||||||
|
});
|
||||||
|
var service = new LegacyParityStockSearchService(executor);
|
||||||
|
|
||||||
|
var result = await service.SearchAsync(" a%_ ");
|
||||||
|
|
||||||
|
Assert.Equal(["가-2", "가-1", "나", "다(NXT)", "라(NXT)"], result.DisplayNames);
|
||||||
|
Assert.Equal("300001", result.ResolveLastByDisplayName("중복")?.StockCode);
|
||||||
|
Assert.Equal(LegacyDomesticStockMarket.NxtKosdaq,
|
||||||
|
result.ResolveLastByDisplayName("중복")?.Market);
|
||||||
|
Assert.Equal(8, executor.Calls.Count);
|
||||||
|
|
||||||
|
foreach (var call in executor.Calls.Skip(4))
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain("ORDER BY", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("LIMIT", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("ROWNUM", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Single(call.Spec.Parameters);
|
||||||
|
Assert.Equal(" A%_ ", call.Spec.Parameters[0].Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EmptySearch_IsAllowedAndMasterSnapshotIsCached()
|
||||||
|
{
|
||||||
|
var tables = new Dictionary<string, DataTable>();
|
||||||
|
foreach (var name in new[]
|
||||||
|
{
|
||||||
|
"LEGACY_PARITY_KOSPI", "LEGACY_PARITY_KOSDAQ",
|
||||||
|
"LEGACY_PARITY_NXT_KOSPI", "LEGACY_PARITY_NXT_KOSDAQ"
|
||||||
|
})
|
||||||
|
{
|
||||||
|
tables[name] = Table();
|
||||||
|
tables[$"{name}_MASTER"] = Table();
|
||||||
|
}
|
||||||
|
|
||||||
|
var executor = new RecordingExecutor(tables);
|
||||||
|
var service = new LegacyParityStockSearchService(executor);
|
||||||
|
|
||||||
|
await service.SearchAsync(string.Empty);
|
||||||
|
await service.SearchAsync(string.Empty);
|
||||||
|
|
||||||
|
Assert.Equal(12, executor.Calls.Count);
|
||||||
|
Assert.Equal(4, executor.Calls.Count(call => call.TableName.EndsWith("_MASTER", StringComparison.Ordinal)));
|
||||||
|
Assert.All(executor.Calls.Where(call => !call.TableName.EndsWith("_MASTER", StringComparison.Ordinal)),
|
||||||
|
call => Assert.Equal(string.Empty, call.Spec.Parameters[0].Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DataTable Table(params (string Name, string Code)[] rows)
|
||||||
|
{
|
||||||
|
var table = new DataTable();
|
||||||
|
table.Columns.Add("STOCK_NAME", typeof(string));
|
||||||
|
table.Columns.Add("STOCK_CODE", typeof(string));
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
table.Rows.Add(row.Name, row.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingExecutor : IDataQueryExecutor
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyDictionary<string, DataTable> _tables;
|
||||||
|
|
||||||
|
public RecordingExecutor(IReadOnlyDictionary<string, DataTable> tables)
|
||||||
|
{
|
||||||
|
_tables = tables;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Call> Calls { get; } = [];
|
||||||
|
|
||||||
|
public DataTable Execute(DataSourceKind source, string tableName, string query) =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<DataTable> ExecuteAsync(
|
||||||
|
DataSourceKind source,
|
||||||
|
string tableName,
|
||||||
|
string query,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<DataTable> ExecuteAsync(
|
||||||
|
DataSourceKind source,
|
||||||
|
string tableName,
|
||||||
|
DataQuerySpec query,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Calls.Add(new Call(source, tableName, query));
|
||||||
|
return Task.FromResult(_tables[tableName].Copy());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record Call(DataSourceKind Source, string TableName, DataQuerySpec Spec);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||||
|
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||||
|
|
||||||
|
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
|
||||||
|
|
||||||
|
public sealed class LegacyStockCutCatalogTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Rows_MatchRuntimeAssetPhysicalLayout()
|
||||||
|
{
|
||||||
|
Assert.Equal(33, LegacyStockCutCatalog.Rows.Count);
|
||||||
|
Assert.Equal(31, LegacyStockCutCatalog.Rows.Count(row => !row.IsSeparator));
|
||||||
|
Assert.Equal([3, 8], LegacyStockCutCatalog.Rows
|
||||||
|
.Where(row => row.IsSeparator)
|
||||||
|
.Select(row => row.PhysicalIndex));
|
||||||
|
Assert.Equal("1열판기본_시간외단일가", LegacyStockCutCatalog.Rows[2].RawLabel);
|
||||||
|
Assert.Equal(31, LegacyStockCutCatalog.Rows[^1].DisplayOrdinal);
|
||||||
|
Assert.Equal(
|
||||||
|
"45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20",
|
||||||
|
LegacyStockCutCatalog.RuntimeAssetSha256);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EveryActiveCut_ResolvesToRegisteredAlias()
|
||||||
|
{
|
||||||
|
foreach (var row in LegacyStockCutCatalog.Rows.Where(row => !row.IsSeparator))
|
||||||
|
{
|
||||||
|
var alias = LegacyStockCutCatalog.ResolveCutAlias(row.RawLabel, isNxt: false);
|
||||||
|
Assert.NotEmpty(LegacySceneCatalog.FindByCutAlias(alias));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("1열판기본_현재가", "N5001")]
|
||||||
|
public void NxtCurrent_UsesN5001(string label, string expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, LegacyStockCutCatalog.ResolveCutAlias(label, isNxt: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlaylistRow_PreservesLegacySevenFieldsAndDefersAliasResolution()
|
||||||
|
{
|
||||||
|
var stock = new LegacyStockIdentity(
|
||||||
|
LegacyDomesticStockMarket.Kospi,
|
||||||
|
"삼성전자",
|
||||||
|
"005930");
|
||||||
|
var row = LegacyStockCutCatalog.CreatePlaylistRow(
|
||||||
|
"row-1",
|
||||||
|
stock.DisplayName,
|
||||||
|
stock,
|
||||||
|
LegacyStockCutCatalog.Rows[1]);
|
||||||
|
|
||||||
|
Assert.True(row.IsEnabled);
|
||||||
|
Assert.Equal("코스피", row.MarketText);
|
||||||
|
Assert.Equal("삼성전자", row.StockName);
|
||||||
|
Assert.Equal("1열판기본", row.GraphicType);
|
||||||
|
Assert.Equal("현재가", row.Subtype);
|
||||||
|
Assert.Equal("1/1", row.PageText);
|
||||||
|
Assert.Equal("005930", row.StockCode);
|
||||||
|
|
||||||
|
var playout = row.ToPlayoutEntry();
|
||||||
|
Assert.Equal("5001", playout.CutCode);
|
||||||
|
Assert.Equal("005930", playout.Selection?.DataCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.LegacyApplication\MBN_STOCK_WEBVIEW.LegacyApplication.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
global using Xunit;
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||||
|
using MBN_STOCK_WEBVIEW.LegacyBridge;
|
||||||
|
|
||||||
|
namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests;
|
||||||
|
|
||||||
|
public sealed class LegacyBridgeProtocolTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ParseSearch_PreservesBlankEnterAndRejectsExtraAuthority()
|
||||||
|
{
|
||||||
|
Assert.True(LegacyBridgeProtocol.TryParseIntent(
|
||||||
|
"{\"type\":\"search-stocks\",\"payload\":{\"text\":\"\",\"trigger\":\"enter\"}}",
|
||||||
|
out var parsed,
|
||||||
|
out var error));
|
||||||
|
Assert.Null(error);
|
||||||
|
var search = Assert.IsType<LegacySearchStocksIntent>(parsed);
|
||||||
|
Assert.Equal(string.Empty, search.Text);
|
||||||
|
Assert.Equal(LegacySearchTrigger.Enter, search.Trigger);
|
||||||
|
|
||||||
|
Assert.False(LegacyBridgeProtocol.TryParseIntent(
|
||||||
|
"{\"type\":\"search-stocks\",\"payload\":{\"text\":\"삼성\",\"trigger\":\"button\",\"sceneCode\":\"5001\"}}",
|
||||||
|
out _,
|
||||||
|
out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePointer_RequiresExactBoundedInput()
|
||||||
|
{
|
||||||
|
const string json =
|
||||||
|
"{\"type\":\"cut-pointer-down\",\"payload\":{\"physicalIndex\":1,\"control\":false,\"shift\":true,\"x\":20,\"y\":45,\"timestampMilliseconds\":1400}}";
|
||||||
|
|
||||||
|
Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out _));
|
||||||
|
var pointer = Assert.IsType<LegacyCutPointerDownIntent>(parsed);
|
||||||
|
Assert.Equal(1, pointer.PhysicalIndex);
|
||||||
|
Assert.True(pointer.Shift);
|
||||||
|
Assert.Equal(1_400, pointer.TimestampMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://legacy-parity.mbn.local/index.html", true)]
|
||||||
|
[InlineData("https://legacy-parity.mbn.local.evil/index.html", false)]
|
||||||
|
[InlineData("http://legacy-parity.mbn.local/index.html", false)]
|
||||||
|
[InlineData("file:///C:/index.html", false)]
|
||||||
|
public void TrustedSource_IsExactHttpsOrigin(string source, bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, LegacyBridgeProtocol.IsTrustedSource(source));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SerializedState_DoesNotExposeHiddenDbOrSceneAuthority()
|
||||||
|
{
|
||||||
|
var playlistRow = LegacyStockCutCatalog.CreatePlaylistRow(
|
||||||
|
"legacy-stock-00000001",
|
||||||
|
"삼성전자",
|
||||||
|
new LegacyStockIdentity(LegacyDomesticStockMarket.Kospi, "삼성전자", "005930"),
|
||||||
|
LegacyStockCutCatalog.Rows[1]);
|
||||||
|
var snapshot = new LegacyOperatorSnapshot(
|
||||||
|
1,
|
||||||
|
"삼성",
|
||||||
|
[new LegacyStockResultRow(0, "삼성전자")],
|
||||||
|
0,
|
||||||
|
[new LegacyCutViewRow(1, 2, "1열판기본_현재가", false, true)],
|
||||||
|
[playlistRow],
|
||||||
|
null,
|
||||||
|
string.Empty,
|
||||||
|
LegacyOperatorStatusKind.Neutral);
|
||||||
|
|
||||||
|
var json = LegacyBridgeProtocol.SerializeState(snapshot, isBusy: true);
|
||||||
|
|
||||||
|
Assert.DoesNotContain("005930", json, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("5001", json, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("rawCutLabel", json, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(json);
|
||||||
|
var payload = document.RootElement.GetProperty("payload");
|
||||||
|
Assert.Equal(
|
||||||
|
"삼성전자",
|
||||||
|
payload.GetProperty("searchResults")[0].GetProperty("displayName").GetString());
|
||||||
|
Assert.Equal(
|
||||||
|
"삼성전자",
|
||||||
|
payload.GetProperty("playlist")[0].GetProperty("stockName").GetString());
|
||||||
|
Assert.Equal(
|
||||||
|
"1열판기본_현재가",
|
||||||
|
payload.GetProperty("cutRows")[0].GetProperty("rawLabel").GetString());
|
||||||
|
Assert.True(payload.GetProperty("isBusy").GetBoolean());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.LegacyBridge\MBN_STOCK_WEBVIEW.LegacyBridge.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
1
tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/GlobalUsings.cs
Normal file
1
tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/GlobalUsings.cs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
global using Xunit;
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
|
||||||
|
|
||||||
|
public sealed class LegacyWebContractTests
|
||||||
|
{
|
||||||
|
private static readonly string RepositoryRoot = FindRepositoryRoot();
|
||||||
|
private static readonly string WebRoot = Path.Combine(
|
||||||
|
RepositoryRoot,
|
||||||
|
"src",
|
||||||
|
"MBN_STOCK_WEBVIEW.LegacyParityApp",
|
||||||
|
"Web");
|
||||||
|
private static readonly string App = File.ReadAllText(Path.Combine(WebRoot, "app.js"));
|
||||||
|
private static readonly string Markup = File.ReadAllText(Path.Combine(WebRoot, "index.html"));
|
||||||
|
private static readonly string Styles = File.ReadAllText(Path.Combine(WebRoot, "styles.css"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShellExposesOriginalStockCutPlaylistAndPlayoutControls()
|
||||||
|
{
|
||||||
|
foreach (var id in new[]
|
||||||
|
{
|
||||||
|
"stock-search",
|
||||||
|
"stock-search-button",
|
||||||
|
"stock-results",
|
||||||
|
"cut-list",
|
||||||
|
"playlist-drop-zone",
|
||||||
|
"playlist-rows",
|
||||||
|
"legacy-dialog"
|
||||||
|
})
|
||||||
|
{
|
||||||
|
Assert.Contains($"id=\"{id}\"", Markup, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Contains("TAKE IN", Markup, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("TAKE OUT", Markup, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("NEXT", Markup, StringComparison.Ordinal);
|
||||||
|
Assert.Matches("class=\"connection-state\"[^>]*>미연결</div>", Markup);
|
||||||
|
Assert.Contains(
|
||||||
|
"class=\"active\" type=\"button\" disabled>해외</button>",
|
||||||
|
Markup,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains(
|
||||||
|
"type=\"button\" disabled>정지</button>",
|
||||||
|
Markup,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WebLayerDoesNotOwnSceneDatabaseIdentityOrPlaylistState()
|
||||||
|
{
|
||||||
|
Assert.DoesNotMatch(
|
||||||
|
new Regex("(?:sceneCode|cutCode|stockCode|dataCode|builderKey)"),
|
||||||
|
App);
|
||||||
|
Assert.DoesNotMatch(
|
||||||
|
new Regex("(?:localStorage|sessionStorage|playlist\\.push|fetch\\s*\\()"),
|
||||||
|
App);
|
||||||
|
Assert.DoesNotMatch(
|
||||||
|
new Regex("\\b(?:5001|N5001|5006|5011|5086|8035|8061|8040|8046|8051|8056|8003|5037)\\b"),
|
||||||
|
App);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DoubleClickUsesPointerFactsInsteadOfBrowserPolicy()
|
||||||
|
{
|
||||||
|
Assert.Contains("cut-pointer-down", App, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("timestampMilliseconds", App, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("event.ctrlKey", App, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("event.shiftKey", App, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("dblclick", App, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CutRowsAreReconciledInPlace()
|
||||||
|
{
|
||||||
|
Assert.Contains("dataset.physicalIndex", App, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("function createCutElement", App, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("cutList.replaceChildren", App, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StockAndPlaylistRowsPreserveNativeControlFocus()
|
||||||
|
{
|
||||||
|
Assert.Contains("dataset.resultIndex", App, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("dataset.rowId", App, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("stockResults.replaceChildren", App, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("playlistRows.replaceChildren", App, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SeparatorRowsPreserveOriginalDragGesture()
|
||||||
|
{
|
||||||
|
Assert.Contains("element.draggable = true", App, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("draggable = !row.isSeparator", App, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BusySnapshotFreezesControlsWithoutQueueingInput()
|
||||||
|
{
|
||||||
|
Assert.Contains("state.isBusy === true", App, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("classList.toggle(\"busy\"", App, StringComparison.Ordinal);
|
||||||
|
Assert.Contains(
|
||||||
|
"body.busy .operator-shell { pointer-events: none; }",
|
||||||
|
Styles,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindRepositoryRoot()
|
||||||
|
{
|
||||||
|
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(directory.FullName, "MBN_STOCK_WEBVIEW.sln")))
|
||||||
|
{
|
||||||
|
return directory.FullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new DirectoryNotFoundException("The repository root could not be located.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user