Files
db-subclass-to-dto/tests/BeforeAfter.Tests/ExtractMethod/ExtractFirstTests.cs
T
mostalive 49a5633a7d em 03: extract-first scan + full suggestion report
- ExtractFirstScanner: reads-only scan of non-local/param read expressions
  (member/field/property access, element access, invocation, cast), deduped
  by symbol+text with occurrence counts; invocations flagged (hoisting
  changes eval count); own-class field/property reads marked optional.
  Assignment LHS skipped whole (decision 5) — surfaced as a report note.
- SignatureBuilder: promotes bucket overlaps into one coherent signature
  (declared-inside vars drop from params; declared-inside return candidates
  become the return; outside candidates already covered by their ref
  write-back stay ref params). Trailing composite returns stay void with a
  note suggesting an extract-variable first.
- ExtractionReporter + ReportFormatter: single composition shared by CLI and
  tests; report = header, params (ref), returns, locals, extract-first
  (count + flags), notes, suggested-signature line last.
- DataFlowClassifier: Returns now carry types (ReturnSuggestion) — the
  signature line needs them.
- Displays: compact symbol/type formatting for the report.
- Fixture: RepeatReads (dedupe ×2 + skipped field write), Casts (cast
  candidate) appended at the end so all pinned line numbers stay put.
2026-09-12 18:26:20 +01:00

144 lines
6.7 KiB
C#

using ExtractMethod.Tooling;
using Microsoft.CodeAnalysis;
namespace BeforeAfter.Tests.ExtractMethod;
/// <summary>
/// Tests for yak em 03: the extract-first scan over the checked-in Demo.cs
/// fixture. Line anchors are the exact fixture lines pinned by em 01/02:
/// ScoreReads body 56..58, Summarize body 68..76, Heaviest body 87..93,
/// RepeatReads body 105..108, Casts body 116 (the em 03 additions at the
/// end of the fixture; nothing before them may shift — see Demo.cs header).
///
/// Extract-first = read expressions that are NOT simple local/parameter
/// references (parent bucket spec): member/field/property access, element
/// access, invocations, casts — deduped by symbol + text, occurrence-
/// counted, invocations flagged ("hoisting changes eval count"), own-class
/// field/property access marked optional.
/// </summary>
public class ExtractFirstTests
{
// ---------------------------------------------------------------------
// optional marking (own-class FIELD): ScoreReads reads `_seed` once;
// `seed`, `score`, `bonus` are simple local/param names and must NOT
// appear (decision #6 puts them in the params bucket instead).
// ---------------------------------------------------------------------
[Fact]
public void ScoreReads_reports_own_class_field_once_and_optional()
{
var report = Compose(56, 58);
var entry = Assert.Single(report.ExtractFirst);
Assert.Equal("_seed", entry.Text);
Assert.Equal("Demo._seed", entry.SymbolDisplay);
Assert.Equal(1, entry.Occurrences);
Assert.False(entry.IsInvocation);
Assert.True(entry.Optional, "own-class field reads are optional to hoist");
Assert.Equal("field", entry.OwnMemberKind);
}
// ---------------------------------------------------------------------
// optional marking (own-class PROPERTY): Summarize reads `Scale` once
// (line 74). The binary expressions around it (`total * Scale`,
// `"sum=" + scaled`) are composites of locals/params and literals —
// exactly the shapes the parent spec does NOT list, so they stay out.
// ---------------------------------------------------------------------
[Fact]
public void Summarize_reports_own_class_property_once_and_optional()
{
var report = Compose(68, 76);
var entry = Assert.Single(report.ExtractFirst);
Assert.Equal("Scale", entry.Text);
Assert.Equal("Demo.Scale", entry.SymbolDisplay);
Assert.Equal(1, entry.Occurrences);
Assert.False(entry.IsInvocation);
Assert.True(entry.Optional, "own-class property reads are optional to hoist");
Assert.Equal("property", entry.OwnMemberKind);
}
// ---------------------------------------------------------------------
// invocation flag + element access (Heaviest, full body 87..93):
// `widgets[0]` and `widgets[i]` are two DIFFERENT indexer reads (same
// indexer symbol, different text -> separate entries); the call
// `best.Bigger(widgets[i])` is flagged because hoisting it changes how
// often it is evaluated. The callee `best.Bigger` must NOT be reported
// separately (the invocation covers it), and `best`/`i`/`widgets`/
// `count` are simple local/param names — invisible to this bucket.
// None of Heaviest's candidates is own-class (Widget is a nested record,
// the indexer belongs to List<T>) -> nothing optional here.
// ---------------------------------------------------------------------
[Fact]
public void Heaviest_flags_the_invocation_and_lists_both_indexer_reads()
{
var report = Compose(87, 93);
Assert.Equal(
new[] { "widgets[0]", "best.Bigger(widgets[i])", "widgets[i]" },
report.ExtractFirst.Select(e => e.Text));
Assert.All(report.ExtractFirst, e => Assert.Equal(1, e.Occurrences));
Assert.All(report.ExtractFirst, e => Assert.False(e.Optional)); // none of Heaviest's reads is own-class
var invocation = Assert.Single(report.ExtractFirst, e => e.IsInvocation);
Assert.Equal("best.Bigger(widgets[i])", invocation.Text);
}
// ---------------------------------------------------------------------
// dedupe + occurrence count (RepeatReads, body 105..108): `_seed` is
// read TWICE (lines 105, 106) and must dedupe to ONE entry with
// Occurrences == 2. The field WRITE `_seed = a;` (line 107) is a
// non-variable assignment LHS — the reads-only scan skips it, so it
// neither adds an occurrence nor changes the count (decision 5), and
// the report must carry the decision-5 limitation note.
// ---------------------------------------------------------------------
[Fact]
public void RepeatReads_dedupes_the_field_read_to_count_two_and_skips_the_field_write()
{
var report = Compose(105, 108);
var entry = Assert.Single(report.ExtractFirst);
Assert.Equal("_seed", entry.Text);
Assert.Equal(2, entry.Occurrences);
Assert.True(entry.Optional);
Assert.Equal("field", entry.OwnMemberKind);
// Two notes apply to this selection: the decision-5 write limitation
// AND the composite trailing return (`return a + b;`).
Assert.Equal(2, report.Notes.Count);
Assert.Contains(report.Notes, n => n.Contains("assignment left-hand sides", StringComparison.Ordinal));
Assert.Contains(report.Notes, n => n.Contains("composite return expression (a + b)", StringComparison.Ordinal));
}
// ---------------------------------------------------------------------
// cast (Casts, body 116): `(double)total` is an extract-first candidate
// (parent spec lists casts). A conversion is not a symbol, so the entry
// keys on its text alone ("unbound" display) and carries no flags.
// ---------------------------------------------------------------------
[Fact]
public void Casts_reports_the_cast_expression_and_the_field_read()
{
var report = Compose(116, 116);
Assert.Equal(
new[] { "(double)total", "_seed" },
report.ExtractFirst.Select(e => e.Text));
var cast = report.ExtractFirst[0];
Assert.Equal(1, cast.Occurrences);
Assert.False(cast.IsInvocation);
Assert.False(cast.Optional);
Assert.Equal("<unbound>", cast.SymbolDisplay);
}
private static ExtractionReport Compose(int startLine, int endLine)
{
var (tree, compilation) = DemoFixture.Load();
var model = compilation.GetSemanticModel(tree);
var resolved = SelectionResolver.Resolve(tree, startLine, endLine);
Assert.True(resolved.Succeeded, resolved.Error);
return ExtractionReporter.Compose(model, resolved);
}
}