feat: restore legacy named playlist workflows

This commit is contained in:
2026-07-12 11:59:18 +09:00
parent a481c96c39
commit af80c36cde
52 changed files with 9261 additions and 83 deletions

View File

@@ -0,0 +1,377 @@
#nullable enable
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyNamedForeignStockRestoreServiceTests
{
public static TheoryData<string, string, string, string, int?> Actions =>
new()
{
{ "1열판기본", "", "stock-current", "5001", null },
{ "캔들그래프", "5일", "stock-candle-5d", "8061", 5 },
{ "캔들그래프", "20일", "stock-candle-20d", "8040", 20 },
{ "캔들그래프", "60일", "stock-candle-60d", "8046", 60 },
{ "캔들그래프", "120일", "stock-candle-120d", "8051", 120 },
{ "캔들그래프", "240일", "stock-candle-240d", "8056", 240 }
};
[Theory]
[MemberData(nameof(Actions))]
public async Task ResolveAsync_MapsEveryClosedUc5ActionAfterExactBoundIdentity(
string graphicType,
string subtype,
string actionId,
string cutCode,
int? periodDays)
{
var executor = new RecordingExecutor(call =>
{
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal(LegacyNamedForeignStockRestoreService.QueryName, call.QueryName);
call.Spec.ValidateFor(DataSourceKind.Oracle);
Assert.Contains("F_INPUT_NAME = :input_name", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("F_FDTC = '1'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("F_NATC IN ('US', 'TW')", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("SELECT DISTINCT", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ROWNUM <= 2", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("LIKE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("NVIDIA", call.Spec.Sql, StringComparison.Ordinal);
var parameter = Assert.Single(call.Spec.Parameters);
Assert.Equal("input_name", parameter.Name);
Assert.Equal("NVIDIA", parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
return IdentityTable(("NVIDIA", "NAS@NVDA", "US", "1"));
});
var row = Row(graphicType: graphicType, subtype: subtype);
Assert.True(LegacyNamedForeignStockRestoreService.IsCandidate(row));
var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
.ResolveAsync([row])).Rows);
Assert.True(outcome.IsResolved);
Assert.Equal(actionId, outcome.Identity!.ActionId);
Assert.Equal("NVIDIA", outcome.Identity.InputName);
Assert.Equal("NAS@NVDA", outcome.Identity.Symbol);
Assert.Equal("US", outcome.Identity.NationCode);
Assert.Equal("1", outcome.Identity.ForeignDomesticCode);
Assert.Equal(periodDays is null ? "s5001" : "s8010", outcome.Identity.BuilderKey);
Assert.Equal(cutCode, outcome.Identity.CutCode);
Assert.Equal(periodDays is null ? "CURRENT" : "PRICE", outcome.Identity.ValueType);
Assert.Equal(periodDays, outcome.Identity.PeriodDays);
Assert.Single(executor.Calls);
Assert.Equal(0, executor.StringSqlCallCount);
}
[Fact]
public async Task ResolveAsync_PreservesOrderAndCachesRepeatedExactInputName()
{
var executor = new RecordingExecutor(call =>
{
var name = Assert.IsType<string>(Assert.Single(call.Spec.Parameters).Value);
return name == "TSMC"
? IdentityTable((name, "TPE@2330", "TW", "1"))
: IdentityTable((name, "NAS@NVDA", "US", "1"));
});
var rows = new[]
{
Row(itemIndex: 12, subject: "TSMC", graphicType: "캔들그래프", subtype: "240일"),
Row(itemIndex: 2, graphicType: "1열판기본", subtype: ""),
Row(itemIndex: 900, graphicType: "캔들그래프", subtype: "5일")
};
var result = await new LegacyNamedForeignStockRestoreService(executor).ResolveAsync(rows);
Assert.Equal([12, 2, 900], result.Rows.Select(value => value.ItemIndex));
Assert.Equal(["TPE@2330", "NAS@NVDA", "NAS@NVDA"],
result.Rows.Select(value => value.Identity!.Symbol));
Assert.Equal(2, executor.Calls.Count);
}
[Fact]
public async Task ResolveAsync_FailsClosedPerRowForMissingAmbiguousAndInvalidData()
{
var executor = new RecordingExecutor(call =>
{
var name = Assert.IsType<string>(Assert.Single(call.Spec.Parameters).Value);
return name switch
{
"MISSING" => IdentityTable(),
"AMBIGUOUS" => IdentityTable(
(name, "NAS@ONE", "US", "1"),
(name, "TPE@TWO", "TW", "1")),
"INVALID" => IdentityTable((name, "bad symbol!", "US", "1")),
_ => throw new InvalidOperationException(name)
};
});
var result = await new LegacyNamedForeignStockRestoreService(executor).ResolveAsync(
[
Row(1, "MISSING"),
Row(2, "AMBIGUOUS"),
Row(3, "INVALID")
]);
Assert.Equal(
new LegacyNamedForeignStockRestoreFailure?[]
{
LegacyNamedForeignStockRestoreFailure.IdentityNotFound,
LegacyNamedForeignStockRestoreFailure.IdentityAmbiguous,
LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid
},
result.Rows.Select(value => value.Failure));
Assert.All(result.Rows, value => Assert.False(value.IsResolved));
Assert.Null(result.Rows[0].DatabaseIssue);
Assert.Null(result.Rows[1].DatabaseIssue);
Assert.Equal(
LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsciiPunctuation,
result.Rows[2].DatabaseIssue);
}
[Theory]
[InlineData("FOREIGN_STOCK", "1열판기본", "", "1/1", "")]
[InlineData("해외종목", "CURRENT", "", "1/1", "")]
[InlineData("해외종목", "캔들그래프", "1년", "1/1", "")]
[InlineData("해외종목", "캔들그래프", "5일", "1/2", "")]
[InlineData("해외종목", "캔들그래프", "5일", "1/1", "NAS@NVDA|US|1")]
public async Task ResolveAsync_RejectsEverythingOutsideExactLegacyGrammarWithoutQuery(
string groupCode,
string graphicType,
string subtype,
string pageText,
string dataCode)
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var row = Row(
groupCode: groupCode,
graphicType: graphicType,
subtype: subtype,
pageText: pageText,
dataCode: dataCode);
Assert.False(LegacyNamedForeignStockRestoreService.IsCandidate(row));
var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
.ResolveAsync([row])).Rows);
Assert.Equal(LegacyNamedForeignStockRestoreFailure.InvalidRow, outcome.Failure);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task ResolveAsync_PreservesDisabledRowAndTaiwanNation()
{
var executor = new RecordingExecutor(_ =>
IdentityTable(("TSMC", "TPE@2330", "TW", "1")));
var row = Row(subject: "TSMC", isEnabled: false);
var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
.ResolveAsync([row])).Rows);
Assert.True(outcome.IsResolved);
Assert.Equal("TW", outcome.Identity!.NationCode);
}
[Fact]
public async Task ResolveAsync_AcceptsBoundProviderSymbolContainingHangulLetters()
{
const string symbol = "NAS@해외 종목1";
var executor = new RecordingExecutor(_ =>
IdentityTable(("NVIDIA", symbol, "US", "1")));
var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
.ResolveAsync([Row()])).Rows);
Assert.True(outcome.IsResolved);
Assert.Equal(symbol, outcome.Identity!.Symbol);
Assert.Null(outcome.DatabaseIssue);
}
[Fact]
public async Task ResolveAsync_AcceptsSafeSupplementaryScalarsConsistentlyWithWebContract()
{
const string inputName = "회사🚀";
const string symbol = "NAS@𐐀1";
var executor = new RecordingExecutor(_ =>
IdentityTable((inputName, symbol, "US", "1")));
var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
.ResolveAsync([Row(subject: inputName)])).Rows);
Assert.True(outcome.IsResolved);
Assert.Equal(inputName, outcome.Identity!.InputName);
Assert.Equal(symbol, outcome.Identity.Symbol);
Assert.Null(outcome.DatabaseIssue);
}
[Theory]
[InlineData("NAS|NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolDataDelimiter)]
[InlineData("NAS^NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolCaret)]
[InlineData(" NAS@NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical)]
[InlineData("NAS@NVDA ", LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical)]
[InlineData("NAS!NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsciiPunctuation)]
[InlineData("NAS\nNVDA", LegacyNamedForeignStockDatabaseIssue.SymbolNotCanonical)]
[InlineData("NAS😀NVDA", LegacyNamedForeignStockDatabaseIssue.SymbolNonAsciiOther)]
public async Task ResolveAsync_DiagnosticReasonNeverPrintsOrRelaxesUnsafeSymbol(
string symbol,
LegacyNamedForeignStockDatabaseIssue expectedIssue)
{
var executor = new RecordingExecutor(_ =>
IdentityTable(("NVIDIA", symbol, "US", "1")));
var outcome = Assert.Single((await new LegacyNamedForeignStockRestoreService(executor)
.ResolveAsync([Row()])).Rows);
Assert.False(outcome.IsResolved);
Assert.Equal(LegacyNamedForeignStockRestoreFailure.DatabaseDataInvalid, outcome.Failure);
Assert.Equal(expectedIssue, outcome.DatabaseIssue);
Assert.DoesNotContain(symbol, outcome.ToString(), StringComparison.Ordinal);
}
[Fact]
public async Task ResolveAsync_UsesInjectedClockForCorrelatedBatchTimestamp()
{
var now = new DateTimeOffset(2026, 7, 12, 3, 4, 5, TimeSpan.Zero);
var executor = new RecordingExecutor(_ =>
IdentityTable(("NVIDIA", "NAS@NVDA", "US", "1")));
var service = new LegacyNamedForeignStockRestoreService(
executor,
new FixedTimeProvider(now));
var result = await service.ResolveAsync([Row()]);
Assert.Equal(now, result.RetrievedAt);
}
[Fact]
public async Task ResolveAsync_RejectsMalformedBatchAndNeverRetriesFailure()
{
var failed = new RecordingExecutor(_ => throw new DataException("one read failure"));
var service = new LegacyNamedForeignStockRestoreService(failed);
await Assert.ThrowsAsync<DataException>(() => service.ResolveAsync([Row()]));
Assert.Single(failed.Calls);
var unused = new RecordingExecutor(_ => throw new InvalidOperationException());
service = new LegacyNamedForeignStockRestoreService(unused);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ResolveAsync([]));
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync([Row(4), Row(4)]));
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync([null!]));
Assert.Empty(unused.Calls);
}
[Fact]
public async Task ResolveAsync_HonorsCancellationWithoutASecondQuery()
{
using var cancellation = new CancellationTokenSource();
var executor = new RecordingExecutor(call =>
{
cancellation.Cancel();
var name = Assert.IsType<string>(Assert.Single(call.Spec.Parameters).Value);
return IdentityTable((name, "NAS@NVDA", "US", "1"));
}, throwBeforeHandlerWhenCanceled: false);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
new LegacyNamedForeignStockRestoreService(executor).ResolveAsync(
[
Row(0),
Row(1, "TSMC")
],
cancellation.Token));
Assert.Single(executor.Calls);
}
private static LegacyNamedForeignStockRestoreRow Row(
int itemIndex = 0,
string subject = "NVIDIA",
bool isEnabled = true,
string groupCode = "해외종목",
string graphicType = "1열판기본",
string subtype = "",
string pageText = "1/1",
string dataCode = "") =>
new(
itemIndex,
isEnabled,
groupCode,
subject,
graphicType,
subtype,
pageText,
dataCode);
private static DataTable IdentityTable(
params (string InputName, string Symbol, string Nation, string Fdtc)[] rows)
{
var table = new DataTable();
table.Columns.Add("F_INPUT_NAME", typeof(string));
table.Columns.Add("F_SYMB", typeof(string));
table.Columns.Add("F_NATC", typeof(string));
table.Columns.Add("F_FDTC", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(row.InputName, row.Symbol, row.Nation, row.Fdtc);
}
return table;
}
private sealed record RecordedCall(
DataSourceKind Source,
string QueryName,
DataQuerySpec Spec,
CancellationToken CancellationToken);
private sealed class FixedTimeProvider(DateTimeOffset value) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => value;
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
private sealed class RecordingExecutor(
Func<RecordedCall, DataTable> handler,
bool throwBeforeHandlerWhenCanceled = true) : IDataQueryExecutor
{
private int _stringSqlCallCount;
public List<RecordedCall> Calls { get; } = [];
public int StringSqlCallCount => Volatile.Read(ref _stringSqlCallCount);
public DataTable Execute(DataSourceKind source, string tableName, string query)
{
Interlocked.Increment(ref _stringSqlCallCount);
throw new InvalidOperationException();
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _stringSqlCallCount);
throw new InvalidOperationException();
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
if (throwBeforeHandlerWhenCanceled)
{
cancellationToken.ThrowIfCancellationRequested();
}
query.ValidateFor(source);
var call = new RecordedCall(source, tableName, query, cancellationToken);
Calls.Add(call);
return Task.FromResult(handler(call).Copy());
}
}
}

View File

@@ -0,0 +1,406 @@
#nullable enable
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyNamedKrxThemeRestoreServiceTests
{
public static TheoryData<string, string, string, string, int, string> SupportedRows =>
new()
{
{ "5단 표그래프", "테마-현재가(입력순)", "five-row-current", "s5074", 5, "INPUT_ORDER" },
{ "5단 표그래프", "테마-현재가(상승률순)", "five-row-current", "s5074", 5, "GAIN_DESC" },
{ "5단 표그래프", "테마-현재가(하락률순)", "five-row-current", "s5074", 5, "GAIN_ASC" },
{ "5단 표그래프", "테마-현재가", "five-row-current", "s5074", 5, "GAIN_ASC" },
{ "5단 표그래프", "테마-예상체결가(입력순)", "five-row-expected", "s5074", 5, "INPUT_ORDER" },
{ "5단 표그래프", "테마-예상체결가(상승률순)", "five-row-expected", "s5074", 5, "GAIN_DESC" },
{ "5단 표그래프", "테마-예상체결가(하락률순)", "five-row-expected", "s5074", 5, "GAIN_ASC" },
{ "5단 표그래프", "테마-예상체결가", "five-row-expected", "s5074", 5, "GAIN_ASC" },
{ "6종목 현재가", "테마-현재가(입력순)", "six-row-current", "s5077", 6, "INPUT_ORDER" },
{ "6종목 현재가", "테마-현재가(상승률순)", "six-row-current", "s5077", 6, "GAIN_DESC" },
{ "6종목 현재가", "테마-현재가(하락률순)", "six-row-current", "s5077", 6, "GAIN_ASC" },
{ "6종목 현재가", "테마-현재가", "six-row-current", "s5077", 6, "GAIN_ASC" },
{ "12종목 현재가", "테마-현재가(입력순)", "twelve-row-current", "s5088", 12, "INPUT_ORDER" },
{ "12종목 현재가", "테마-현재가(상승률순)", "twelve-row-current", "s5088", 12, "GAIN_DESC" },
{ "12종목 현재가", "테마-현재가(하락률순)", "twelve-row-current", "s5088", 12, "GAIN_ASC" },
{ "12종목 현재가", "테마-현재가", "twelve-row-current", "s5088", 12, "GAIN_ASC" }
};
[Theory]
[MemberData(nameof(SupportedRows))]
public async Task ResolveAsync_PreservesOriginalActionsAndFinalSortBranchWhileRemappingCode(
string graphicType,
string subtype,
string actionId,
string builderKey,
int pageSize,
string sort)
{
var executor = GoodExecutor("반도체", "87654321", previewCount: 2);
var service = new LegacyNamedKrxThemeRestoreService(executor);
var outcome = Assert.Single((await service.ResolveAsync(
[Row(graphicType: graphicType, subtype: subtype)])).Rows);
Assert.True(outcome.IsResolved);
Assert.Null(outcome.Failure);
Assert.NotNull(outcome.Identity);
Assert.Equal(actionId, outcome.Identity.ActionId);
Assert.Equal(builderKey, outcome.Identity.BuilderKey);
Assert.Equal(builderKey[1..], outcome.Identity.CutCode);
Assert.Equal(pageSize, outcome.Identity.PageSize);
Assert.Equal(sort, outcome.Identity.Sort);
Assert.Equal("반도체", outcome.Identity.ThemeTitle);
Assert.Equal("0123", outcome.Identity.StoredThemeCode);
Assert.Equal("87654321", outcome.Identity.CurrentThemeCode);
Assert.True(outcome.Identity.CodeWasRemapped);
Assert.Equal(2, outcome.Identity.PreviewItemCount);
Assert.False(outcome.Identity.PreviewIsTruncated);
Assert.Equal(2, executor.Calls.Count);
var identityCall = executor.Calls[0];
Assert.Equal(DataSourceKind.Oracle, identityCall.Source);
Assert.Equal(LegacyNamedKrxThemeRestoreService.IdentityQueryName, identityCall.QueryName);
identityCall.Spec.ValidateFor(DataSourceKind.Oracle);
Assert.Contains("s.SB_TITLE = :theme_title", identityCall.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("d.SB_CODE = s.SB_CODE", identityCall.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ROWNUM <= 2", identityCall.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("반도체", identityCall.Spec.Sql, StringComparison.Ordinal);
Assert.Collection(identityCall.Spec.Parameters, parameter =>
{
Assert.Equal("theme_title", parameter.Name);
Assert.Equal("반도체", parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
});
var previewCall = executor.Calls[1];
Assert.Equal(DataSourceKind.Oracle, previewCall.Source);
Assert.Equal("THEME_PREVIEW_KRX", previewCall.QueryName);
Assert.Equal("87654321", previewCall.Spec.Parameters[0].Value);
Assert.Equal("반도체", previewCall.Spec.Parameters[1].Value);
Assert.Equal(241, previewCall.Spec.Parameters[2].Value);
Assert.Equal(0, executor.StringSqlCallCount);
Assert.All(executor.Calls, call =>
{
var sql = call.Spec.Sql.TrimStart().ToUpperInvariant();
Assert.StartsWith("SELECT", sql, StringComparison.Ordinal);
Assert.DoesNotContain("INSERT ", sql, StringComparison.Ordinal);
Assert.DoesNotContain("UPDATE ", sql, StringComparison.Ordinal);
Assert.DoesNotContain("DELETE ", sql, StringComparison.Ordinal);
Assert.DoesNotContain("MERGE ", sql, StringComparison.Ordinal);
});
}
[Fact]
public async Task ResolveAsync_CachesExactTitleButPreservesBatchOrderAndStoredCodes()
{
var executor = GoodExecutor("AI", "90000001", previewCount: 1);
var service = new LegacyNamedKrxThemeRestoreService(executor);
var rows = new[]
{
Row(itemIndex: 7, subject: "AI", dataCode: "111"),
Row(itemIndex: 2, subject: "AI", dataCode: "2222", graphicType: "6종목 현재가")
};
var result = await service.ResolveAsync(rows);
Assert.Equal([7, 2], result.Rows.Select(row => row.ItemIndex));
Assert.Equal(["111", "2222"], result.Rows.Select(row => row.Identity!.StoredThemeCode));
Assert.All(result.Rows, row => Assert.Equal("90000001", row.Identity!.CurrentThemeCode));
Assert.Equal(2, executor.Calls.Count);
}
[Fact]
public async Task ResolveAsync_AcceptsSafeSupplementaryThemeTitleConsistentlyWithWebContract()
{
const string title = "우주🚀";
var outcome = Assert.Single((await new LegacyNamedKrxThemeRestoreService(
GoodExecutor(title, "90000001", previewCount: 1))
.ResolveAsync([Row(subject: title)])).Rows);
Assert.True(outcome.IsResolved);
Assert.Equal(title, outcome.Identity!.ThemeTitle);
}
[Theory]
[InlineData("테마_NXT", "반도체", "1/1", "0123")]
[InlineData("테마", "반도체(NXT)", "1/1", "0123")]
[InlineData("테마", " 반도체", "1/1", "0123")]
[InlineData("테마", "반도체", "01/1", "0123")]
[InlineData("테마", "반도체", "1/1", "R0123")]
public async Task ResolveAsync_InvalidRowsFailWithoutDatabaseAccess(
string groupCode,
string subject,
string pageText,
string dataCode)
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var outcome = Assert.Single((await new LegacyNamedKrxThemeRestoreService(executor)
.ResolveAsync([Row(
groupCode: groupCode,
subject: subject,
pageText: pageText,
dataCode: dataCode)])).Rows);
Assert.Equal(LegacyNamedKrxThemeRestoreFailure.InvalidRow, outcome.Failure);
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("6종목 현재가", "테마-예상체결가(입력순)")]
[InlineData("12종목 현재가", "테마-예상체결가")]
[InlineData("네모그래프", "테마-현재가(입력순)")]
[InlineData("5단 표그래프", "테마-현재가(거래량순)")]
public async Task ResolveAsync_UnsupportedActionsFailClosedPerRow(
string graphicType,
string subtype)
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var outcome = Assert.Single((await new LegacyNamedKrxThemeRestoreService(executor)
.ResolveAsync([Row(graphicType: graphicType, subtype: subtype)])).Rows);
Assert.Equal(LegacyNamedKrxThemeRestoreFailure.UnsupportedAction, outcome.Failure);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task ResolveAsync_ReportsNotFoundAndAmbiguousPerExactTitleWithoutPreview()
{
var executor = new RecordingExecutor(call =>
{
var title = Assert.IsType<string>(call.Spec.Parameters[0].Value);
return title == "없음"
? IdentityTable()
: IdentityTable(
(title, "111", "KRX", "1"),
(title, "222", "KRX", "1"));
});
var result = await new LegacyNamedKrxThemeRestoreService(executor).ResolveAsync(
[
Row(itemIndex: 0, subject: "없음"),
Row(itemIndex: 1, subject: "중복")
]);
Assert.Equal(
[
LegacyNamedKrxThemeRestoreFailure.IdentityNotFound,
LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous
],
result.Rows.Select(row => row.Failure));
Assert.Equal(2, executor.Calls.Count);
}
[Fact]
public async Task ResolveAsync_RequiresNonemptyValidatedKrxPreview()
{
var executor = new RecordingExecutor(call => call.QueryName switch
{
LegacyNamedKrxThemeRestoreService.IdentityQueryName =>
IdentityTable(("반도체", "00000001", "KRX", "1")),
"THEME_PREVIEW_KRX" => EmptyPreviewTable("반도체", "00000001"),
_ => throw new InvalidOperationException(call.QueryName)
});
var outcome = Assert.Single((await new LegacyNamedKrxThemeRestoreService(executor)
.ResolveAsync([Row()])).Rows);
Assert.Equal(LegacyNamedKrxThemeRestoreFailure.PreviewEmpty, outcome.Failure);
Assert.False(outcome.IsResolved);
Assert.Equal("00000001", outcome.ObservedCurrentThemeCode);
Assert.Equal(2, executor.Calls.Count);
}
[Fact]
public async Task ResolveAsync_PreservesValidatedPreviewTruncationEvidence()
{
var executor = GoodExecutor("반도체", "00000001", previewCount: 241);
var identity = Assert.Single((await new LegacyNamedKrxThemeRestoreService(executor)
.ResolveAsync([Row()])).Rows).Identity!;
Assert.Equal(LegacyThemeSelectionService.MaximumPreviewItems, identity.PreviewItemCount);
Assert.True(identity.PreviewIsTruncated);
}
[Fact]
public async Task ResolveAsync_FailsClosedOnInvalidDatabaseSchemaCodeOrPreview()
{
var wrongSchema = Assert.Single((await new LegacyNamedKrxThemeRestoreService(
new RecordingExecutor(_ => new DataTable())).ResolveAsync([Row()])).Rows);
Assert.Equal(LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid, wrongSchema.Failure);
var duplicateCode = Assert.Single((await new LegacyNamedKrxThemeRestoreService(
new RecordingExecutor(_ =>
IdentityTable(("반도체", "00000001", "KRX", "2"))))
.ResolveAsync([Row()])).Rows);
Assert.Equal(LegacyNamedKrxThemeRestoreFailure.IdentityAmbiguous, duplicateCode.Failure);
var invalidPreviewExecutor = new RecordingExecutor(call => call.QueryName switch
{
LegacyNamedKrxThemeRestoreService.IdentityQueryName =>
IdentityTable(("반도체", "00000001", "KRX", "1")),
"THEME_PREVIEW_KRX" => new DataTable(),
_ => throw new InvalidOperationException(call.QueryName)
});
var invalidPreview = Assert.Single((await new LegacyNamedKrxThemeRestoreService(
invalidPreviewExecutor).ResolveAsync([Row()])).Rows);
Assert.Equal(
LegacyNamedKrxThemeRestoreFailure.DatabaseDataInvalid,
invalidPreview.Failure);
Assert.Equal("00000001", invalidPreview.ObservedCurrentThemeCode);
}
[Fact]
public async Task ResolveAsync_PropagatesReadFailureOnceAndHonorsCancellation()
{
var failing = new RecordingExecutor(_ => throw new DataException("simulated"));
await Assert.ThrowsAsync<DataException>(() =>
new LegacyNamedKrxThemeRestoreService(failing).ResolveAsync([Row()]));
Assert.Single(failing.Calls);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var canceled = new RecordingExecutor(_ => throw new InvalidOperationException());
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
new LegacyNamedKrxThemeRestoreService(canceled)
.ResolveAsync([Row()], cancellation.Token));
Assert.Empty(canceled.Calls);
}
[Fact]
public async Task ResolveAsync_RejectsInvalidBatchAndAllowsDisabledRows()
{
var executor = GoodExecutor("반도체", "00000001", previewCount: 1);
var service = new LegacyNamedKrxThemeRestoreService(executor);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ResolveAsync([]));
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
[Row(itemIndex: 3), Row(itemIndex: 3)]));
var outcome = Assert.Single((await service.ResolveAsync([Row(isEnabled: false)])).Rows);
Assert.True(outcome.IsResolved);
Assert.True(LegacyNamedKrxThemeRestoreService.IsCandidate(Row(isEnabled: false)));
Assert.False(LegacyNamedKrxThemeRestoreService.IsCandidate(Row(groupCode: "테마_NXT")));
}
private static RecordingExecutor GoodExecutor(
string title,
string code,
int previewCount) =>
new(call => call.QueryName switch
{
LegacyNamedKrxThemeRestoreService.IdentityQueryName =>
IdentityTable((title, code, "KRX", "1")),
"THEME_PREVIEW_KRX" => PreviewTable(title, code, previewCount),
_ => throw new InvalidOperationException(call.QueryName)
});
private static LegacyNamedKrxThemeRestoreRow Row(
int itemIndex = 0,
bool isEnabled = true,
string groupCode = "테마",
string subject = "반도체",
string graphicType = "5단 표그래프",
string subtype = "테마-현재가(입력순)",
string pageText = "1/1",
string dataCode = "0123") =>
new(itemIndex, isEnabled, groupCode, subject, graphicType, subtype, pageText, dataCode);
private static DataTable IdentityTable(
params (string Title, string Code, string Market, string CodeCount)[] rows)
{
var table = new DataTable();
table.Columns.Add("THEME_TITLE", typeof(string));
table.Columns.Add("THEME_CODE", typeof(string));
table.Columns.Add("THEME_MARKET", typeof(string));
table.Columns.Add("CODE_IDENTITY_COUNT", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(row.Title, row.Code, row.Market, row.CodeCount);
}
return table;
}
private static DataTable PreviewTable(string title, string code, int count)
{
var table = CreatePreviewTable();
for (var index = 0; index < count; index++)
{
table.Rows.Add(
title,
code,
"KRX",
$"종목{index + 1}",
$"P{index + 1:D6}",
index.ToString());
}
return table;
}
private static DataTable EmptyPreviewTable(string title, string code)
{
var table = CreatePreviewTable();
table.Rows.Add(title, code, "KRX", DBNull.Value, DBNull.Value, DBNull.Value);
return table;
}
private static DataTable CreatePreviewTable()
{
var table = new DataTable();
table.Columns.Add("THEME_TITLE", typeof(string));
table.Columns.Add("THEME_CODE", typeof(string));
table.Columns.Add("THEME_MARKET", typeof(string));
table.Columns.Add("ITEM_NAME", typeof(string));
table.Columns.Add("ITEM_CODE", typeof(string));
table.Columns.Add("ITEM_INDEX", typeof(string));
return table;
}
private sealed record RecordedCall(
DataSourceKind Source,
string QueryName,
DataQuerySpec Spec,
CancellationToken CancellationToken);
private sealed class RecordingExecutor(Func<RecordedCall, DataTable> handler)
: IDataQueryExecutor
{
public List<RecordedCall> Calls { get; } = [];
public int StringSqlCallCount { get; private set; }
public DataTable Execute(
DataSourceKind source,
string tableName,
string query)
{
StringSqlCallCount++;
throw new InvalidOperationException("String SQL must not be used.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string sql,
CancellationToken cancellationToken = default)
{
StringSqlCallCount++;
throw new InvalidOperationException("String SQL must not be used.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var call = new RecordedCall(source, tableName, query, cancellationToken);
Calls.Add(call);
return Task.FromResult(handler(call));
}
}
}

View File

@@ -0,0 +1,238 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyNamedManualRestoreAuditTests
{
[Theory]
[InlineData("코스피", "주요매출 구성", ManualFinancialScreenKind.RevenueComposition, StockMarket.Kospi)]
[InlineData("코스닥", "성장성 지표", ManualFinancialScreenKind.GrowthMetrics, StockMarket.Kosdaq)]
[InlineData("코스피_NXT", "매출액", ManualFinancialScreenKind.Sales, StockMarket.NxtKospi)]
[InlineData("코스닥_NXT", "영업이익", ManualFinancialScreenKind.OperatingProfit, StockMarket.NxtKosdaq)]
public void ClassifyHistoricalFinancialGrammar(
string group,
string graphic,
ManualFinancialScreenKind expectedScreen,
StockMarket expectedMarket)
{
var candidate = LegacyNamedManualRestoreClassifier.Classify(
Row(group, expectedMarket is StockMarket.NxtKospi or StockMarket.NxtKosdaq
? "테스트(NXT)"
: "테스트", graphic, string.Empty, "1/1", string.Empty));
Assert.NotNull(candidate);
Assert.Equal(LegacyNamedManualRestoreKind.Financial, candidate.Kind);
Assert.True(candidate.IsHistorical);
Assert.Equal(expectedScreen, candidate.FinancialScreen);
Assert.Equal(expectedMarket, candidate.Market);
Assert.Equal("테스트" + (expectedMarket is StockMarket.NxtKospi or StockMarket.NxtKosdaq
? "(NXT)"
: string.Empty), candidate.StockName);
Assert.Equal("Named manual restore candidate (Financial)", candidate.ToString());
}
[Theory]
[InlineData("KOSPI", "REVENUE_COMPOSITION", ManualFinancialScreenKind.RevenueComposition)]
[InlineData("KOSDAQ", "GROWTH_METRICS", ManualFinancialScreenKind.GrowthMetrics)]
[InlineData("NXT_KOSPI", "SALES", ManualFinancialScreenKind.Sales)]
[InlineData("NXT_KOSDAQ", "OPERATING_PROFIT", ManualFinancialScreenKind.OperatingProfit)]
public void ClassifyCanonicalFinancialGrammar(
string group,
string graphic,
ManualFinancialScreenKind expectedScreen)
{
var candidate = LegacyNamedManualRestoreClassifier.Classify(
Row(group, group.StartsWith("NXT_", StringComparison.Ordinal)
? "테스트(NXT)"
: "테스트", graphic, string.Empty, "1/1", string.Empty));
Assert.NotNull(candidate);
Assert.False(candidate.IsHistorical);
Assert.Equal(expectedScreen, candidate.FinancialScreen);
}
[Theory]
[InlineData("개인 순매도 상위(수동)", S5025ManualAudience.Individual)]
[InlineData("외국인 순매도 상위(수동)", S5025ManualAudience.Foreign)]
[InlineData("기관 순매도 상위(수동)", S5025ManualAudience.Institution)]
public void ClassifyHistoricalNetSellGrammar(
string group,
S5025ManualAudience expected)
{
var candidate = LegacyNamedManualRestoreClassifier.Classify(
Row(group, "지수", "순매도 상위", "순매도 상위", "1/1", string.Empty));
Assert.NotNull(candidate);
Assert.Equal(LegacyNamedManualRestoreKind.NetSell, candidate.Kind);
Assert.True(candidate.IsHistorical);
Assert.Equal(expected, candidate.Audience);
}
[Theory]
[InlineData("INDIVIDUAL", S5025ManualAudience.Individual)]
[InlineData("FOREIGN", S5025ManualAudience.Foreign)]
[InlineData("INSTITUTION", S5025ManualAudience.Institution)]
public void ClassifyCanonicalNetSellGrammar(
string group,
S5025ManualAudience expected)
{
var candidate = LegacyNamedManualRestoreClassifier.Classify(
Row(group, "INDEX", "MANUAL_NET_SELL", "MANUAL_NET_SELL", "1/1", string.Empty));
Assert.NotNull(candidate);
Assert.False(candidate.IsHistorical);
Assert.Equal(expected, candidate.Audience);
}
[Fact]
public void ClassifyHistoricalAndVersionedViGrammar()
{
var historical = LegacyNamedManualRestoreClassifier.Classify(
Row(string.Empty, "가,나,다,라,마,바", "5단 표그래프", "VI 발동(수동)", "1/2", string.Empty));
var version = new string('a', 64);
var canonical = LegacyNamedManualRestoreClassifier.Classify(
Row("PAGED_VI", TrustedViManualReference.SubjectSentinel, "INPUT_ORDER", "CURRENT", "1/2", version));
Assert.NotNull(historical);
Assert.True(historical.IsHistorical);
Assert.Equal(6, historical.HistoricalViNames!.Count);
Assert.Equal(2, historical.ExpectedPageCount);
Assert.NotNull(canonical);
Assert.False(canonical.IsHistorical);
Assert.Equal(version, canonical.ExpectedViVersion);
Assert.Equal(2, canonical.ExpectedPageCount);
}
[Theory]
[InlineData("1/1", "가,나,다,라,마,바")]
[InlineData("1/21", "가")]
[InlineData("01/1", "가")]
public void RejectViPageGrammarThatCannotMatchMaterialization(
string page,
string subject)
{
Assert.Null(LegacyNamedManualRestoreClassifier.Classify(
Row(string.Empty, subject, "5단 표그래프", "VI 발동(수동)", page, string.Empty)));
}
[Fact]
public void FinancialFreshProofRequiresTwoStableReadsAndOneMasterIdentity()
{
var candidate = Assert.IsType<LegacyNamedManualRestoreCandidate>(
LegacyNamedManualRestoreClassifier.Classify(
Row("코스피", "테스트", "매출액", string.Empty, "1/1", string.Empty)));
var first = SalesSnapshot("테스트", 'A');
var second = SalesSnapshot("테스트", 'A');
var search = Search(
"테스트",
new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "테스트", "A0001"));
Assert.Equal(
LegacyNamedManualFreshProofFailure.None,
LegacyNamedManualFreshProof.EvaluateFinancial(candidate, first, second, search));
Assert.Equal(
LegacyNamedManualFreshProofFailure.FinancialSnapshotChanged,
LegacyNamedManualFreshProof.EvaluateFinancial(
candidate,
first,
SalesSnapshot("테스트", 'B'),
search));
Assert.Equal(
LegacyNamedManualFreshProofFailure.FinancialStockIdentityNotUnique,
LegacyNamedManualFreshProof.EvaluateFinancial(
candidate,
first,
second,
Search(
"테스트",
new StockSearchItem(StockMarket.Kospi, DataSourceKind.Oracle, "테스트", "A0001"),
new StockSearchItem(StockMarket.Kosdaq, DataSourceKind.Oracle, "테스트", "A0002"))));
}
[Fact]
public void FinancialFreshProofUsesOriginalNxtDisplayIdentityForSearch()
{
var candidate = Assert.IsType<LegacyNamedManualRestoreCandidate>(
LegacyNamedManualRestoreClassifier.Classify(
Row("코스피_NXT", "테스트(NXT)", "매출액", string.Empty, "1/1", string.Empty)));
Assert.Equal("테스트", LegacyNamedManualFreshProof.FinancialStockSearchQuery(candidate));
Assert.Equal(
LegacyNamedManualFreshProofFailure.None,
LegacyNamedManualFreshProof.EvaluateFinancial(
candidate,
SalesSnapshot("테스트(NXT)", 'A'),
SalesSnapshot("테스트(NXT)", 'A'),
Search(
"테스트",
new StockSearchItem(
StockMarket.NxtKospi,
DataSourceKind.MariaDb,
"테스트(NXT)",
"A0001"))));
}
[Fact]
public void ManualFileFreshProofRejectsShapeOrIdentityDrift()
{
var netSell = Assert.IsType<LegacyNamedManualRestoreCandidate>(
LegacyNamedManualRestoreClassifier.Classify(
Row("INDIVIDUAL", "INDEX", "MANUAL_NET_SELL", "MANUAL_NET_SELL", "1/1", string.Empty)));
var rows = Enumerable.Range(0, 5)
.Select(_ => new S5025TrustedManualRow("", "", "", ""))
.ToArray();
Assert.Equal(
LegacyNamedManualFreshProofFailure.None,
LegacyNamedManualFreshProof.EvaluateNetSell(netSell, rows));
Assert.Equal(
LegacyNamedManualFreshProofFailure.NetSellSnapshotInvalid,
LegacyNamedManualFreshProof.EvaluateNetSell(netSell, rows.Take(4).ToArray()));
var vi = Assert.IsType<LegacyNamedManualRestoreCandidate>(
LegacyNamedManualRestoreClassifier.Classify(
Row(string.Empty, "가,나,다,라,마,바", "5단 표그래프", "VI 발동(수동)", "1/2", string.Empty)));
var snapshot = new TrustedViStockNameSnapshot(
["가", "나", "다", "라", "마", "바"],
new string('a', 64));
Assert.Equal(
LegacyNamedManualFreshProofFailure.None,
LegacyNamedManualFreshProof.EvaluateVi(vi, snapshot));
Assert.Equal(
LegacyNamedManualFreshProofFailure.ViHistoricalNamesChanged,
LegacyNamedManualFreshProof.EvaluateVi(
vi,
snapshot with { StockNames = ["가", "나", "다", "라", "마", "변경"] }));
}
private static LegacyPListAuditRow Row(
string group,
string subject,
string graphic,
string subtype,
string page,
string dataCode)
{
var fields = new[] { "1", group, subject, graphic, subtype, page, dataCode };
return new LegacyPListAuditRow(
"20260712",
"감사",
0,
true,
Array.AsReadOnly(fields),
string.Join('^', fields),
page.Replace(" ", string.Empty, StringComparison.Ordinal));
}
private static ManualFinancialSnapshot SalesSnapshot(string stockName, char version) =>
new(
new ManualSalesRecord(
new ManualFinancialIdentity(ManualFinancialScreenKind.Sales, stockName),
Enumerable.Range(1, 6)
.Select(index => new ManualQuarterValue($"Q{index}", index))
.ToArray()),
new string(version, 64));
private static StockSearchResult Search(string query, params StockSearchItem[] items) =>
new(query, DateTimeOffset.UnixEpoch, Array.AsReadOnly(items), IsTruncated: false);
}

View File

@@ -0,0 +1,222 @@
#nullable enable
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyNxtThemeJoinAuditServiceTests
{
[Fact]
public async Task AuditAsync_UsesBoundAggregateReadsAndReturnsNoIdentifiers()
{
var executor = new RecordingExecutor(call =>
{
var current = Assert.IsType<string>(call.Spec.Parameters[0].Value);
return current == "90000001"
? Table("3", "0", "2", "2", "1", "0", "0", "0", "0", "0", "0", "3",
"2", "0", "1", "0")
: Table("4", "1", "0", "0", "0", "1", "1", "1", "0", "0", "0", "4",
"0", "0", "0", "0");
});
var service = new LegacyNxtThemeJoinAuditService(
executor,
new FixedTimeProvider(new DateTimeOffset(2026, 7, 12, 3, 4, 5, TimeSpan.Zero)));
var result = await service.AuditAsync(
[
new LegacyNxtThemeJoinAuditIdentity("90000001", "111"),
new LegacyNxtThemeJoinAuditIdentity("90000002", "222")
]);
Assert.Equal(new DateTimeOffset(2026, 7, 12, 3, 4, 5, TimeSpan.Zero), result.RetrievedAt);
Assert.Equal(2, result.Themes.Count);
Assert.Equal(0, result.Themes[0].InputIndex);
Assert.Equal(3, result.Themes[0].RawCurrentItemCount);
Assert.Equal(2, result.Themes[0].CurrentOriginalJoinCount);
Assert.Equal(1, result.Themes[0].CurrentLiveJoinCount);
Assert.Equal(2, result.Themes[0].CurrentKospiMasterJoinCount);
Assert.Equal(1, result.Themes[0].CurrentKospiOnlineJoinCount);
Assert.Equal(1, result.Themes[1].InputIndex);
Assert.Equal(4, result.Themes[1].RawCurrentItemCount);
Assert.Equal(1, result.Themes[1].RawStoredItemCount);
Assert.Equal(1, result.Themes[1].StoredLiveJoinCount);
Assert.Equal(2, executor.Calls.Count);
Assert.Equal(0, executor.StringSqlCallCount);
Assert.All(executor.Calls, call =>
{
Assert.Equal(DataSourceKind.MariaDb, call.Source);
Assert.Equal(LegacyNxtThemeJoinAuditService.QueryName, call.QueryName);
call.Spec.ValidateFor(DataSourceKind.MariaDb);
Assert.Contains(
"SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE",
call.Spec.Sql,
StringComparison.Ordinal);
Assert.Contains("RIGHT(i.SB_I_CODE, 6)", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("FROM N_STOCK q", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("FROM N_KOSDAQ_ONLINE q", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("AND EXISTS (", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain(
Assert.IsType<string>(call.Spec.Parameters[0].Value),
call.Spec.Sql,
StringComparison.Ordinal);
Assert.Collection(
call.Spec.Parameters,
current =>
{
Assert.Equal("current_theme_code", current.Name);
Assert.Equal(DbType.String, current.DbType);
},
stored =>
{
Assert.Equal("stored_theme_code", stored.Name);
Assert.Equal(DbType.String, stored.DbType);
});
});
}
[Theory]
[MemberData(nameof(InvalidIdentities))]
public async Task AuditAsync_RejectsInvalidOrDuplicateIdentitiesBeforeDatabaseAccess(
IReadOnlyList<LegacyNxtThemeJoinAuditIdentity> identities)
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyNxtThemeJoinAuditService(executor);
await Assert.ThrowsAnyAsync<ArgumentException>(() => service.AuditAsync(identities));
Assert.Empty(executor.Calls);
Assert.Equal(0, executor.StringSqlCallCount);
}
public static TheoryData<IReadOnlyList<LegacyNxtThemeJoinAuditIdentity>> InvalidIdentities =>
new()
{
Array.Empty<LegacyNxtThemeJoinAuditIdentity>(),
new LegacyNxtThemeJoinAuditIdentity[] { new("12", "123") },
new LegacyNxtThemeJoinAuditIdentity[] { new("123", "A123") },
new LegacyNxtThemeJoinAuditIdentity[]
{
new("123", "111"),
new("123", "222")
}
};
[Fact]
public async Task AuditAsync_FailsClosedOnWrongSchemaNonCanonicalCountOrInconsistentSubset()
{
var tables = new Queue<DataTable>(
[
new DataTable(),
Table("01", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0",
"0", "0", "0", "0"),
Table("1", "0", "1", "2", "0", "0", "0", "0", "0", "0", "0", "1",
"0", "0", "0", "0"),
Table("1", "0", "2", "0", "0", "0", "0", "0", "0", "0", "0", "1",
"0", "0", "0", "0"),
Table("1", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "1",
"2", "0", "0", "0"),
Table("2", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "2",
"1", "2", "0", "0")
]);
var service = new LegacyNxtThemeJoinAuditService(
new RecordingExecutor(_ => tables.Dequeue()));
var identity = new[] { new LegacyNxtThemeJoinAuditIdentity("123", "456") };
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
await Assert.ThrowsAsync<InvalidDataException>(() => service.AuditAsync(identity));
}
[Fact]
public async Task AuditAsync_HonorsPreCanceledTokenWithoutDatabaseAccess()
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
new LegacyNxtThemeJoinAuditService(executor).AuditAsync(
[new("123", "456")],
cancellation.Token));
Assert.Empty(executor.Calls);
}
private static DataTable Table(params string[] counts)
{
Assert.Equal(16, counts.Length);
var table = new DataTable();
table.Columns.Add("RAW_CURRENT_ITEM_COUNT", typeof(string));
table.Columns.Add("RAW_STORED_ITEM_COUNT", typeof(string));
table.Columns.Add("CURRENT_ORIGINAL_JOIN_COUNT", typeof(string));
table.Columns.Add("CURRENT_NON_STOPPED_JOIN_COUNT", typeof(string));
table.Columns.Add("CURRENT_LIVE_JOIN_COUNT", typeof(string));
table.Columns.Add("STORED_ORIGINAL_JOIN_COUNT", typeof(string));
table.Columns.Add("STORED_NON_STOPPED_JOIN_COUNT", typeof(string));
table.Columns.Add("STORED_LIVE_JOIN_COUNT", typeof(string));
table.Columns.Add("CURRENT_EXACT_JOIN_COUNT", typeof(string));
table.Columns.Add("CURRENT_STRIP_BOTH_JOIN_COUNT", typeof(string));
table.Columns.Add("CURRENT_RIGHT_SIX_JOIN_COUNT", typeof(string));
table.Columns.Add("CURRENT_VALID_ITEM_CODE_COUNT", typeof(string));
table.Columns.Add("CURRENT_KOSPI_MASTER_JOIN_COUNT", typeof(string));
table.Columns.Add("CURRENT_KOSDAQ_MASTER_JOIN_COUNT", typeof(string));
table.Columns.Add("CURRENT_KOSPI_ONLINE_JOIN_COUNT", typeof(string));
table.Columns.Add("CURRENT_KOSDAQ_ONLINE_JOIN_COUNT", typeof(string));
table.Rows.Add(counts.Cast<object>().ToArray());
return table;
}
private sealed record RecordedCall(
DataSourceKind Source,
string QueryName,
DataQuerySpec Spec,
CancellationToken CancellationToken);
private sealed class RecordingExecutor(Func<RecordedCall, DataTable> handler)
: IDataQueryExecutor
{
public List<RecordedCall> Calls { get; } = [];
public int StringSqlCallCount { get; private set; }
public DataTable Execute(DataSourceKind source, string tableName, string query)
{
StringSqlCallCount++;
throw new InvalidOperationException("String SQL must not be used.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string sql,
CancellationToken cancellationToken = default)
{
StringSqlCallCount++;
throw new InvalidOperationException("String SQL must not be used.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var call = new RecordedCall(source, tableName, query, cancellationToken);
Calls.Add(call);
return Task.FromResult(handler(call));
}
}
private sealed class FixedTimeProvider(DateTimeOffset value) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => value.ToUniversalTime();
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
}

View File

@@ -0,0 +1,171 @@
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyPListReadAuditParserTests
{
[Fact]
public void Parse_PreservesEveryOriginalFieldAndOnlyNormalizesDisplayPageSpaces()
{
const string text = "1^테마^반도체 장비^5단 표그래프^테마-현재가(입력순)^ 1 / 12 ^00123";
var result = LegacyPListReadAuditParser.Parse(
"00000001",
"아침 방송",
"0",
text);
Assert.True(result.IsSuccess);
var row = Assert.IsType<LegacyPListAuditRow>(result.Row);
Assert.Equal(text, row.RawListText);
Assert.Equal(text, string.Join('^', row.Fields));
Assert.True(row.IsLossless);
Assert.True(row.IsEnabled);
Assert.Equal("테마", row.GroupCode);
Assert.Equal("반도체 장비", row.Subject);
Assert.Equal(" 1 / 12 ", row.RawPageText);
Assert.Equal("1/12", row.PageTextForDisplay);
}
[Theory]
[InlineData("1^테마^반도체^5단^현재가^1/1", LegacyPListAuditParseFailure.InvalidFieldCount)]
[InlineData("2^테마^반도체^5단^현재가^1/1^1", LegacyPListAuditParseFailure.InvalidEnabledField)]
[InlineData("1^테마^반도체^5단^현재가^1/1^1^extra", LegacyPListAuditParseFailure.InvalidFieldCount)]
public void Parse_RejectsAnythingOutsideTheOriginalSevenFieldGrammar(
string text,
LegacyPListAuditParseFailure expected)
{
var result = LegacyPListReadAuditParser.Parse("00000001", "방송", "0", text);
Assert.False(result.IsSuccess);
Assert.Equal(expected, result.Failure);
Assert.Null(result.Row);
}
[Fact]
public void Parse_RejectsReplacementAndKnownCodepageDamageWithoutEchoingText()
{
var replacement = LegacyPListReadAuditParser.Parse(
"00000001", "방송", "0", "1^테마^반도체<EB8F84>^5단^현재가^1/1^1");
var mojibake = LegacyPListReadAuditParser.Parse(
"00000001", "방송", "0", "1^테마^정^5단^현재가^1/1^1");
Assert.Equal(LegacyPListAuditParseFailure.ReplacementCharacter, replacement.Failure);
Assert.Equal(LegacyPListAuditParseFailure.MojibakeSignal, mojibake.Failure);
}
[Fact]
public void Parse_RejectsUnpairedUtf16SurrogatesButPreservesValidScalarPairs()
{
var unpaired = LegacyPListReadAuditParser.Parse(
"00000001", "방송", "0", "1^테마^반도체\uD800^5단^현재가^1/1^1");
var validPair = LegacyPListReadAuditParser.Parse(
"00000001", "방송 📈", "0", "1^테마^반도체 📈^5단^현재가^1/1^1");
Assert.Equal(LegacyPListAuditParseFailure.UnsafeText, unpaired.Failure);
Assert.True(validPair.IsSuccess);
Assert.True(validPair.Row!.IsLossless);
}
[Theory]
[InlineData("0000001")]
[InlineData("0000000A")]
[InlineData(" 00000001")]
public void Parse_RequiresTheOriginalEightDigitProgramIdentity(string code)
{
var result = LegacyPListReadAuditParser.Parse(
code, "방송", "0", "1^테마^반도체^5단^현재가^1/1^1");
Assert.Equal(LegacyPListAuditParseFailure.InvalidProgramCode, result.Failure);
}
[Theory]
[InlineData("00")]
[InlineData("-1")]
[InlineData("1000")]
[InlineData(" 0")]
public void Parse_RequiresCanonicalBoundedItemIndex(string itemIndex)
{
var result = LegacyPListReadAuditParser.Parse(
"00000001", "방송", itemIndex, "1^테마^반도체^5단^현재가^1/1^1");
Assert.Equal(LegacyPListAuditParseFailure.InvalidItemIndex, result.Failure);
}
[Fact]
public void Parse_AllowsEmptyLegacySelectionFieldsAndLiteralQuestionMarks()
{
const string text = "0^^확인?^^^1/1^";
var result = LegacyPListReadAuditParser.Parse("00000001", "질문?", "0", text);
Assert.True(result.IsSuccess);
Assert.False(result.Row!.IsEnabled);
Assert.Equal(string.Empty, result.Row.GroupCode);
Assert.Equal(string.Empty, result.Row.DataCode);
}
[Fact]
public void Evidence_RequiresContiguousIndexesAndExactPersistenceFields()
{
var first = Parse("0", "1^업종_코스피^코스피^네모그래프^^1/1^");
var second = Parse("1", "0^업종_코스피^코스피^섹터지수^^^");
var stored = new NamedPlaylistStoredItem(
0,
true,
new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
"업종_코스피", "코스피", "네모그래프", "", ""),
new NamedPlaylistPageState(1, 1));
Assert.True(LegacyPListReadAuditEvidence.HasContiguousIndexes([first, second]));
Assert.False(LegacyPListReadAuditEvidence.HasContiguousIndexes([
first,
second with { ItemIndex = 2 }
]));
Assert.True(LegacyPListReadAuditEvidence.MatchesPersistenceResult(first, stored));
Assert.False(LegacyPListReadAuditEvidence.MatchesPersistenceResult(
first with { RawListText = first.RawListText.Replace("코스피", "코스닥") },
stored with { IsEnabled = false }));
}
[Theory]
[InlineData("1^업종_코스피^코스피^네모그래프^^1/1^", LegacyPListAuditRoute.IndustryFixedClosedMapper)]
[InlineData("1^해외지수^다우존스^캔들그래프^5일^1/1^", LegacyPListAuditRoute.ForeignIndexCandleFreshIdentity)]
[InlineData("1^테마_NXT^반도체(NXT)^5단 표그래프^테마-현재가(입력순)^1/1^123", LegacyPListAuditRoute.NxtThemeFreshIdentity)]
[InlineData("1^테마^반도체^5단 표그래프^테마-현재가(입력순)^1/1^123", LegacyPListAuditRoute.KrxThemeFreshIdentity)]
[InlineData("1^해외종목^APPLE^캔들그래프^5일^1/1^", LegacyPListAuditRoute.ForeignStockFreshIdentity)]
[InlineData("1^FOREIGN_STOCK^NVIDIA^1열판기본^CURRENT^1/1^NAS@NVDA|US|1", LegacyPListAuditRoute.ExistingClosedMapper)]
[InlineData("1^FOREIGN_STOCK^NVIDIA^1열판기본^CURRENT^1/1^NAS@𐐀|US|1", LegacyPListAuditRoute.ExistingClosedMapper)]
[InlineData("1^FOREIGN_STOCK^NVIDIA^MA5^PRICE^1/1^NAS@NVDA|US|1", LegacyPListAuditRoute.Unmapped)]
[InlineData("1^코스피^삼성전자^성장성 지표^^1/1^", LegacyPListAuditRoute.ManualFreshMaterialization)]
[InlineData("1^KOSPI^삼성전자^GROWTH_METRICS^^1/1^", LegacyPListAuditRoute.ManualFreshMaterialization)]
[InlineData("1^INDIVIDUAL^INDEX^MANUAL_NET_SELL^MANUAL_NET_SELL^1/1^", LegacyPListAuditRoute.ManualFreshMaterialization)]
[InlineData("1^PAGED_VI^@MBN_VI_SNAPSHOT_V1^INPUT_ORDER^CURRENT^1/1^aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", LegacyPListAuditRoute.ManualFreshMaterialization)]
[InlineData("1^코스피^삼성전자^캔들그래프^20일^1/1^005930", LegacyPListAuditRoute.ExistingClosedMapper)]
[InlineData("1^ALL^INDEX^PROGRAM_TRADING^TABLE_GRAPH^1/1^", LegacyPListAuditRoute.ExistingClosedMapper)]
[InlineData("1^알수없는그룹^알수없는대상^비슷한그래프^^1/1^", LegacyPListAuditRoute.Unmapped)]
public void Evidence_RoutesSpecialRowsWithoutGuessing(
string text,
LegacyPListAuditRoute expected)
{
Assert.Equal(expected, LegacyPListReadAuditEvidence.ClassifyRoute(Parse("0", text)));
}
[Fact]
public void Evidence_DoesNotTreatNearestStockLabelAsAClosedMapperMatch()
{
var exact = Parse("0", "1^코스피^삼성전자^수익률그래프^20일^1/1^005930");
var nearest = Parse("0", "1^코스피^삼성전자^수익률 그래프^20일^1/1^005930");
Assert.Equal(
LegacyPListAuditRoute.ExistingClosedMapper,
LegacyPListReadAuditEvidence.ClassifyRoute(exact));
Assert.Equal(
LegacyPListAuditRoute.Unmapped,
LegacyPListReadAuditEvidence.ClassifyRoute(nearest));
}
private static LegacyPListAuditRow Parse(string itemIndex, string text) =>
Assert.IsType<LegacyPListAuditRow>(LegacyPListReadAuditParser.Parse(
"00000001", "방송", itemIndex, text).Row);
}

View File

@@ -7,6 +7,7 @@ const stock = require("../../Web/operator-workflow.js");
const fixed = require("../../Web/legacy-fixed-workflow.js");
const industry = require("../../Web/industry-workflow.js");
const overseas = require("../../Web/overseas-workflow.js");
const namedForeignStock = require("../../Web/named-foreign-stock-restore-workflow.js");
const tradingHalt = require("../../Web/trading-halt-workflow.js");
const adapters = { stock, fixed, industry, overseas, tradingHalt };
@@ -35,6 +36,37 @@ const haltedStock = Object.freeze({
function entries() {
const fixedAction = fixed.actions.find(value => value.builderKey === "s8010" && value.available);
const industryCut = industry.cuts.find(value => value.kind === "candle");
const pendingNamedForeign = namedForeignStock.classify({
itemIndex: 0,
enabled: false,
groupCode: "해외종목",
subject: "NVIDIA",
graphicType: "캔들그래프",
subtype: "120일",
pageText: "1/1",
dataCode: ""
}, options("named-overseas-candle"));
const namedResponse = namedForeignStock.normalizeResponse({
requestId: "named-overseas-load",
retrievedAt: "2026-07-12T12:34:56+09:00",
totalRowCount: 1,
rows: [{
itemIndex: 0,
status: "resolved",
actionId: "stock-candle-120d",
inputName: "NVIDIA",
symbol: "NAS@NVDA",
nationCode: "US",
foreignDomesticCode: "1",
builderKey: "s8010",
cutCode: "8051",
valueType: "PRICE",
periodDays: 120
}]
}, "named-overseas-load", [pendingNamedForeign]);
const namedForeignEntry = namedForeignStock.materialize(
pendingNamedForeign,
namedResponse.rows[0]);
return [
stock.createStockPlaylistEntry(domesticStock, "candle-120d", options("stock-candle")),
fixed.createFixedPlaylistEntry(fixedAction.id, options("fixed-candle")),
@@ -46,6 +78,7 @@ function entries() {
overseasStock,
"stock-candle-120d",
options("overseas-candle")),
namedForeignEntry,
tradingHalt.createTradingHaltPlaylistEntry(
haltedStock,
"candle-120d",
@@ -61,13 +94,35 @@ test("global flags map to the exact closed candle graphic type", () => {
assert.throws(() => workflow.expectedGraphicType("yes", true));
});
test("all five original candle entry sources can be recreated with global flags", () => {
test("all original and DB-restored candle entry sources can be recreated with global flags", () => {
for (const entry of entries()) {
const recreated = workflow.recreateCandleEntry(entry, true, true, adapters);
assert.ok(recreated, entry.operator.source);
assert.equal(recreated.selection.graphicType, "MA5,MA20");
assert.deepEqual(recreated.operator.movingAverages, { ma5: true, ma20: true });
assert.equal(recreated.enabled, entry.enabled);
if (entry.operator.source === namedForeignStock.source) {
assert.equal(recreated.operator.source, namedForeignStock.source);
assert.deepEqual(recreated.operator.rawSelection, entry.operator.rawSelection);
assert.ok(namedForeignStock.restorePlaylistEntry(recreated));
assert.deepEqual(namedForeignStock.legacySelectionForEntry(recreated), {
groupCode: "해외종목",
subject: "NVIDIA",
graphicType: "캔들그래프",
subtype: "120일",
dataCode: ""
});
assert.equal(namedForeignStock.matchesRaw(recreated, {
itemIndex: 0,
enabled: false,
groupCode: "해외종목",
subject: "NVIDIA",
graphicType: "캔들그래프",
subtype: "120일",
pageText: "1/1",
dataCode: ""
}), true);
}
}
});

View File

@@ -209,6 +209,25 @@ test("legacy signature round-trips only the exact verified entry", () => {
}
});
test("enabled replacement preserves the branded foreign-index proof immutably", () => {
const storedRaw = raw("S&P500", "20일", 6, true);
const pending = workflow.classify(storedRaw, {
id: "foreign_toggle", fadeDuration: 6
});
const normalized = workflow.normalizeResponse(response("load_toggle", [pending]),
"load_toggle", [pending]);
const entry = workflow.materialize(pending, normalized.rows[0]);
const replacement = workflow.withEnabled(entry, false);
assert.notEqual(replacement, entry);
assert.equal(Object.isFrozen(replacement), true);
assert.equal(entry.enabled, true);
assert.equal(replacement.enabled, false);
assert.equal(workflow.isMaterializedEntry(replacement), true);
assert.equal(workflow.matchesRaw(replacement, { ...storedRaw, enabled: false }), true);
assert.equal(workflow.withEnabled({ ...entry }, false), null);
});
test("bridge errors are request-bound and never retryable", () => {
const error = {
requestId: "load_error",

View File

@@ -605,6 +605,24 @@ test("playlist entry has the complete standard operator metadata and native trus
}, { id: "forged" }), /native-confirmed/);
});
test("enabled replacement keeps native financial trust without mutating the frozen entry", () => {
const snapshot = trustedSnapshot("sales");
const stock = verifiedStock(snapshot.record);
const selection = trustedSelection(snapshot, stock);
const entry = workflow.createPlaylistEntry(snapshot, stock, selection, {
id: "sales-toggle",
fadeDuration: 6
});
const replacement = workflow.withEnabled(entry, false);
assert.notEqual(replacement, entry);
assert.equal(Object.isFrozen(replacement), true);
assert.equal(entry.enabled, true);
assert.equal(replacement.enabled, false);
assert.equal(workflow.isPlaylistEntryPlayable(replacement), true);
assert.equal(workflow.withEnabled({ ...entry }, false), null);
});
test("stored entries restore only as non-playable refresh-required descriptors", () => {
const snapshot = trustedSnapshot("sales");
const stock = verifiedStock(snapshot.record);

View File

@@ -361,6 +361,29 @@ test("only entries bound to a correlated fresh read are playable", () => {
viRead)), false);
});
test("enabled replacement transfers only the correlated manual-list read capability", () => {
const netRead = workflow.normalizeNetSellDataResponse({
requestId,
audience: "FOREIGN",
rows: rows()
}, { requestId, audience: "FOREIGN" });
const entry = workflow.createNetSellPlaylistEntry("FOREIGN", {
id: "entry_net_toggle",
fadeDuration: 6
}, netRead);
const replacement = workflow.withEnabled(entry, false);
assert.notEqual(replacement, entry);
assert.equal(entry.enabled, true);
assert.equal(replacement.enabled, false);
assert.equal(workflow.isPlaylistEntryPlayable(replacement), true);
assert.equal(workflow.withEnabled({ ...entry }, false), null);
assert.equal(workflow.withEnabled(workflow.createNetSellPlaylistEntry("FOREIGN", {
id: "entry_net_toggle_untrusted",
fadeDuration: 6
}), false), null);
});
test("request identifiers and option bounds are strict", () => {
assert.throws(() => workflow.createStatusRequest("bad id"));
assert.throws(() => workflow.createNetSellPlaylistEntry("FOREIGN", {

View File

@@ -0,0 +1,136 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const foreignStock = require("../../Web/named-foreign-stock-restore-workflow.js");
const legacyRows = require("../../Web/legacy-named-row-workflow.js");
const namedPlaylists = require("../../Web/named-playlist-workflow.js");
const root = path.resolve(__dirname, "../..");
const app = fs.readFileSync(path.join(root, "Web/app.js"), "utf8");
const index = fs.readFileSync(path.join(root, "Web/index.html"), "utf8");
const bridge = fs.readFileSync(
path.join(root, "MainWindow.NamedForeignStockRestore.cs"), "utf8");
const namedBridge = fs.readFileSync(path.join(root, "MainWindow.NamedPlaylists.cs"), "utf8");
const core = fs.readFileSync(
path.join(root, "src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedForeignStockRestore.cs"),
"utf8");
function rawRow(enabled = false) {
return {
itemIndex: 0,
enabled,
groupCode: "해외종목",
subject: "NVIDIA",
graphicType: "캔들그래프",
subtype: "240일",
pageText: "1/1",
dataCode: ""
};
}
function materializedEntry() {
const raw = rawRow();
const pending = foreignStock.classify(raw, {
id: "db-foreign-stock-240",
fadeDuration: 6,
ma5: true,
ma20: false
});
const response = foreignStock.normalizeResponse({
requestId: "foreign-stock-load",
retrievedAt: "2026-07-12T12:34:56+09:00",
totalRowCount: 1,
rows: [{
itemIndex: 0,
status: "resolved",
actionId: "stock-candle-240d",
inputName: "NVIDIA",
symbol: "NAS@NVDA",
nationCode: "US",
foreignDomesticCode: "1",
builderKey: "s8010",
cutCode: "8056",
valueType: "PRICE",
periodDays: 240
}]
}, "foreign-stock-load", [pending]);
return foreignStock.materialize(pending, response.rows[0]);
}
test("named load connects one correlated native foreign-stock restore batch end to end", () => {
assert.match(index, /<script src="named-foreign-stock-restore-workflow\.js"><\/script>[\s\S]*<script src="legacy-named-row-workflow\.js"><\/script>/u);
assert.match(app, /MbnNamedForeignStockRestoreWorkflow/u);
assert.match(app, /namedForeignStockRestoreWorkflow\.classify/u);
assert.match(app, /namedForeignStockRestoreWorkflow\.normalizeResponse/u);
assert.match(app, /namedForeignStockRestoreWorkflow\.materialize/u);
assert.match(app, /case "named-foreign-stock-restore-results"/u);
assert.match(app, /case "named-foreign-stock-restore-error"/u);
assert.match(namedBridge, /await PostNamedForeignStockRestoreAsync\(/u);
assert.match(bridge, /PostMessage\("named-foreign-stock-restore-results"/u);
assert.match(bridge, /PostMessage\("named-foreign-stock-restore-error"/u);
});
test("Core uses one exact bound SELECT and native bridge never writes or calls playout", () => {
assert.match(core, /F_INPUT_NAME = :input_name/u);
assert.match(core, /F_FDTC = '1'/u);
assert.match(core, /F_NATC IN \('US', 'TW'\)/u);
assert.match(core, /WHERE ROWNUM <= 2/u);
assert.match(core, /new DataQueryParameter\("input_name"/u);
assert.doesNotMatch(core, /\bLIKE\b/iu);
assert.doesNotMatch(core, /\b(?:INSERT\s+INTO|UPDATE\s+[A-Z_]|DELETE\s+FROM|MERGE\s+INTO)\b/iu);
assert.doesNotMatch(bridge, /Tornado|TakeIn|PrepareAsync|ExecuteNonQuery/iu);
assert.match(bridge, /retryable = false/u);
assert.match(bridge, /자동 재시도하지 않습니다/u);
});
test("pending state blocks edits and timeout is terminal without an automatic retry", () => {
assert.match(app, /state\.namedPlaylist\.foreignStockRestorePending/u);
assert.match(app, /armNamedForeignStockRestoreTimeout/u);
assert.match(app, /setTimeout\(\(\) => \{[\s\S]*자동 재시도하지 않습니다\.[\s\S]*\}, 30000\)/u);
assert.match(app, /comparisonRestorePending \|\|[\s\S]*foreignStockRestorePending \|\|[\s\S]*foreignIndexCandleRestorePending/u);
assert.match(app, /pendingForeignStocks\.length === 0/u);
assert.match(app, /clearNamedForeignStockRestoreTimeout\(record\)/u);
assert.doesNotMatch(app, /retryNamedForeignStock|requestNamedForeignStockRestore/u);
});
test("verified identity and moving averages preserve the exact original seven fields for save", () => {
const entry = materializedEntry();
const raw = rawRow();
assert.ok(entry);
assert.equal(legacyRows.matchesCanonicalEntry(entry, raw), true);
const selection = legacyRows.legacySelectionForEntry(entry);
assert.deepEqual(selection, {
groupCode: "해외종목",
subject: "NVIDIA",
graphicType: "캔들그래프",
subtype: "240일",
dataCode: ""
});
assert.deepEqual(entry.operator.movingAverages, { ma5: true, ma20: false });
assert.deepEqual(namedPlaylists.toLegacySevenFields({ ...entry, selection }, "1/1"), [
"0", "해외종목", "NVIDIA", "캔들그래프", "240일", "1/1", ""
]);
assert.match(app, /해외종목 항목의 원본 7필드 서명을 안전하게 왕복할 수 없습니다/u);
assert.match(app, /namedForeignStockRestoreWorkflow\.matchesRaw\(restoredForeignStock, rawRow\)/u);
assert.match(app, /item\.operator\?\.source === namedForeignStockRestoreWorkflow\.source\) return item/u);
});
test("canonical FOREIGN_STOCK remains on the existing workflow and cannot collide", () => {
assert.equal(foreignStock.classify({
...rawRow(true),
groupCode: "FOREIGN_STOCK",
graphicType: "MA5",
subtype: "PRICE",
dataCode: "NAS@NVDA|US|1"
}, {
id: "canonical-does-not-route",
fadeDuration: 6,
ma5: true,
ma20: false
}), null);
assert.match(app, /addOverseasNamedCandidates/u);
assert.match(app, /rawRow\.groupCode !== "FOREIGN_STOCK"/u);
});

View File

@@ -0,0 +1,251 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const workflow = require("../../Web/named-foreign-stock-restore-workflow.js");
const actions = [
["1열판기본", "", "stock-current", "s5001", "5001", "CURRENT", null],
["캔들그래프", "5일", "stock-candle-5d", "s8010", "8061", "PRICE", 5],
["캔들그래프", "20일", "stock-candle-20d", "s8010", "8040", "PRICE", 20],
["캔들그래프", "60일", "stock-candle-60d", "s8010", "8046", "PRICE", 60],
["캔들그래프", "120일", "stock-candle-120d", "s8010", "8051", "PRICE", 120],
["캔들그래프", "240일", "stock-candle-240d", "s8010", "8056", "PRICE", 240]
];
function raw(graphicType = "1열판기본", subtype = "", itemIndex = 0, enabled = true) {
return {
itemIndex,
enabled,
groupCode: "해외종목",
subject: "NVIDIA",
graphicType,
subtype,
pageText: "1/1",
dataCode: ""
};
}
function options(id = "foreign-stock-entry") {
return { id, fadeDuration: 6, ma5: true, ma20: false };
}
function outcome(pending, overrides = {}) {
return {
itemIndex: pending.itemIndex,
status: "resolved",
actionId: pending.actionId,
inputName: pending.inputName,
symbol: "NAS@NVDA",
nationCode: "US",
foreignDomesticCode: "1",
builderKey: pending.builderKey,
cutCode: pending.cutCode,
valueType: pending.valueType,
periodDays: pending.periodDays,
...overrides
};
}
function response(requestId, pendingRows, rows = pendingRows.map(value => outcome(value))) {
return {
requestId,
retrievedAt: "2026-07-12T12:34:56+09:00",
totalRowCount: rows.length,
rows
};
}
test("only the exact legacy overseas-stock grammar classifies all six UC5 actions", () => {
actions.forEach(([graphicType, subtype, actionId, builderKey, cutCode, valueType, periodDays], index) => {
const pending = workflow.classify(raw(graphicType, subtype, index, index !== 5), options(`foreign-${index}`));
assert.ok(pending);
assert.equal(workflow.isPending(pending), true);
assert.equal(pending.actionId, actionId);
assert.equal(pending.builderKey, builderKey);
assert.equal(pending.cutCode, cutCode);
assert.equal(pending.valueType, valueType);
assert.equal(pending.periodDays, periodDays);
assert.equal(pending.enabled, index !== 5);
assert.deepEqual(pending.movingAverages,
builderKey === "s8010" ? { ma5: true, ma20: false } : { ma5: false, ma20: false });
});
for (const invalid of [
{ ...raw(), groupCode: "FOREIGN_STOCK", dataCode: "NAS@NVDA|US|1" },
{ ...raw(), groupCode: " 해외종목" },
{ ...raw(), subject: " NVIDIA" },
{ ...raw(), graphicType: "CURRENT" },
{ ...raw("캔들그래프", "5일"), subtype: "1년" },
{ ...raw(), pageText: "1/2" },
{ ...raw(), dataCode: "NAS@NVDA" },
{ ...raw(), itemIndex: 1000 }
]) {
assert.equal(workflow.classify(invalid, options("invalid-row")), null);
}
assert.equal(workflow.classify(raw(), { ...options(), extra: true }), null);
});
test("a correlated unique Oracle identity materializes current and candle entries", () => {
for (const [graphicType, subtype, , builderKey, cutCode] of actions) {
const pending = workflow.classify(raw(graphicType, subtype, 7, false), options(`entry-${cutCode}`));
const normalized = workflow.normalizeResponse(
response("load-one", [pending]),
"load-one",
[pending]);
assert.ok(normalized);
const entry = workflow.materialize(pending, normalized.rows[0]);
assert.ok(entry);
assert.equal(workflow.isMaterializedEntry(entry), true);
assert.equal(entry.builderKey, builderKey);
assert.equal(entry.code, cutCode);
assert.equal(entry.enabled, false);
assert.equal(entry.symbol, "NAS@NVDA");
assert.equal(entry.nationCode, "US");
assert.equal(entry.fdtc, "1");
assert.equal(entry.operator.source, workflow.source);
assert.equal(entry.selection.groupCode, "FOREIGN_STOCK");
assert.equal(entry.selection.dataCode, "NAS@NVDA|US|1");
assert.equal(entry.selection.graphicType, builderKey === "s8010" ? "MA5" : "1열판기본");
}
});
test("a correlated Hangul-letter provider symbol remains a bound closed identity", () => {
const pending = workflow.classify(
raw("캔들그래프", "5일", 8),
options("hangul-symbol"));
const normalized = workflow.normalizeResponse(response(
"hangul-symbol-load",
[pending],
[outcome(pending, { symbol: "NAS@해외 종목1" })]),
"hangul-symbol-load",
[pending]);
assert.ok(normalized);
const entry = workflow.materialize(pending, normalized.rows[0]);
assert.ok(entry);
assert.equal(entry.selection.dataCode, "NAS@해외 종목1|US|1");
assert.ok(workflow.restorePlaylistEntry(entry));
});
test("safe supplementary input-name and letter symbols match the native scalar contract", () => {
const pending = workflow.classify(
{ ...raw("1열판기본", "", 10), subject: "회사🚀" },
options("supplementary-symbol"));
const normalized = workflow.normalizeResponse(response(
"supplementary-symbol-load",
[pending],
[outcome(pending, { inputName: "회사🚀", symbol: "NAS@𐐀1" })]),
"supplementary-symbol-load",
[pending]);
assert.ok(normalized);
const entry = workflow.materialize(pending, normalized.rows[0]);
assert.ok(entry);
assert.equal(entry.selection.dataCode, "NAS@𐐀1|US|1");
assert.ok(workflow.restorePlaylistEntry(entry));
});
test("batch count, order, request, action and identity correlation are strict", () => {
const first = workflow.classify(raw("캔들그래프", "5일", 4), options("first"));
const secondRaw = { ...raw("1열판기본", "", 9), subject: "TSMC" };
const second = workflow.classify(secondRaw, options("second"));
const rows = [outcome(first), outcome(second, { inputName: "TSMC", symbol: "TPE@2330", nationCode: "TW" })];
const exact = response("load-batch", [first, second], rows);
assert.ok(workflow.normalizeResponse(exact, "load-batch", [first, second]));
assert.equal(workflow.normalizeResponse(exact, "stale", [first, second]), null);
assert.equal(workflow.normalizeResponse({ ...exact, totalRowCount: 1 }, "load-batch", [first, second]), null);
assert.equal(workflow.normalizeResponse({ ...exact, rows: [...rows].reverse() }, "load-batch", [first, second]), null);
assert.equal(workflow.normalizeResponse({ ...exact, extra: true }, "load-batch", [first, second]), null);
for (const [key, value] of [
["actionId", "stock-current"],
["inputName", "TSMC"],
["nationCode", "KR"],
["foreignDomesticCode", "0"],
["builderKey", "s5001"],
["cutCode", "5001"],
["valueType", "VOLUME"],
["periodDays", 20]
]) {
const tampered = [{ ...rows[0], [key]: value }, rows[1]];
assert.equal(workflow.normalizeResponse({ ...exact, rows: tampered }, "load-batch", [first, second]), null, key);
}
});
test("terminal per-row failures, forged capability objects and retryable errors fail closed", () => {
const pending = workflow.classify(raw("캔들그래프", "120일"), options("failed"));
for (const failure of [
"INVALID_ROW", "IDENTITY_NOT_FOUND", "IDENTITY_AMBIGUOUS",
"DATABASE_DATA_INVALID", "RESTORE_FAILED"
]) {
const normalized = workflow.normalizeResponse(response("failed-load", [pending], [{
itemIndex: pending.itemIndex,
status: "failed",
failure
}]), "failed-load", [pending]);
assert.ok(normalized);
assert.equal(workflow.materialize(pending, normalized.rows[0]), null);
}
const normalized = workflow.normalizeResponse(response("resolved-load", [pending]), "resolved-load", [pending]);
assert.equal(workflow.materialize({ ...pending }, normalized.rows[0]), null);
assert.equal(workflow.materialize(pending, { ...normalized.rows[0] }), null);
const error = {
requestId: "error-load",
code: "PLAYOUT_PRIORITY",
message: "송출 우선 처리로 취소했습니다.",
retryable: false
};
assert.deepEqual(workflow.normalizeError(error, "error-load"), error);
assert.equal(workflow.normalizeError({ ...error, retryable: true }, "error-load"), null);
assert.equal(workflow.normalizeError(error, "stale"), null);
});
test("verified identity, moving averages and original seven-field signature round-trip together", () => {
const storedRaw = raw("캔들그래프", "240일", 3, false);
const pending = workflow.classify(storedRaw, options("round-trip"));
const normalized = workflow.normalizeResponse(response("round-trip-load", [pending]), "round-trip-load", [pending]);
const entry = workflow.materialize(pending, normalized.rows[0]);
const restored = workflow.restorePlaylistEntry(entry);
assert.ok(restored);
assert.deepEqual(restored.operator.movingAverages, { ma5: true, ma20: false });
assert.deepEqual(workflow.legacySelectionForEntry(restored), {
groupCode: "해외종목",
subject: "NVIDIA",
graphicType: "캔들그래프",
subtype: "240일",
dataCode: ""
});
assert.equal(workflow.matchesRaw(restored, storedRaw), true);
assert.equal(workflow.matchesRaw(restored, { ...storedRaw, subtype: "120일" }), false);
for (const tampered of [
{ ...entry, code: "8040" },
{ ...entry, selection: { ...entry.selection, dataCode: "TPE@2330|TW|1" } },
{ ...entry, operator: { ...entry.operator, identity: { ...entry.operator.identity, symbol: "TPE@2330" } } },
{ ...entry, operator: { ...entry.operator, rawSelection: { ...entry.operator.rawSelection, subtype: "120일" } } },
{ ...entry, operator: { ...entry.operator, movingAverages: { ma5: false, ma20: false } } },
{ ...entry, extra: true }
]) {
assert.equal(workflow.restorePlaylistEntry(tampered), null);
}
});
test("enabled replacement preserves foreign-stock identity proof without mutating the frozen entry", () => {
const storedRaw = raw("1열판기본", "", 2, true);
const pending = workflow.classify(storedRaw, options("foreign-toggle"));
const normalized = workflow.normalizeResponse(response("foreign-toggle-load", [pending]),
"foreign-toggle-load", [pending]);
const entry = workflow.materialize(pending, normalized.rows[0]);
const replacement = workflow.withEnabled(entry, false);
assert.notEqual(replacement, entry);
assert.equal(Object.isFrozen(replacement), true);
assert.equal(entry.enabled, true);
assert.equal(replacement.enabled, false);
assert.equal(workflow.isMaterializedEntry(replacement), true);
assert.equal(workflow.matchesRaw(replacement, { ...storedRaw, enabled: false }), true);
assert.deepEqual(workflow.legacySelectionForEntry(replacement),
workflow.legacySelectionForEntry(entry));
assert.equal(workflow.withEnabled({ ...entry }, false), null);
});

View File

@@ -0,0 +1,83 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..", "..");
const app = fs.readFileSync(path.join(root, "Web", "app.js"), "utf8");
const index = fs.readFileSync(path.join(root, "Web", "index.html"), "utf8");
const native = fs.readFileSync(path.join(root, "MainWindow.NamedKrxThemeRestore.cs"), "utf8");
const namedBridge = fs.readFileSync(path.join(root, "MainWindow.NamedPlaylists.cs"), "utf8");
function functionBody(name, nextName) {
const start = app.indexOf(`function ${name}(`);
const end = app.indexOf(`function ${nextName}(`, start + 1);
assert.ok(start >= 0 && end > start, `${name} must precede ${nextName}`);
return app.slice(start, end);
}
test("named KRX restore is correlated from one native load through page preflight", () => {
assert.match(index,
/src="named-krx-theme-restore-workflow\.js"[\s\S]*src="named-nxt-theme-restore-workflow\.js"/u);
assert.match(namedBridge,
/await PostNamedKrxThemeRestoreAsync\([\s\S]*await PostNamedNxtThemeRestoreAsync\(/u);
assert.match(native, /PostMessage\("named-krx-theme-restore-results"/u);
assert.match(app, /krxThemeRestorePending/u);
assert.match(app, /case "named-krx-theme-restore-results"/u);
assert.match(app, /case "named-krx-theme-restore-error"/u);
const finalize = functionBody("finalizeNamedManualRestores", "handleNamedManualListResults");
assert.match(finalize, /state\.namedPlaylist\.krxThemeRestorePending/u);
assert.match(finalize, /prepareNamedPagePreflight/u);
});
test("generic theme restore cannot trust a historical KRX code before native verification", () => {
const addTheme = functionBody("addThemeNamedCandidates", "addOverseasNamedCandidates");
assert.match(addTheme,
/rawRow\.groupCode === "테마" \|\| rawRow\.groupCode === "테마_NXT"/u);
const load = functionBody("handleNamedPlaylistLoadResults", "failNamedComparisonRestore");
assert.match(load, /namedKrxThemeRestoreWorkflow\.classify/u);
assert.match(load, /pendingKrxThemes\.length === 0/u);
assert.match(load, /krxThemeRestorePending = pendingKrxThemes\.length/u);
const resolve = functionBody("resolveNamedPlaylistRawRow", "prepareNamedPagePreflight");
assert.match(resolve,
/rawRow\?\.groupCode === "테마" \|\| rawRow\?\.groupCode === "테마_NXT"[\s\S]*return null/u);
});
test("current KRX identity stays branded in memory and old code is retained only for save", () => {
const materialize = functionBody("materializeNamedRestoration", "namedPlaylistPrepareBlockers");
assert.match(materialize,
/namedKrxThemeRestoreWorkflow\.isMaterializedEntry\(row\.entry\)/u);
const synchronize = functionBody("synchronizeThemeEntries", "clearOverseasTimeout");
assert.match(synchronize,
/namedKrxThemeRestoreWorkflow\.isMaterializedEntry\(item\)[\s\S]*\? item/u);
const fields = functionBody("namedLegacyFieldsForEntry", "isTrustedNamedPlaylistEntry");
assert.match(fields, /namedKrxThemeRestoreWorkflow\.legacySelectionForEntry\(entry\)/u);
assert.match(fields, /namedKrxThemeRestoreWorkflow\.matchesRaw\(entry, rawRow\)/u);
assert.match(fields, /KRX 테마의 과거 code와 현재 송출 code/u);
});
test("KRX failures are terminal and never trigger an automatic retry or playout", () => {
const failed = functionBody("failNamedKrxThemeRestore", "handleNamedKrxThemeRestoreResults");
assert.match(failed, /krxThemeRestorePending = null/u);
assert.doesNotMatch(app, /retryNamedKrxTheme|requestNamedKrxThemeRestore/u);
assert.doesNotMatch(native, /IPlayoutEngine|PrepareAsync|TakeInAsync|NextAsync|TakeOutAsync/u);
assert.match(native, /retryable = false/u);
});
test("KRX success accounting and post-load cancellation close every correlated busy record", () => {
const results = functionBody("handleNamedKrxThemeRestoreResults", "handleNamedKrxThemeRestoreError");
assert.match(results,
/state\.playlist\[pending\.itemIndex\] = entry;\s*restoredCount \+= 1;/u);
const clear = functionBody("clearNamedPlaylistLoadState", "saveNamedPlaylistBackup");
assert.match(clear, /krxThemeRestorePending = null/u);
const busy = functionBody("namedPlaylistBusy", "renderNamedPlaylist");
assert.match(busy, /state\.namedPlaylist\.krxThemeRestorePending/u);
const loadError = functionBody("handleNamedPlaylistError", "normalizePlayoutState");
assert.match(loadError, /error\.operation !== "load"/u);
assert.match(loadError,
/state\.namedPlaylist\.krxThemeRestorePending\?\.requestId === error\.requestId[\s\S]*krxThemeRestorePending = null/u);
assert.match(loadError, /복원 배치는 자동 재시도하지 않습니다/u);
});

View File

@@ -0,0 +1,249 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const workflow = require("../../Web/named-krx-theme-restore-workflow.js");
function raw(overrides = {}) {
return {
itemIndex: 4,
enabled: true,
groupCode: "테마",
subject: "반도체",
graphicType: "5단 표그래프",
subtype: "테마-현재가(입력순)",
pageText: "1/9",
dataCode: "0123",
...overrides
};
}
function resolved(overrides = {}) {
return {
itemIndex: 4,
status: "resolved",
actionId: "five-row-current",
builderKey: "s5074",
cutCode: "5074",
pageSize: 5,
sort: "INPUT_ORDER",
themeTitle: "반도체",
storedThemeCode: "0123",
currentThemeCode: "87654321",
previewItemCount: 7,
previewIsTruncated: false,
codeWasRemapped: true,
...overrides
};
}
function response(row) {
return {
requestId: "load-1",
retrievedAt: "2026-07-12T12:59:00+09:00",
totalRowCount: 1,
rows: [row]
};
}
function normalize(sourceRaw = raw(), outcome = resolved()) {
const pending = workflow.classify(sourceRaw, { id: "db-4", fadeDuration: 6 });
const normalized = workflow.normalizeResponse(response(outcome), "load-1", [pending]);
return { pending, normalized, outcome: normalized?.rows[0] ?? null };
}
test("classify routes every exact KRX theme group row for native fail-closed verification", () => {
const pending = workflow.classify(raw({
subject: "",
graphicType: "unsupported",
subtype: "",
pageText: "",
dataCode: ""
}), { id: "db-4", fadeDuration: 6 });
assert.equal(workflow.isPending(pending), true);
assert.equal(pending.rawSelection.groupCode, "테마");
assert.equal(workflow.classify(raw({ groupCode: "테마_NXT" }), {
id: "db-4",
fadeDuration: 6
}), null);
});
test("normalizeResponse accepts one exact correlated current identity", () => {
const { normalized } = normalize();
assert.equal(normalized.rows[0].currentThemeCode, "87654321");
assert.equal(normalized.rows[0].previewItemCount, 7);
assert.deepEqual(workflow.bridgeContract, {
resultType: "named-krx-theme-restore-results",
errorType: "named-krx-theme-restore-error"
});
});
test("normalizeResponse enforces exact wire schema, raw action correlation and preview bounds", () => {
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
for (const invalid of [
{ ...resolved(), extra: true },
resolved({ storedThemeCode: "999" }),
resolved({ themeTitle: "다른제목" }),
resolved({ actionId: "six-row-current", builderKey: "s5077", cutCode: "5077", pageSize: 6 }),
resolved({ sort: "GAIN_ASC" }),
resolved({ previewItemCount: 0 }),
resolved({ previewItemCount: 239, previewIsTruncated: true }),
resolved({ previewItemCount: 241, previewIsTruncated: true }),
resolved({ codeWasRemapped: false })
]) {
assert.equal(workflow.normalizeResponse(response(invalid), "load-1", [pending]), null);
}
assert.equal(workflow.normalizeResponse({
requestId: "load-1",
retrievedAt: "2026-07-12T12:59:00+09:00",
totalRowCount: 2,
rows: [resolved({ itemIndex: 5 }), resolved({ itemIndex: 4 })]
}, "load-1", [
workflow.classify(raw({ itemIndex: 4 }), { id: "db-4", fadeDuration: 6 }),
workflow.classify(raw({ itemIndex: 5 }), { id: "db-5", fadeDuration: 6 })
]), null);
});
test("failed rows preserve a closed no-retry reason", () => {
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
for (const failure of [
"INVALID_ROW", "UNSUPPORTED_ACTION", "IDENTITY_NOT_FOUND", "IDENTITY_AMBIGUOUS",
"PREVIEW_EMPTY", "DATABASE_DATA_INVALID", "RESTORE_FAILED"
]) {
const result = workflow.normalizeResponse(response({
itemIndex: 4,
status: "failed",
failure
}), "load-1", [pending]);
assert.equal(result.rows[0].failure, failure);
}
assert.equal(workflow.normalizeError({
requestId: "load-1",
code: "DATABASE_UNAVAILABLE",
message: "조회 실패",
retryable: false
}, "load-1").retryable, false);
assert.equal(workflow.normalizeError({
requestId: "load-1",
code: "DATABASE_UNAVAILABLE",
message: "조회 실패",
retryable: true
}, "load-1"), null);
});
test("materialize uses only current code and retains the old raw identity for lossless save", () => {
const sourceRaw = raw({ enabled: false });
const { pending, outcome } = normalize(sourceRaw);
const entry = workflow.materialize(pending, outcome);
assert.equal(workflow.isMaterializedEntry(entry), true);
assert.equal(entry.enabled, false);
assert.equal(entry.selection.groupCode, "PAGED_THEME");
assert.equal(entry.selection.dataCode, "87654321");
assert.equal(entry.operator.theme.themeCode, "87654321");
assert.equal(entry.pagePreview.itemCount, 7);
assert.deepEqual(workflow.legacySelectionForEntry(entry), {
groupCode: "테마",
subject: "반도체",
graphicType: "5단 표그래프",
subtype: "테마-현재가(입력순)",
dataCode: "0123"
});
assert.equal(workflow.matchesRaw(entry, sourceRaw), true);
assert.equal(workflow.matchesRaw(entry, { ...sourceRaw, dataCode: "87654321" }), false);
});
test("expected price and the original no-token loss-rate branch remain closed", () => {
const sourceRaw = raw({
subtype: "테마-예상체결가",
pageText: "1/2"
});
const native = resolved({
actionId: "five-row-expected",
sort: "GAIN_ASC"
});
const { pending, outcome } = normalize(sourceRaw, native);
const entry = workflow.materialize(pending, outcome);
assert.equal(entry.operator.actionId, "five-row-expected");
assert.equal(entry.operator.sort, "GAIN_ASC");
assert.equal(entry.selection.subtype, "EXPECTED");
assert.equal(entry.selection.graphicType, "GAIN_ASC");
assert.equal(workflow.matchesRaw(entry, sourceRaw), true);
});
test("5-current, 5-expected, 6 and 12 row wire profiles retain the final sort fallback", () => {
const cases = [
["5단 표그래프", "테마-현재가", "five-row-current", "s5074", "5074", 5],
["5단 표그래프", "테마-예상체결가", "five-row-expected", "s5074", "5074", 5],
["6종목 현재가", "테마-현재가", "six-row-current", "s5077", "5077", 6],
["12종목 현재가", "테마-현재가", "twelve-row-current", "s5088", "5088", 12]
];
for (const [graphicType, subtype, actionId, builderKey, cutCode, pageSize] of cases) {
const sourceRaw = raw({ graphicType, subtype });
const native = resolved({ actionId, builderKey, cutCode, pageSize, sort: "GAIN_ASC" });
const { pending, outcome } = normalize(sourceRaw, native);
const entry = workflow.materialize(pending, outcome);
assert.equal(entry.builderKey, builderKey);
assert.equal(entry.pageSize, pageSize);
assert.equal(entry.operator.sort, "GAIN_ASC");
}
});
test("240 visible rows with truncation is the only accepted truncated preview boundary", () => {
const { pending, outcome } = normalize(raw(), resolved({
previewItemCount: 240,
previewIsTruncated: true
}));
const entry = workflow.materialize(pending, outcome);
assert.equal(entry.operator.namedKrxRestore.previewItemCount, 240);
assert.equal(entry.operator.namedKrxRestore.previewIsTruncated, true);
assert.equal(entry.pagePreview.pageCount, 20);
assert.equal(entry.pagePreview.isTruncated, true);
});
test("materialization accepts only branded normalized responses", () => {
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
assert.equal(workflow.materialize(pending, resolved()), null);
const failed = workflow.normalizeResponse(response({
itemIndex: 4,
status: "failed",
failure: "PREVIEW_EMPTY"
}), "load-1", [pending]).rows[0];
assert.equal(workflow.materialize(pending, failed), null);
assert.equal(workflow.materialize({}, resolved()), null);
});
test("enabled replacement is immutable and preserves only the trusted KRX brand", () => {
const sourceRaw = raw({ enabled: true });
const { pending, outcome } = normalize(sourceRaw);
const entry = workflow.materialize(pending, outcome);
const replacement = workflow.withEnabled(entry, false);
assert.notEqual(replacement, entry);
assert.equal(Object.isFrozen(replacement), true);
assert.equal(entry.enabled, true);
assert.equal(replacement.enabled, false);
assert.equal(workflow.isMaterializedEntry(replacement), true);
assert.deepEqual(workflow.legacySelectionForEntry(replacement),
workflow.legacySelectionForEntry(entry));
assert.equal(workflow.matchesRaw(replacement, { ...sourceRaw, enabled: false }), true);
assert.equal(workflow.withEnabled({ ...entry }, false), null);
assert.equal(workflow.withEnabled(entry, "false"), null);
});
test("native partial emits the exact result and non-retry error contract", () => {
const root = path.resolve(__dirname, "..", "..");
const native = fs.readFileSync(path.join(root, "MainWindow.NamedKrxThemeRestore.cs"), "utf8");
assert.match(native, /PostMessage\("named-krx-theme-restore-results"/u);
assert.match(native, /PostMessage\("named-krx-theme-restore-error"/u);
assert.match(native, /identity\.StoredThemeCode/u);
assert.match(native, /identity\.CurrentThemeCode/u);
assert.match(native, /identity\.PreviewIsTruncated/u);
assert.match(native, /retryable = false/u);
assert.doesNotMatch(native, /retryable = true/u);
assert.doesNotMatch(native, /IPlayoutEngine|PrepareAsync|TakeInAsync|NextAsync|TakeOutAsync/u);
});

View File

@@ -132,6 +132,24 @@ test("dynamic NXT session changes exactly at local 13:00 without losing native p
assert.equal(workflow.matchesRaw(atThirteen, sourceRaw), true);
});
test("enabled replacement is immutable and retains NXT proof across session refresh", () => {
const sourceRaw = raw({ enabled: true });
const pending = workflow.classify(sourceRaw, { id: "db-toggle", fadeDuration: 6 });
const outcome = workflow.normalizeResponse(response(resolved({ itemIndex: 4 })),
"load-1", [pending]).rows[0];
const entry = workflow.materialize(pending, outcome);
const replacement = workflow.withEnabled(entry, false);
const refreshed = workflow.refreshDynamicSession(replacement, new Date(2026, 6, 12, 13, 0));
assert.notEqual(replacement, entry);
assert.equal(Object.isFrozen(replacement), true);
assert.equal(entry.enabled, true);
assert.equal(replacement.enabled, false);
assert.equal(workflow.isMaterializedEntry(replacement), true);
assert.equal(workflow.matchesRaw(refreshed, { ...sourceRaw, enabled: false }), true);
assert.equal(workflow.withEnabled({ ...entry }, false), null);
});
test("materialization refuses a failed outcome and non-pending objects", () => {
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
assert.equal(workflow.materialize(pending, {

View File

@@ -209,6 +209,25 @@ test("stock responses preserve US/TW identity and reject duplicate input-name lo
})), null);
});
test("provider symbols accept Unicode letters but keep serialized delimiters closed", () => {
const unicode = workflow.normalizeStock({
inputName: "Provider Unicode Symbol",
symbol: "NAS@해외 종목1",
nationCode: "US",
fdtc: "1"
});
assert.ok(unicode);
const entry = workflow.createOverseasStockPlaylistEntry(
unicode,
"stock-candle-5d",
{ id: "unicode-symbol", fadeDuration: 6, ma5: true, ma20: false });
assert.equal(entry.selection.dataCode, "NAS@해외 종목1|US|1");
for (const symbol of ["NAS|NVDA", "NAS^NVDA", " NAS@NVDA", "NAS@NVDA ", "NAS!NVDA", "NAS\nNVDA", "NAS😀NVDA"]) {
assert.equal(workflow.normalizeStock({ ...unicode, symbol }), null, symbol);
}
});
test("bridge errors use exact payloads and reject stale request correlation", () => {
const industryRequest = workflow.createIndustrySearchRequest("industry-error-1", "AI");
const industryError = {

View File

@@ -0,0 +1,122 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..", "..");
const app = fs.readFileSync(path.join(root, "Web", "app.js"), "utf8");
function functionSource(name, nextName) {
const start = app.indexOf(`function ${name}(`);
const end = app.indexOf(`function ${nextName}(`, start + 1);
assert.ok(start >= 0 && end > start, `${name} must precede ${nextName}`);
return app.slice(start, end).trim();
}
function recreateWith(stubs) {
const source = functionSource(
"recreatePlaylistEntryWithEnabled",
"playlistEntryManualEditBlockReason");
return new Function(
"namedKrxThemeRestoreWorkflow",
"namedNxtThemeRestoreWorkflow",
"namedForeignStockRestoreWorkflow",
"foreignIndexCandleRestoreWorkflow",
"manualFinancialWorkflow",
"manualListsWorkflow",
`return (${source});`)(
stubs.krx,
stubs.nxt,
stubs.foreignStock,
stubs.foreignIndex,
stubs.manualFinancial,
stubs.manualLists);
}
function stubSet() {
const recreate = label => (entry, enabled) => entry.trusted === label
? Object.freeze({ ...entry, enabled, retainedBrand: label })
: null;
return {
krx: {
isMaterializedEntry: entry => entry.trusted === "krx",
withEnabled: recreate("krx")
},
nxt: {
isMaterializedEntry: entry => entry.trusted === "nxt",
withEnabled: recreate("nxt")
},
foreignStock: {
source: "legacy-named-foreign-stock-workflow",
withEnabled: recreate("foreign-stock")
},
foreignIndex: { withEnabled: recreate("foreign-index") },
manualFinancial: { withEnabled: recreate("manual-financial") },
manualLists: { withEnabled: recreate("manual-lists") }
};
}
test("a frozen ordinary entry toggles through an immutable replacement", () => {
const recreate = recreateWith(stubSet());
const entry = Object.freeze({
id: "ordinary",
code: "5001",
enabled: true,
operator: Object.freeze({ source: "legacy-stock-workflow" })
});
const replacement = recreate(entry, false);
assert.notEqual(replacement, entry);
assert.equal(entry.enabled, true);
assert.equal(replacement.enabled, false);
assert.equal(replacement.id, entry.id);
});
test("every capability-branded entry delegates to its trust-preserving workflow", () => {
const recreate = recreateWith(stubSet());
const cases = [
["krx", { source: "legacy-theme-workflow", namedKrxRestore: {} }],
["nxt", { source: "legacy-theme-workflow", namedNxtRestore: {} }],
["foreign-stock", { source: "legacy-named-foreign-stock-workflow" }],
["foreign-index", { source: "legacy-foreign-index-candle-workflow" }],
["manual-financial", { source: "manual-financial-workflow" }],
["manual-lists", { source: "legacy-manual-lists-workflow" }]
];
for (const [trusted, operator] of cases) {
const entry = Object.freeze({ id: trusted, enabled: true, trusted, operator });
const replacement = recreate(entry, false);
assert.equal(replacement.retainedBrand, trusted);
assert.equal(replacement.enabled, false);
assert.equal(entry.enabled, true);
}
});
test("a proof-shaped but unbranded named entry fails closed instead of becoming a plain clone", () => {
const recreate = recreateWith(stubSet());
const forgedKrx = Object.freeze({
id: "forged-krx",
enabled: true,
operator: Object.freeze({
source: "legacy-theme-workflow",
namedKrxRestore: Object.freeze({ schemaVersion: 1 })
})
});
assert.equal(recreate(forgedKrx, false), null);
});
test("checkbox, enabled-all and current-row paths never assign into playlist entries", () => {
const render = functionSource("renderPlaylist", "selectPlaylistRow");
const all = functionSource("toggleAllEntriesEnabled", "moveSelected");
const current = functionSource("toggleCurrentEntryEnabled", "savePlaylist");
assert.match(render, /replacePlaylistEntryEnabledAt\(index, checkbox\.checked\)/u);
assert.match(render, /renderPlaylist\(\)/u);
assert.match(all, /state\.playlist\.map\(item => recreatePlaylistEntryWithEnabled/u);
assert.match(all, /replacements\.some\(item => !item\)/u);
assert.match(current,
/replacePlaylistEntryEnabledAt\(state\.currentIndex, item\.enabled === false\)/u);
assert.doesNotMatch(`${render}\n${all}\n${current}`, /item\.enabled\s*=(?!=)/u);
});

View File

@@ -0,0 +1,180 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..", "..");
const app = fs.readFileSync(path.join(root, "Web", "app.js"), "utf8");
function functionSource(name, nextName) {
const start = app.indexOf(`function ${name}(`);
const end = app.indexOf(`function ${nextName}(`, start + 1);
assert.ok(start >= 0 && end > start, `${name} must precede ${nextName}`);
return app.slice(start, end).trim();
}
function evaluateFunction(name, nextName, dependencies = {}) {
const names = Object.keys(dependencies);
return new Function(...names,
`return (${functionSource(name, nextName)});`)(...names.map(key => dependencies[key]));
}
function stateStub() {
return {
namedPlaylist: { guardByEntryId: new Map() },
manualFinancialRestore: { entries: new Map() }
};
}
test("every restored workflow boundary blocks direct scene and DB-row edits", () => {
const state = stateStub();
const blockReason = evaluateFunction(
"playlistEntryManualEditBlockReason",
"normalizeManualSceneSelection",
{ state });
const protectedEntries = [
["krx", "legacy-theme-workflow", { namedKrxRestore: {} }],
["nxt", "legacy-theme-workflow", { namedNxtRestore: {} }],
["foreign-stock", "legacy-named-foreign-stock-workflow", {}],
["foreign-index", "legacy-foreign-index-candle-workflow", {}],
["manual-financial", "manual-financial-workflow", {}],
["manual-lists", "legacy-manual-lists-workflow", {}]
];
for (const [id, source, proof] of protectedEntries) {
const entry = Object.freeze({
id,
enabled: true,
operator: Object.freeze({ source, ...proof })
});
assert.equal(blockReason(entry), "trusted-workflow-entry", id);
}
const guarded = Object.freeze({ id: "guarded", enabled: true });
state.namedPlaylist.guardByEntryId.set(guarded.id, Object.freeze({ trusted: false }));
assert.equal(blockReason(guarded), "named-playlist-entry");
const pending = Object.freeze({ id: "pending", enabled: true });
state.manualFinancialRestore.entries.set(pending.id, Object.freeze({ status: "load" }));
assert.equal(blockReason(pending), "manual-restore-entry");
});
test("a frozen ordinary entry receives scene values through an immutable replacement", () => {
const state = stateStub();
const blockReason = evaluateFunction(
"playlistEntryManualEditBlockReason",
"normalizeManualSceneSelection",
{ state });
const normalize = evaluateFunction(
"normalizeManualSceneSelection",
"recreatePlaylistEntryWithSceneSelection");
const sceneAliases = item => item.aliases;
const recreate = evaluateFunction(
"recreatePlaylistEntryWithSceneSelection",
"recreatePlaylistEntryWithLiveSelection",
{
playlistEntryManualEditBlockReason: blockReason,
normalizeManualSceneSelection: normalize,
sceneAliases
});
const entry = Object.freeze({
id: "ordinary",
code: "5001",
aliases: Object.freeze(["5001", "N5001"]),
enabled: true,
fadeDuration: 6,
selection: Object.freeze({
groupCode: "KOSPI",
subject: "OLD",
graphicType: "",
subtype: "",
dataCode: ""
})
});
const replacement = recreate(entry, {
groupCode: " NXT_KOSPI ",
subject: " 삼성전자 ",
graphicType: "CURRENT",
subtype: "",
dataCode: "005930"
}, "N5001", 4);
assert.notEqual(replacement, entry);
assert.equal(entry.code, "5001");
assert.equal(entry.fadeDuration, 6);
assert.equal(entry.selection.subject, "OLD");
assert.equal(replacement.code, "N5001");
assert.equal(replacement.fadeDuration, 4);
assert.equal(replacement.selection.groupCode, "NXT_KOSPI");
assert.equal(replacement.selection.subject, "삼성전자");
assert.equal(Object.isFrozen(replacement.selection), true);
assert.equal(recreate(entry, replacement.selection, "invalid", 4), null);
});
test("live DB selection also replaces only an unprotected ordinary entry", () => {
const state = stateStub();
const blockReason = evaluateFunction(
"playlistEntryManualEditBlockReason",
"normalizeManualSceneSelection",
{ state });
const normalize = evaluateFunction(
"normalizeManualSceneSelection",
"recreatePlaylistEntryWithSceneSelection");
const recreate = evaluateFunction(
"recreatePlaylistEntryWithLiveSelection",
"replacePlaylistEntryAt",
{
playlistEntryManualEditBlockReason: blockReason,
normalizeManualSceneSelection: normalize
});
const ordinary = Object.freeze({
id: "ordinary-live",
code: "5001",
enabled: true,
selection: Object.freeze({
groupCode: "KOSPI_STOCK",
subject: "OLD",
graphicType: "CURRENT",
subtype: "",
dataCode: "000001"
})
});
const selection = {
groupCode: "KOSPI_STOCK",
subject: "삼성전자",
graphicType: "CURRENT",
subtype: "",
dataCode: "005930"
};
const replacement = recreate(ordinary, selection);
assert.notEqual(replacement, ordinary);
assert.equal(ordinary.selection.subject, "OLD");
assert.equal(replacement.selection.subject, "삼성전자");
assert.equal(replacement.subject, "삼성전자");
assert.equal(replacement.dataCode, "005930");
const protectedEntry = Object.freeze({
...ordinary,
id: "protected-live",
operator: Object.freeze({ source: "legacy-stock-workflow" })
});
assert.equal(recreate(protectedEntry, selection), null);
});
test("both edit handlers keep snapshot locking and contain no playlist-entry assignment", () => {
const render = functionSource("renderSceneSelectionForm", "applySceneSelection");
const scene = functionSource("applySceneSelection", "applyCutAliasPreset");
const live = functionSource("applyLiveRowToCurrentCut", "renderLiveTables");
assert.match(render, /playlistEntryManualEditBlockReason\(item\)/u);
assert.match(scene, /if \(!requirePlaylistEditable\(\)\) return;/u);
assert.match(scene, /playlistEntryManualEditBlockReason\(item\)/u);
assert.match(scene, /recreatePlaylistEntryWithSceneSelection/u);
assert.match(scene, /replacePlaylistEntryAt\(state\.currentIndex, replacement\)/u);
assert.match(live, /if \(!requirePlaylistEditable\(\)\) return;/u);
assert.match(live, /playlistEntryManualEditBlockReason\(item\)/u);
assert.match(live, /recreatePlaylistEntryWithLiveSelection/u);
assert.match(live, /replacePlaylistEntryAt\(state\.currentIndex, replacement\)/u);
assert.doesNotMatch(app,
/\bitem\.[A-Za-z_$][A-Za-z0-9_$]*\s*=(?!=)/u);
});