Skip to content

Commit c3c6c70

Browse files
ozakboyclaude
andcommitted
更新 v3.1.0 CHANGELOG
新增 自訂時間格式 thread 顯示開關 OutputFormat txt/log/json 新增 Quote pipeline 對齊 Binance ticker schema 含 A1 強型別與 A2 struct 雙路徑 新增 4 個 xUnit 測試檔 共 48 個測試 OzaLog.Test 重寫為完整 v3.1 demo 升級 System.Text.Json 8.0.5 至 9.0.16 Microsoft.SourceLink.GitHub 8.0.0 至 10.0.300 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b722317 commit c3c6c70

28 files changed

Lines changed: 3035 additions & 128 deletions

OzaLog/OzaLog.Test/Program.cs

Lines changed: 467 additions & 77 deletions
Large diffs are not rendered by default.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using System;
2+
using System.Threading;
3+
using OzaLog.Core;
4+
using Xunit;
5+
6+
namespace OzaLog.Tests
7+
{
8+
/// <summary>
9+
/// v3.1+ 驗證 LogFormatter 在不同 TimeFormat / Thread 顯示開關下的輸出。
10+
/// </summary>
11+
/// <remarks>
12+
/// 注意:LogConfiguration.Configure 全域只能呼叫一次,測試中無法切換 TimeFormat。
13+
/// 這裡只測試「預設行為」與 LogFormatter 內部分支(fast path / fallback)。
14+
/// 完整 e2e 切換驗證留給人工煙測。
15+
/// </remarks>
16+
public class CustomTimeFormatTests
17+
{
18+
[Fact]
19+
public void Format_FastPath_DefaultTimeFormatProducesHHmmssfff()
20+
{
21+
var ticks = new DateTime(2026, 5, 14, 10, 23, 45, 123, DateTimeKind.Local).Ticks;
22+
var item = new LogItem(LogLevel.Info, "", "x", null, ticks, 42, null, false);
23+
var line = LogFormatter.Format(in item);
24+
25+
// 預設 TimeFormat = HH:mm:ss.fff
26+
Assert.StartsWith("10:23:45.123", line);
27+
}
28+
29+
[Fact]
30+
public void Format_ThreadName_SkippedWhenNullEvenIfShowThreadNameTrue()
31+
{
32+
// 預設配置:ShowThreadId=true, ShowThreadName=false
33+
// 沒設定 Configure 時用預設值;此測試依預設行為,不切配置
34+
var ticks = DateTime.Now.Ticks;
35+
var item = new LogItem(LogLevel.Info, "", "msg", null, ticks, 7, null, false);
36+
var line = LogFormatter.Format(in item);
37+
38+
// 預設只有 [T:tid],不含 N: 區段
39+
Assert.Contains("[T:7]", line);
40+
Assert.DoesNotContain("N:", line);
41+
}
42+
}
43+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using System;
2+
using OzaLog.Core;
3+
using Xunit;
4+
5+
namespace OzaLog.Tests
6+
{
7+
/// <summary>
8+
/// v3.1+ JsonLogFormatter NDJSON 輸出驗證
9+
/// </summary>
10+
public class JsonLogFormatterTests
11+
{
12+
[Fact]
13+
public void Format_ProducesValidNdjsonWithRequiredFields()
14+
{
15+
var ticks = new DateTime(2026, 5, 14, 10, 23, 45, 123, DateTimeKind.Local).Ticks;
16+
var item = new LogItem(LogLevel.Info, "BTC", "hello", null, ticks, 42, "Dispatcher", false);
17+
18+
var line = JsonLogFormatter.Format(in item);
19+
20+
Assert.StartsWith("{", line);
21+
Assert.EndsWith("}", line);
22+
Assert.Contains("\"ts\":", line);
23+
Assert.Contains("\"lv\":\"Info\"", line);
24+
Assert.Contains("\"nm\":\"BTC\"", line);
25+
Assert.Contains("\"msg\":\"hello\"", line);
26+
// 一行不應有換行(NDJSON 由 StreamWriter.WriteLine 補)
27+
Assert.DoesNotContain("\n", line);
28+
}
29+
30+
[Fact]
31+
public void Format_AllLevelsProduceCorrectLvString()
32+
{
33+
var ticks = DateTime.Now.Ticks;
34+
foreach (var (level, expected) in new[]
35+
{
36+
(LogLevel.Trace, "Trace"),
37+
(LogLevel.Debug, "Debug"),
38+
(LogLevel.Info, "Info"),
39+
(LogLevel.Warn, "Warn"),
40+
(LogLevel.Error, "Error"),
41+
(LogLevel.Fatal, "Fatal"),
42+
(LogLevel.CustomName, "CustomName"),
43+
})
44+
{
45+
var item = new LogItem(level, "n", "m", null, ticks, 1, null, false);
46+
var line = JsonLogFormatter.Format(in item);
47+
Assert.Contains($"\"lv\":\"{expected}\"", line);
48+
}
49+
}
50+
51+
[Fact]
52+
public void Format_AppliesArgsToMsgField()
53+
{
54+
var ticks = DateTime.Now.Ticks;
55+
var item = new LogItem(LogLevel.Info, "", "user {0} did {1}",
56+
new object[] { "alice", "login" }, ticks, 1, null, false);
57+
58+
var line = JsonLogFormatter.Format(in item);
59+
60+
Assert.Contains("\"msg\":\"user alice did login\"", line);
61+
}
62+
}
63+
}

OzaLog/OzaLog.Tests/LogFormatterTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ public class LogFormatterTests
1010
public void Format_ProducesExpectedTimestampLayout()
1111
{
1212
var ticks = new DateTime(2026, 5, 8, 9, 7, 5, 123, DateTimeKind.Local).Ticks;
13-
var item = new LogItem(LogLevel.Info, "", "hello", null, ticks, 42, false);
13+
var item = new LogItem(LogLevel.Info, "", "hello", null, ticks, 42, null, false);
1414
var line = LogFormatter.Format(in item);
1515

1616
Assert.StartsWith("09:07:05.123[T:42] ", line);
@@ -21,7 +21,7 @@ public void Format_ProducesExpectedTimestampLayout()
2121
public void Format_AppliesArgsFormatting()
2222
{
2323
var ticks = DateTime.Now.Ticks;
24-
var item = new LogItem(LogLevel.Info, "", "user {0} did {1}", new object[] { "alice", "login" }, ticks, 1, false);
24+
var item = new LogItem(LogLevel.Info, "", "user {0} did {1}", new object[] { "alice", "login" }, ticks, 1, null, false);
2525
var line = LogFormatter.Format(in item);
2626

2727
Assert.Contains("user alice did login", line);
@@ -32,7 +32,7 @@ public void Format_GracefullyHandlesBadFormatString()
3232
{
3333
var ticks = DateTime.Now.Ticks;
3434
// {2} 超出 args 範圍 → 應 fallback 為原字串而非拋例外
35-
var item = new LogItem(LogLevel.Info, "", "broken {2}", new object[] { "x" }, ticks, 1, false);
35+
var item = new LogItem(LogLevel.Info, "", "broken {2}", new object[] { "x" }, ticks, 1, null, false);
3636
var line = LogFormatter.Format(in item);
3737

3838
Assert.Contains("broken {2}", line);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using OzaLog.Core;
2+
using Xunit;
3+
4+
namespace OzaLog.Tests
5+
{
6+
/// <summary>
7+
/// v3.1+ QuoteFileStreamPool.Sanitize 驗證:把檔系統非法字元換成 '-'
8+
/// </summary>
9+
public class QuoteFileStreamPoolSanitizeTests
10+
{
11+
[Theory]
12+
[InlineData("BTCUSDT", "BTCUSDT")] // 純 alphanumeric 不動
13+
[InlineData("BTC-USDT-SWAP", "BTC-USDT-SWAP")] // 連字號合法
14+
[InlineData("BTC/USDT", "BTC-USDT")] // / 換成 -
15+
[InlineData("BTC\\USDT", "BTC-USDT")] // \ 換成 -
16+
[InlineData("BTC:USDT", "BTC-USDT")] // : 換成 -
17+
[InlineData("BTC*USDT", "BTC-USDT")] // * 換成 -
18+
[InlineData("BTC?USDT", "BTC-USDT")] // ? 換成 -
19+
[InlineData("BTC|USDT", "BTC-USDT")] // | 換成 -
20+
[InlineData("BTC<USDT>", "BTC-USDT-")] // < > 都換成 -
21+
[InlineData("BTC\"USDT", "BTC-USDT")] // 雙引號 換成 -
22+
[InlineData("BTC/USDT:PERP", "BTC-USDT-PERP")] // 多個非法字元
23+
public void Sanitize_ReplacesInvalidCharsWithDash(string input, string expected)
24+
{
25+
Assert.Equal(expected, QuoteFileStreamPool.Sanitize(input));
26+
}
27+
28+
[Fact]
29+
public void Sanitize_EmptyOrNullPassesThrough()
30+
{
31+
Assert.Equal("", QuoteFileStreamPool.Sanitize(""));
32+
Assert.Null(QuoteFileStreamPool.Sanitize(null));
33+
}
34+
}
35+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using OzaLog;
4+
using OzaLog.Core;
5+
using Xunit;
6+
7+
namespace OzaLog.Tests
8+
{
9+
/// <summary>
10+
/// v3.1+ QuoteFormatter 三種輸出格式 + 驗證邏輯測試
11+
/// </summary>
12+
public class QuoteFormatterTests
13+
{
14+
private static long FixedTicks => new DateTime(2026, 5, 14, 10, 23, 45, 123, DateTimeKind.Local).Ticks;
15+
16+
[Fact]
17+
public void Format_Txt_OutputsHumanReadableKvLine()
18+
{
19+
var rec = new QuoteRecord(
20+
symbol: "BTCUSDT",
21+
bucket: "binance_spot",
22+
ticks: FixedTicks,
23+
last: 60123.5m,
24+
bid: 60123.0m,
25+
ask: 60124.0m);
26+
27+
var line = QuoteFormatter.Format(in rec, QuoteOutputFormat.Txt);
28+
29+
Assert.Contains("[2026-05-14 10:23:45.123]", line);
30+
Assert.Contains("binance_spot BTCUSDT", line);
31+
Assert.Contains("last=60123.5", line);
32+
Assert.Contains("bid=60123.0", line);
33+
Assert.Contains("ask=60124.0", line);
34+
}
35+
36+
[Fact]
37+
public void Format_Txt_SkipsNullOptionalFields()
38+
{
39+
var rec = new QuoteRecord(
40+
symbol: "ETHUSDT",
41+
bucket: "binance_spot",
42+
ticks: FixedTicks,
43+
last: 3000m);
44+
45+
var line = QuoteFormatter.Format(in rec, QuoteOutputFormat.Txt);
46+
47+
Assert.Contains("last=3000", line);
48+
Assert.DoesNotContain("bid=", line);
49+
Assert.DoesNotContain("ask=", line);
50+
Assert.DoesNotContain("open=", line);
51+
Assert.DoesNotContain("volume=", line);
52+
}
53+
54+
[Fact]
55+
public void Format_Json_OutputsNDJSONWithEpochMs()
56+
{
57+
var rec = new QuoteRecord(
58+
symbol: "BTCUSDT",
59+
bucket: "binance_spot",
60+
ticks: FixedTicks,
61+
last: 60123.5m,
62+
bid: 60123.0m,
63+
bidQty: 0.5m,
64+
ask: 60124.0m,
65+
askQty: 1.2m);
66+
67+
var line = QuoteFormatter.Format(in rec, QuoteOutputFormat.Json);
68+
69+
Assert.StartsWith("{", line);
70+
Assert.EndsWith("}", line);
71+
Assert.Contains("\"symbol\":\"BTCUSDT\"", line);
72+
Assert.Contains("\"bucket\":\"binance_spot\"", line);
73+
Assert.Contains("\"last\":60123.5", line);
74+
Assert.Contains("\"bid\":60123.0", line);
75+
Assert.Contains("\"bidQty\":0.5", line);
76+
Assert.Contains("\"ask\":60124.0", line);
77+
Assert.Contains("\"askQty\":1.2", line);
78+
79+
// ts 應為 epoch_ms 整數
80+
Assert.Contains("\"ts\":", line);
81+
// 不含 thread 資訊(Quote 不寫 tid/tn)
82+
Assert.DoesNotContain("\"tid\"", line);
83+
Assert.DoesNotContain("\"tn\"", line);
84+
}
85+
86+
[Fact]
87+
public void Format_Json_SkipsNullOptionalFields()
88+
{
89+
var rec = new QuoteRecord(
90+
symbol: "ETHUSDT",
91+
bucket: "binance_spot",
92+
ticks: FixedTicks,
93+
last: 3000m);
94+
95+
var line = QuoteFormatter.Format(in rec, QuoteOutputFormat.Json);
96+
97+
Assert.Contains("\"last\":3000", line);
98+
Assert.DoesNotContain("\"bid\"", line);
99+
Assert.DoesNotContain("\"ask\"", line);
100+
Assert.DoesNotContain("\"open\"", line);
101+
}
102+
103+
[Fact]
104+
public void Format_Json_ExtrasNestedInExtrasObject()
105+
{
106+
var extras = new Dictionary<string, object>
107+
{
108+
["funding"] = 0.0001m,
109+
["mark"] = 60100m,
110+
};
111+
var rec = new QuoteRecord(
112+
symbol: "BTCUSDT",
113+
bucket: "binance_perp",
114+
ticks: FixedTicks,
115+
last: 60123.5m,
116+
extras: extras);
117+
118+
var line = QuoteFormatter.Format(in rec, QuoteOutputFormat.Json);
119+
120+
// Extras 應該 nested 在 "extras":{...} 而不是 top level
121+
Assert.Contains("\"extras\":{", line);
122+
Assert.Contains("\"funding\":0.0001", line);
123+
Assert.Contains("\"mark\":60100", line);
124+
}
125+
126+
[Fact]
127+
public void Format_ThrowsWhenExtrasAndExtrasJsonBothSet()
128+
{
129+
var rec = new QuoteRecord(
130+
symbol: "BTCUSDT",
131+
bucket: "x",
132+
ticks: FixedTicks,
133+
last: 1m,
134+
extras: new Dictionary<string, object> { ["a"] = 1 },
135+
extrasJson: "{\"b\":2}");
136+
137+
var ex = Assert.Throws<ArgumentException>(() => QuoteFormatter.Format(in rec, QuoteOutputFormat.Txt));
138+
Assert.Contains("不可同時設定", ex.Message);
139+
}
140+
141+
[Fact]
142+
public void Format_ThrowsWhenExtrasKeyCollidesWithReservedKey()
143+
{
144+
var rec = new QuoteRecord(
145+
symbol: "BTCUSDT",
146+
bucket: "x",
147+
ticks: FixedTicks,
148+
last: 1m,
149+
extras: new Dictionary<string, object> { ["bid"] = 999m });
150+
151+
Assert.Throws<ArgumentException>(() => QuoteFormatter.Format(in rec, QuoteOutputFormat.Json));
152+
}
153+
154+
[Fact]
155+
public void Format_Log_SameContentAsTxt()
156+
{
157+
var rec = new QuoteRecord(
158+
symbol: "BTCUSDT",
159+
bucket: "binance_spot",
160+
ticks: FixedTicks,
161+
last: 60123.5m);
162+
163+
var txtLine = QuoteFormatter.Format(in rec, QuoteOutputFormat.Txt);
164+
var logLine = QuoteFormatter.Format(in rec, QuoteOutputFormat.Log);
165+
166+
Assert.Equal(txtLine, logLine);
167+
}
168+
}
169+
}

0 commit comments

Comments
 (0)