Skip to content

4.13 - Ôn tập và đánh giá Module 4 ​

Tóm tắt: Bài ôn tập cuối Module 4 gồm ba phần: 10 câu quiz bám vào những cái bẫy chính của module (constructor giữ bất biến, bẫy collection, thứ tự khởi tạo khi gọi virtual, struct và ==, record chứa List, CS8509 với CS8524, Equals thiếu GetHashCode, Turkish I, "trạng thái không phải kiểu"), ba bài lab xây domain CRM nhỏ có test xUnit kèm lời giải, và một rubric mô tả từng mức điểm. Làm quiz trước khi xem đáp án, làm lab trước khi mở lời giải.

Mục tiêu bài học ​

Sau bài này bạn có thể:

  • Trả lời đúng ít nhất 8/10 câu quiz và giải thích lý do cho từng câu.
  • Xây một domain model CRM nhỏ (Email, Customer, Lead, quy tắc giảm giá) đóng gói đúng, dùng interface và composition thay cho kế thừa sai chỗ.
  • Viết test xUnit chứng minh các quy tắc nghiệp vụ, và tự chấm theo rubric.

Cần biết trước: toàn bộ Module 4, từ bài 4.1 tới bài 4.12, cùng cách dựng solution có project test ở bài 0.4.

Nội dung bài học ​

4.13.1 — Cách làm bài ​

  1. Làm Phần A (quiz) trên giấy hoặc file nháp, ghi đáp án kèm một dòng lý do. Xong cả 10 câu mới mở đáp án.
  2. Làm Phần B (lab) trong một solution gồm một class library Crm.Domain và một project test Crm.Domain.Tests. Ba lab dùng chung solution này, lab sau xây trên lab trước.
  3. Tự chấm theo rubric ở mục 4.13.4. Câu quiz sai và tiêu chí lab bị trừ điểm cho biết bài nào cần đọc lại.

Dựng solution:

bash
dotnet new sln -n Crm
dotnet new classlib -n Crm.Domain -o src/Crm.Domain
dotnet new xunit -n Crm.Domain.Tests -o tests/Crm.Domain.Tests
dotnet sln add src/Crm.Domain tests/Crm.Domain.Tests
dotnet add tests/Crm.Domain.Tests reference src/Crm.Domain

Thêm vào src/Crm.Domain/Crm.Domain.csproj, trong <PropertyGroup>, cấu hình kiểm tra vét cạn của bài 4.8:

xml
<WarningsAsErrors>$(WarningsAsErrors);CS8509</WarningsAsErrors>
<NoWarn>$(NoWarn);CS8524</NoWarn>

4.13.2 — Phần A: Quiz (20 điểm, mỗi câu 2 điểm) ​

Câu 1. Class Lead có public required string Email { get; init; }. Dòng nào sau đây biên dịch được? A. var l = new Lead(); B. var l = new Lead { Email = "abc" }; C. var l = new Lead { Email = "[email protected]" }; l.Email = "[email protected]"; D. Không dòng nào

Đáp án câu 1

B. required chỉ kiểm tra có gán hay không, không kiểm tra giá trị, nên "abc" vẫn qua. A thiếu Email (lỗi CS9035). C gán lại thuộc tính init sau khi tạo (lỗi CS8852). Muốn từ chối "abc" thì phải kiểm tra trong constructor hoặc dùng value object Email. Xem mục 4.1.4 và bài 4.10.

Câu 2. Class Order có private readonly List<OrderLine> _lines và public IReadOnlyList<OrderLine> Lines => _lines;. Câu nào đúng? A. Người gọi không có cách nào thêm dòng hàng mà không qua AddLine B. order.Lines.Add(...) biên dịch được nhưng ném lỗi lúc chạy C. ((List<OrderLine>)order.Lines).Add(...) biên dịch và chạy được, bỏ qua mọi kiểm tra D. readonly trên field đã ngăn mọi thay đổi nội dung list

Đáp án câu 2

C. IReadOnlyList là một góc nhìn, không phải bản sao: ép kiểu ngược về List vẫn được. B sai vì order.Lines.Add là lỗi biên dịch CS1061. D sai vì readonly chỉ ngăn gán một list khác vào field. Cần chặn tuyệt đối thì trả _lines.AsReadOnly(). Xem bài 4.2.

Câu 3. Lớp cha gọi phương thức virtual Init() trong constructor. Lớp con override Init() để dùng field _items. Trường hợp nào ném NullReferenceException? A. Lớp con khai báo private readonly List<string> _items = new(); B. Lớp con gán _items = new List<string>(); trong thân constructor của nó C. Cả hai D. Không trường hợp nào, vì lớp con được đánh dấu sealed

Đáp án câu 3

B. Thứ tự khởi tạo: field initializer của lớp con, rồi constructor lớp cha (lúc này Init() chạy), rồi mới tới thân constructor lớp con. Với A, _items đã có ở bước 1. Với B, _items còn null khi Init() chạy. sealed ở lớp con không đổi thứ tự này. Xem mục 4.3.4.

Câu 4. Đoạn code sau in ra gì?

csharp
// Base.cs
public class Base { public virtual string Who() => "Base"; }
public class Derived : Base { public new string Who() => "Derived"; }
csharp
// Program.cs
Base b = new Derived();
Console.WriteLine(b.Who());

A. Derived B. Base C. Lỗi biên dịch D. Lỗi lúc chạy

Đáp án câu 4

B. new chỉ che phương thức cha, không thay thế nó, nên phương thức được chọn theo kiểu khai báo của biến (Base). Đổi new thành override thì in Derived. Xem mục 4.3.2.

Câu 5. Với public struct Point { public int X; public int Y; }, dòng Console.WriteLine(p1 == p2); cho kết quả gì? A. True nếu hai điểm cùng toạ độ B. False vì so sánh tham chiếu C. Lỗi biên dịch CS0019 D. Luôn True vì struct là kiểu giá trị

Đáp án câu 5

C. Struct tự định nghĩa không có sẵn toán tử ==. Equals thì có (so sánh từng field, nhưng chậm). Muốn có == theo giá trị, dùng readonly record struct. Xem mục 4.1.5.

Câu 6. record Order(int Id, List<string> Tags); Hai object new Order(1, ["vip"]) và new Order(1, ["vip"]) so sánh == cho kết quả gì? A. True, vì record so sánh theo giá trị B. False, vì List<string> so sánh theo tham chiếu C. Lỗi biên dịch D. True nếu đổi List<string> thành ImmutableArray<string>

Đáp án câu 6

B. Record gọi Equals của từng thành phần, và List so sánh theo tham chiếu. D cũng sai: ImmutableArray vẫn so sánh theo mảng nền, hai mảng khác nhau là khác nhau. Cách sửa: tự viết Equals/GetHashCode dùng SequenceEqual, hoặc dùng kiểu bọc như EquatableArray<T>. Xem bài 4.7.

Câu 7. Bạn muốn build thất bại khi ai đó thêm giá trị vào enum OrderStatus mà quên sửa các switch expression (không có _). Cấu hình nào đúng? A. <WarningsAsErrors>CS8524;CS8509</WarningsAsErrors> B. <WarningsAsErrors>CS8509</WarningsAsErrors> và <NoWarn>CS8524</NoWarn> C. Thêm nhánh _ => throw new UnreachableException() vào mọi switch D. Không cần gì, trình biên dịch mặc định đã báo lỗi

Đáp án câu 7

B. CS8509 báo thiếu giá trị có tên — đúng thứ cần bắt. CS8524 báo thiếu giá trị không tên như (OrderStatus)5 và xuất hiện cả khi switch đã đủ nhánh, nên A làm build hỏng ngay với code đúng. C tắt luôn CS8509 vì mọi giá trị mới rơi vào _. D sai vì mặc định chỉ là cảnh báo. Xem mục 4.8.3.

Câu 8. Một class ghi đè Equals theo tên và địa chỉ nhưng không ghi đè GetHashCode. Hai object a, b cùng dữ liệu. Kết quả nào sai? A. a.Equals(b) là True B. new List<T> { a }.Contains(b) là True C. new HashSet<T> { a }.Contains(b) là True D. Trình biên dịch phát cảnh báo CS0659

Đáp án câu 8

C (nó cho False). HashSet tính GetHashCode trước để tìm ngăn; mã băm mặc định theo danh tính object nên hai object rơi vào hai ngăn khác nhau và Equals không bao giờ được gọi. List.Contains chỉ dùng Equals nên vẫn đúng. Xem bài 4.12.

Câu 9. Máy chủ đặt văn hoá tr-TR. Cách kiểm tra vai trò nào luôn đúng? A. role.ToUpper() == "ADMIN" B. role.ToLower() == "admin" C. string.Compare(role, "admin", true) == 0 D. string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase)

Đáp án câu 9

D. ToUpper, ToLower và string.Compare không truyền StringComparison đều dùng văn hoá hiện tại: trong tiếng Thổ, "admin".ToUpper() là "ADMİN" (I có chấm). OrdinalIgnoreCase so từng ký tự, không phụ thuộc văn hoá. Xem mục 4.9.8.

Câu 10. Một lead sau khi chốt thì trở thành khách hàng. Thiết kế nào đúng? A. class Customer : Lead, đổi object lead thành customer khi chốt B. class Lead : Contact, class Customer : Contact, rồi ép kiểu (Customer)lead C. Lead có trạng thái LeadStatus; khi chốt, lead.ConvertToCustomer() tạo một Customer mới ghi nhớ SourceLeadId D. Một class LeadCustomer gộp cả hai

Đáp án câu 10

C. Một object không đổi được kiểu trong đời nó, nên A và B không làm được (ép kiểu ở B còn không biên dịch: error CS0030: Cannot convert type 'Lead' to 'Customer', vì hai lớp anh em không ép kiểu qua lại được). "Đã chốt" là trạng thái của lead, còn khách hàng là một thực thể mới có vòng đời riêng. D là lớp tổ hợp, triệu chứng ở bài 4.11. Xem mục 4.9.3.

4.13.3 — Phần B: Lab (80 điểm) ​

Mọi lab đặt code trong src/Crm.Domain (namespace Crm.Domain) và test trong tests/Crm.Domain.Tests. Tiêu chí chấm của mỗi lab là một danh sách test phải xanh cộng vài điểm kiểm tra bằng mắt. Tự viết test trước khi mở lời giải; lời giải có sẵn bộ test để bạn đối chiếu.

Bài 1 — Value object và đóng gói (Trung bình, 25 điểm) ​

Viết:

  • Email: value object (sealed record). Constructor từ chối chuỗi rỗng, chỉ có khoảng trắng, hoặc không có @ (ném ArgumentException); chuẩn hoá bằng Trim() và chữ thường.
  • OrderLine(string ProductName, int Quantity, decimal UnitPrice): record, có thuộc tính tính toán Amount.
  • Customer: constructor Customer(string name, Email email, Guid? sourceLeadId = null), từ chối tên rỗng. Có Id (Guid), Name, Email, SourceLeadId, Purchases kiểu IReadOnlyList<OrderLine>, TotalSpent tính từ Purchases, và phương thức AddPurchase(OrderLine line) từ chối số lượng không dương hoặc đơn giá âm (ném ArgumentOutOfRangeException).

Tiêu chí chấm:

Tiêu chíĐiểm
new Email(" [email protected] ") bằng new Email("[email protected]")5
Email ném ArgumentException với "", " ", "khong-co-a-cong"5
Customer ném ArgumentException khi tên rỗng3
Mua 2 × 3.000.000 và 5 × 200.000 thì TotalSpent là 7.000.0005
AddPurchase với số lượng 0 ném ArgumentOutOfRangeException3
Không có setter công khai nào; TotalSpent không phải field lưu riêng; Purchases không lộ ra List4
Lời giải — Bài 1
csharp
// src/Crm.Domain/Customers.cs
namespace Crm.Domain;

public sealed record Email
{
    public string Value { get; }

    public Email(string value)
    {
        if (string.IsNullOrWhiteSpace(value) || !value.Contains('@'))
            throw new ArgumentException($"Email không hợp lệ: {value}", nameof(value));
        Value = value.Trim().ToLowerInvariant();
    }

    public override string ToString() => Value;
}

public sealed record OrderLine(string ProductName, int Quantity, decimal UnitPrice)
{
    public decimal Amount => Quantity * UnitPrice;
}

public class Customer
{
    private readonly List<OrderLine> _purchases = new();

    public Customer(string name, Email email, Guid? sourceLeadId = null)
    {
        if (string.IsNullOrWhiteSpace(name))
            throw new ArgumentException("Tên khách hàng không được rỗng.", nameof(name));

        Id = Guid.NewGuid();
        Name = name.Trim();
        Email = email;
        SourceLeadId = sourceLeadId;
    }

    public Guid Id { get; }
    public string Name { get; }
    public Email Email { get; }
    public Guid? SourceLeadId { get; }
    public IReadOnlyList<OrderLine> Purchases => _purchases;
    public decimal TotalSpent => _purchases.Sum(p => p.Amount);

    public void AddPurchase(OrderLine line)
    {
        if (line.Quantity <= 0)
            throw new ArgumentOutOfRangeException(nameof(line), "Số lượng phải dương.");
        if (line.UnitPrice < 0)
            throw new ArgumentOutOfRangeException(nameof(line), "Đơn giá không được âm.");
        _purchases.Add(line);
    }
}
csharp
// tests/Crm.Domain.Tests/CustomerTests.cs
using Crm.Domain;

namespace Crm.Domain.Tests;

public class CustomerTests
{
    [Fact]
    public void Email_IsNormalized_AndComparedByValue()
    {
        Assert.Equal(new Email("[email protected]"), new Email(" [email protected] "));
    }

    [Theory]
    [InlineData("")]
    [InlineData("   ")]
    [InlineData("khong-co-a-cong")]
    public void Email_RejectsInvalidValue(string value)
    {
        Assert.Throws<ArgumentException>(() => new Email(value));
    }

    [Fact]
    public void Customer_RejectsEmptyName()
    {
        Assert.Throws<ArgumentException>(() => new Customer(" ", new Email("[email protected]")));
    }

    [Fact]
    public void TotalSpent_IsComputedFromPurchases()
    {
        var customer = new Customer("An", new Email("[email protected]"));
        customer.AddPurchase(new OrderLine("Máy in", 2, 3_000_000m));
        customer.AddPurchase(new OrderLine("Mực in", 5, 200_000m));

        Assert.Equal(7_000_000m, customer.TotalSpent);
    }

    [Fact]
    public void AddPurchase_RejectsNonPositiveQuantity()
    {
        var customer = new Customer("An", new Email("[email protected]"));
        Assert.Throws<ArgumentOutOfRangeException>(() => customer.AddPurchase(new OrderLine("Máy in", 0, 1m)));
    }
}

Điểm hay sai: để TotalSpent { get; private set; } rồi cộng dồn trong AddPurchase (hai nguồn sự thật, bài 4.2); dùng readonly record struct Email khiến default(Email) lọt qua kiểm tra (bài 4.7).

Bài 2 — Interface và composition (Trung bình - Khá, 25 điểm) ​

Một đơn hàng có thể được giảm giá theo nhiều quy tắc, và đội kinh doanh hay thêm quy tắc mới. Viết:

  • Interface IDiscountRule với decimal GetRate(Customer customer, decimal orderAmount) trả về tỉ lệ từ 0 tới 1.
  • LoyalCustomerDiscount: 5% nếu TotalSpent của khách từ 10.000.000 trở lên.
  • LargeOrderDiscount: 3% nếu giá trị đơn từ 5.000.000 trở lên.
  • PriceCalculator: nhận danh sách IDiscountRule qua constructor (composition, không kế thừa), có CalculateFinalPrice(Customer customer, decimal orderAmount) cộng tỉ lệ của mọi quy tắc nhưng tối đa 10%, và từ chối giá trị đơn âm.

Tiêu chí chấm:

Tiêu chíĐiểm
Không quy tắc nào: giá giữ nguyên4
Khách đã chi 12.000.000, đơn 10.000.000, hai quy tắc trên: giá cuối 9.200.0007
Test dùng bản giả IDiscountRule tự viết trả 8% hai lần: tổng bị giới hạn 10%, đơn 1.000.000 còn 900.0007
PriceCalculator không biết tên lớp quy tắc cụ thể nào; thêm quy tắc mới không phải sửa nó4
Mức trần 10% là hằng số có tên, không phải số rơi rớt trong biểu thức3
Lời giải — Bài 2
csharp
// src/Crm.Domain/Discounts.cs
namespace Crm.Domain;

public interface IDiscountRule
{
    // Trả về tỉ lệ giảm giá, từ 0 tới 1
    decimal GetRate(Customer customer, decimal orderAmount);
}

public sealed class LoyalCustomerDiscount : IDiscountRule
{
    public decimal GetRate(Customer customer, decimal orderAmount)
        => customer.TotalSpent >= 10_000_000m ? 0.05m : 0m;
}

public sealed class LargeOrderDiscount : IDiscountRule
{
    public decimal GetRate(Customer customer, decimal orderAmount)
        => orderAmount >= 5_000_000m ? 0.03m : 0m;
}

public class PriceCalculator(IEnumerable<IDiscountRule> rules)
{
    private const decimal MaxRate = 0.10m;

    public decimal CalculateFinalPrice(Customer customer, decimal orderAmount)
    {
        if (orderAmount < 0)
            throw new ArgumentOutOfRangeException(nameof(orderAmount));

        var rate = Math.Min(rules.Sum(r => r.GetRate(customer, orderAmount)), MaxRate);
        return orderAmount * (1 - rate);
    }
}
csharp
// tests/Crm.Domain.Tests/PriceCalculatorTests.cs
using Crm.Domain;

namespace Crm.Domain.Tests;

public class PriceCalculatorTests
{
    private static Customer LoyalCustomer()
    {
        var c = new Customer("An", new Email("[email protected]"));
        c.AddPurchase(new OrderLine("Máy chủ", 1, 12_000_000m));
        return c;
    }

    [Fact]
    public void NoRules_NoDiscount()
    {
        var calculator = new PriceCalculator([]);
        Assert.Equal(1_000_000m, calculator.CalculateFinalPrice(LoyalCustomer(), 1_000_000m));
    }

    [Fact]
    public void LoyalCustomer_LargeOrder_GetsBothDiscounts()
    {
        var calculator = new PriceCalculator([new LoyalCustomerDiscount(), new LargeOrderDiscount()]);
        Assert.Equal(9_200_000m, calculator.CalculateFinalPrice(LoyalCustomer(), 10_000_000m));   // giảm 5% + 3%
    }

    [Fact]
    public void TotalRate_IsCappedAtTenPercent()
    {
        var calculator = new PriceCalculator([new FixedRate(0.08m), new FixedRate(0.08m)]);
        Assert.Equal(900_000m, calculator.CalculateFinalPrice(LoyalCustomer(), 1_000_000m));
    }

    // Bản giả viết tay: luôn trả một tỉ lệ cố định
    private sealed class FixedRate(decimal rate) : IDiscountRule
    {
        public decimal GetRate(Customer customer, decimal orderAmount) => rate;
    }
}

Vì sao đây là composition. PriceCalculator có một danh sách quy tắc, không là một quy tắc. Thêm "giảm 2% dịp Tết" là viết một lớp mới cài IDiscountRule và đưa vào danh sách, không đụng tới PriceCalculator hay các quy tắc cũ. Nếu làm bằng kế thừa (class LoyalLargeOrderCalculator : PriceCalculator), số lớp sẽ tăng theo tổ hợp như bài 4.11.

Bài 3 — Vòng đời lead (Khá, 30 điểm) ​

Lead đi qua các trạng thái New → Contacted → Qualified → Converted, và từ bất kỳ trạng thái nào trước Converted có thể sang Lost. Viết:

  • enum LeadStatus { New, Contacted, Qualified, Converted, Lost }.
  • Lead: constructor Lead(string name, Email email), trạng thái ban đầu New. Các phương thức MarkContacted(), Qualify(), MarkLost(string reason) (bắt buộc có lý do), và ConvertToCustomer() trả về một Customer mới có SourceLeadId bằng Id của lead. Chuyển trạng thái sai thứ tự ném InvalidOperationException và không đổi trạng thái.
  • static string Describe(LeadStatus status): switch expression không có nhánh _, trả nhãn tiếng Việt.

Tiêu chí chấm:

Tiêu chíĐiểm
Đi đủ New → Contacted → Qualified, ConvertToCustomer() trả Customer có SourceLeadId == lead.Id, cùng Email; lead thành Converted8
ConvertToCustomer() khi lead còn New ném InvalidOperationException, trạng thái vẫn New5
Chuyển đổi lần hai ném InvalidOperationException4
MarkLost("") ném ArgumentException3
Describe không có _; xoá một nhánh thì build thất bại với error CS85095
Lead và Customer không kế thừa nhau hay kế thừa một lớp Contact chung; Status không có setter công khai5
Lời giải — Bài 3
csharp
// src/Crm.Domain/Leads.cs
namespace Crm.Domain;

public enum LeadStatus { New, Contacted, Qualified, Converted, Lost }

public class Lead
{
    public Lead(string name, Email email)
    {
        if (string.IsNullOrWhiteSpace(name))
            throw new ArgumentException("Tên lead không được rỗng.", nameof(name));

        Id = Guid.NewGuid();
        Name = name.Trim();
        Email = email;
        Status = LeadStatus.New;
    }

    public Guid Id { get; }
    public string Name { get; }
    public Email Email { get; }
    public LeadStatus Status { get; private set; }
    public string? LostReason { get; private set; }

    public void MarkContacted()
    {
        EnsureStatus(LeadStatus.New, nameof(MarkContacted));
        Status = LeadStatus.Contacted;
    }

    public void Qualify()
    {
        EnsureStatus(LeadStatus.Contacted, nameof(Qualify));
        Status = LeadStatus.Qualified;
    }

    public void MarkLost(string reason)
    {
        if (Status is LeadStatus.Converted or LeadStatus.Lost)
            throw new InvalidOperationException($"Lead đang ở trạng thái {Status}, không đánh dấu thất bại được.");
        if (string.IsNullOrWhiteSpace(reason))
            throw new ArgumentException("Phải ghi lý do.", nameof(reason));

        LostReason = reason;
        Status = LeadStatus.Lost;
    }

    // Lead KHÔNG đổi kiểu thành Customer: nó tạo ra một Customer mới, nhớ Id của lead nguồn
    public Customer ConvertToCustomer()
    {
        EnsureStatus(LeadStatus.Qualified, nameof(ConvertToCustomer));
        Status = LeadStatus.Converted;
        return new Customer(Name, Email, sourceLeadId: Id);
    }

    private void EnsureStatus(LeadStatus expected, string action)
    {
        if (Status != expected)
            throw new InvalidOperationException(
                $"{action} chỉ hợp lệ khi lead ở trạng thái {expected}, hiện là {Status}.");
    }

    public static string Describe(LeadStatus status) => status switch
    {
        LeadStatus.New       => "Mới tạo",
        LeadStatus.Contacted => "Đã liên hệ",
        LeadStatus.Qualified => "Đủ điều kiện",
        LeadStatus.Converted => "Đã thành khách hàng",
        LeadStatus.Lost      => "Thất bại",
    };
}
csharp
// tests/Crm.Domain.Tests/LeadTests.cs
using Crm.Domain;

namespace Crm.Domain.Tests;

public class LeadTests
{
    private static Lead NewLead() => new("Bình", new Email("[email protected]"));

    [Fact]
    public void HappyPath_ConvertsToCustomer_KeepingSourceLeadId()
    {
        var lead = NewLead();
        lead.MarkContacted();
        lead.Qualify();

        var customer = lead.ConvertToCustomer();

        Assert.Equal(LeadStatus.Converted, lead.Status);
        Assert.Equal(lead.Id, customer.SourceLeadId);
        Assert.Equal(lead.Email, customer.Email);
    }

    [Fact]
    public void CannotConvert_BeforeQualified()
    {
        var lead = NewLead();
        Assert.Throws<InvalidOperationException>(() => lead.ConvertToCustomer());
        Assert.Equal(LeadStatus.New, lead.Status);
    }

    [Fact]
    public void CannotConvert_Twice()
    {
        var lead = NewLead();
        lead.MarkContacted();
        lead.Qualify();
        lead.ConvertToCustomer();

        Assert.Throws<InvalidOperationException>(() => lead.ConvertToCustomer());
    }

    [Fact]
    public void MarkLost_RequiresReason()
    {
        Assert.Throws<ArgumentException>(() => NewLead().MarkLost(""));
    }

    [Theory]
    [InlineData(LeadStatus.New, "Mới tạo")]
    [InlineData(LeadStatus.Lost, "Thất bại")]
    public void Describe_ReturnsVietnameseLabel(LeadStatus status, string expected)
    {
        Assert.Equal(expected, Lead.Describe(status));
    }
}

Với cả ba lab, dotnet test trên .NET 10 cho kết quả:

text
Passed!  - Failed:     0, Passed:    16, Skipped:     0, Total:    16

Xoá nhánh LeadStatus.Lost khỏi Describe rồi build, nhận đúng lỗi mong đợi (bỏ phần đường dẫn và số dòng):

text
error CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Crm.Domain.LeadStatus.Lost' is not covered.

Vì sao EnsureStatus kiểm tra trước khi đổi. Mọi phương thức kiểm tra điều kiện rồi mới gán Status, nên một lời gọi sai không bao giờ để lead ở trạng thái nửa vời — đúng nguyên tắc "bất biến nằm trong class" của bài 4.2.

4.13.4 — Rubric tổng (Quiz 20 + Lab 80) ​

MứcĐiểmMô tả
Chưa đạtDưới 60Lab chưa build được, hoặc nhiều test trong tiêu chí còn đỏ. Thường do còn setter công khai, trả List ra ngoài, hoặc Lead/Customer kế thừa nhau. Đọc lại bài 4.1, 4.2 và 4.9 rồi làm lại.
Đạt60 – 74Phần lớn test xanh. Domain model có constructor kiểm tra dữ liệu, nhưng còn vài chỗ lộ trạng thái (ví dụ TotalSpent lưu riêng, Status có setter công khai) hoặc thiếu test cho ca lỗi.
Khá75 – 89Mọi test trong tiêu chí xanh. Không có setter công khai, không lộ List, giá trị suy ra được thì tính; Describe không có _ và cấu hình CS8509 đúng. Tên lớp, hàm, test bằng tiếng Anh.
Giỏi90 – 100Như mức Khá, cộng thêm ít nhất một trong các mở rộng: test hợp đồng Equals/GetHashCode cho Email theo bài tập 2 của bài 4.12; thêm một quy tắc giảm giá mới chỉ bằng một lớp mới; hoặc viết ba câu giải thích vì sao ConvertToCustomer tạo object mới thay vì đổi kiểu.

4.13.5 — Tóm tắt Module 4 ​

Khái niệmĐiều cần mang theoBài
Class và constructorObject hợp lệ từ lúc sinh ra; constructor là cơ hội duy nhất để từ chối4.1
EncapsulationBất biến nghiệp vụ nằm trong class, thay đổi qua phương thức có tên; không lộ List4.2
Inheritance và polymorphismKế thừa cho quan hệ "là một" cố định về hành vi; ưu tiên composition; không gọi virtual trong constructor4.3
InterfaceĐảo hướng phụ thuộc; chia theo vai người dùng; không phải class nào cũng cần4.4
AbstractionĐổi cài đặt mà code gọi không phải sửa; dịch ngoại lệ ở ranh giới4.5
Access modifiersĐóng trước, mở sau; hợp đồng public, cài đặt internal4.6
Record và value objectSo sánh theo giá trị; bất biến nông; value object cần bất biến tuyệt đối dùng sealed record4.7
Pattern matchingCS8509 thành lỗi, CS8524 tắt; property pattern tự xử lý null; không vét cạn trên kiểu4.8
Lỗi thường gặpTrạng thái không phải kiểu; so sánh chuỗi dùng Ordinal4.9
C# hiện đạirequired không kiểm tra giá trị; thứ bạn tự tạo bằng new mà là IDisposable thì phải using4.10
Case studySố lớp tăng theo tổ hợp là dấu hiệu kế thừa sai chỗ4.11
Equals và GetHashCodeGhi đè cái này thì ghi đè cái kia; khoá của cấu trúc băm phải bất biến4.12

Bước tiếp theo: Module 5 — Advanced C#: nguyên tắc SOLID (gom lại những gì module này đã gặp: một lý do thay đổi, đảo ngược phụ thuộc, thay thế Liskov), generics, LINQ, delegate và nullable reference types.

Tham khảo ​

Điều hướng ​

Phát hành theo giấy phép MIT.