feat: restore legacy named playlist workflows
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", {
|
||||
|
||||
136
tests/Web/named-foreign-stock-restore-app-integration.test.cjs
Normal file
136
tests/Web/named-foreign-stock-restore-app-integration.test.cjs
Normal 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);
|
||||
});
|
||||
251
tests/Web/named-foreign-stock-restore-workflow.test.cjs
Normal file
251
tests/Web/named-foreign-stock-restore-workflow.test.cjs
Normal 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);
|
||||
});
|
||||
83
tests/Web/named-krx-theme-restore-app-integration.test.cjs
Normal file
83
tests/Web/named-krx-theme-restore-app-integration.test.cjs
Normal 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);
|
||||
});
|
||||
249
tests/Web/named-krx-theme-restore-workflow.test.cjs
Normal file
249
tests/Web/named-krx-theme-restore-workflow.test.cjs
Normal 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);
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
122
tests/Web/playlist-enabled-toggle-app-integration.test.cjs
Normal file
122
tests/Web/playlist-enabled-toggle-app-integration.test.cjs
Normal 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);
|
||||
});
|
||||
180
tests/Web/playlist-entry-edit-safety-app-integration.test.cjs
Normal file
180
tests/Web/playlist-entry-edit-safety-app-integration.test.cjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user