using ExtractMethod.Tooling; using Microsoft.CodeAnalysis; namespace BeforeAfter.Tests.ExtractMethod; /// /// 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. /// 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) -> 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("", 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); } }