Directive 測試指南

Directive 測試的核心概念

為什麼 Directive 測試特殊?

Directive 與 Component 的根本差異在於:Directive 無法獨立存在,必須附加到宿主元素上才能運作。

錯誤示範 vs 正確方法

// ❌ 無法直接測試 Directive
describe('MyDirective', () => {
  it('should create', () => {
    const directive = new MyDirective(); // 錯誤:缺少宿主和依賴
    expect(directive).toBeTruthy();
  });
});

// ❌ 複雜的手動依賴注入
describe('MyDirective', () => {
  it('should create', () => {
    const mockElementRef = { nativeElement: document.createElement('div') };
    const mockRenderer = jasmine.createSpyObj('Renderer2', ['addClass']);
    const mockDestroyRef = jasmine.createSpyObj('DestroyRef', ['onDestroy']);
    
    TestBed.runInInjectionContext(() => {
      const directive = new MyDirective(mockElementRef, mockRenderer, mockDestroyRef);
      // 還需要手動觸發生命週期、設定屬性等...
    });
  });
});

// ✅ 正確方法:使用 Host Component
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MyDirective } from './my-directive';

@Component({
  standalone: true,
  imports: [MyDirective],
  template: `<div appMyDirective>Content</div>`
})
class HostComponent {}

describe('MyDirective', () => {
  let fixture: ComponentFixture<HostComponent>;
  let host: HostComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [HostComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(HostComponent);
    host = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(host).toBeTruthy();
  });
});

簡潔測試方法論:三步驟測試法

基本測試模板

import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { YourDirective } from './your-directive';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';

@Component({
  standalone: true,
  imports: [YourDirective],
  template: `
    <div 
      yourDirective
      [property1]="value1"
      [property2]="value2">
      Test Content
    </div>
  `,
})
class HostComponent {
  value1 = 'default';
  value2 = false;
}

describe('YourDirective', () => {
  let fixture: ComponentFixture<HostComponent>;
  let host: HostComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [HostComponent, NoopAnimationsModule],
    }).compileComponents();

    fixture = TestBed.createComponent(HostComponent);
    host = fixture.componentInstance;
    fixture.detectChanges();
  });

  // 步驟 1:建立測試
  it('should create', () => {
    expect(host).toBeTruthy();
  });

  // 步驟 2:套用測試
  it('should apply directive without errors', () => {
    const element = fixture.nativeElement.querySelector('div');
    expect(element).toBeTruthy();
    
    // 驗證基本互動不會出錯
    expect(() => {
      element.click();
      fixture.detectChanges();
    }).not.toThrow();
  });

  // 步驟 3:屬性變更測試
  it('should handle input property changes', () => {
    host.value1 = 'updated';
    host.value2 = true;
    fixture.detectChanges();

    // 驗證屬性變更不會造成錯誤
    const element = fixture.nativeElement.querySelector('div');
    expect(element).toBeTruthy();
  });
});

Host Component 設計模式

模式 1:簡單模式

適用於最基本的功能測試:

@Component({
  standalone: true,
  imports: [HighlightDirective],
  template: `<div appHighlight>Highlighted Text</div>`,
})
class SimpleHostComponent {}

describe('HighlightDirective - Simple', () => {
  let fixture: ComponentFixture<SimpleHostComponent>;
  let host: SimpleHostComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [SimpleHostComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(SimpleHostComponent);
    host = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(host).toBeTruthy();
  });

  it('should apply without errors', () => {
    const element = fixture.nativeElement.querySelector('div');
    expect(element).toBeTruthy();
  });
});

模式 2:屬性控制模式

適用於需要測試不同 input 組合:

@Component({
  standalone: true,
  imports: [ColorHighlightDirective],
  template: `
    <div 
      appColorHighlight
      [highlightColor]="color"
      [isEnabled]="enabled"
      [intensity]="intensity">
      Highlighted Text
    </div>
  `,
})
class PropertyHostComponent {
  color = 'yellow';
  enabled = true;
  intensity = 0.5;
}

describe('ColorHighlightDirective - Properties', () => {
  let fixture: ComponentFixture<PropertyHostComponent>;
  let host: PropertyHostComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [PropertyHostComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(PropertyHostComponent);
    host = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should handle color changes', () => {
    host.color = 'red';
    fixture.detectChanges();

    const element = fixture.nativeElement.querySelector('div');
    expect(element).toBeTruthy();
  });

  it('should handle enabled/disabled state', () => {
    host.enabled = false;
    fixture.detectChanges();

    const element = fixture.nativeElement.querySelector('div');
    expect(element).toBeTruthy();
  });

  it('should handle intensity changes', () => {
    host.intensity = 1.0;
    fixture.detectChanges();

    const element = fixture.nativeElement.querySelector('div');
    expect(element).toBeTruthy();
  });

  it('should handle multiple property changes', () => {
    host.color = 'blue';
    host.enabled = false;
    host.intensity = 0.3;
    fixture.detectChanges();

    const element = fixture.nativeElement.querySelector('div');
    expect(element).toBeTruthy();
  });
});

模式 3:事件處理模式

適用於需要測試輸出事件:

@Component({
  standalone: true,
  imports: [ClickTrackerDirective],
  template: `
    <button 
      appClickTracker
      [trackingEnabled]="trackingEnabled"
      [trackingData]="trackingData"
      (trackingEvent)="onTrackingEvent($event)"
      (errorEvent)="onErrorEvent($event)">
      Click Me
    </button>
  `,
})
class EventHostComponent {
  trackingEnabled = true;
  trackingData = { page: 'test' };
  
  lastTrackingEvent: any = null;
  lastErrorEvent: any = null;

  onTrackingEvent(event: any) {
    this.lastTrackingEvent = event;
  }

  onErrorEvent(event: any) {
    this.lastErrorEvent = event;
  }
}

describe('ClickTrackerDirective - Events', () => {
  let fixture: ComponentFixture<EventHostComponent>;
  let host: EventHostComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [EventHostComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(EventHostComponent);
    host = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should handle click events when tracking enabled', () => {
    host.trackingEnabled = true;
    fixture.detectChanges();

    const button = fixture.nativeElement.querySelector('button');
    button.click();
    fixture.detectChanges();

    expect(button).toBeTruthy();
    // 可選:驗證事件是否正確觸發
    // expect(host.lastTrackingEvent).not.toBeNull();
  });

  it('should handle tracking disabled state', () => {
    host.trackingEnabled = false;
    fixture.detectChanges();

    const button = fixture.nativeElement.querySelector('button');
    button.click();
    fixture.detectChanges();

    expect(button).toBeTruthy();
  });

  it('should handle tracking data changes', () => {
    host.trackingData = { page: 'updated', section: 'test' };
    fixture.detectChanges();

    const button = fixture.nativeElement.querySelector('button');
    expect(button).toBeTruthy();
  });
});

模式 4:真實環境模式

適用於與第三方函式庫整合測試:

@Component({
  standalone: true,
  imports: [
    MatSelectKeyboardNavigationDirective,
    MatSelectModule,
    MatFormFieldModule,
    ReactiveFormsModule,
  ],
  template: `
    <form [formGroup]="testForm">
      <mat-form-field>
        <mat-select 
          appMatSelectKeyboardNavigation
          [searchable]="searchable"
          [isMultipleSelector]="isMultiple"
          [stickyHeaderHeight]="headerHeight"
          formControlName="selection">
          <mat-option value="option1">Option 1</mat-option>
          <mat-option value="option2">Option 2</mat-option>
          <mat-option value="option3">Option 3</mat-option>
        </mat-select>
      </mat-form-field>
    </form>
  `,
})
class RealisticHostComponent {
  searchable = false;
  isMultiple = false;
  headerHeight: number | null = null;

  testForm = this.fb.group({
    selection: ['']
  });

  constructor(private fb: FormBuilder) {}
}

describe('MatSelectKeyboardNavigationDirective - Integration', () => {
  let fixture: ComponentFixture<RealisticHostComponent>;
  let host: RealisticHostComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [RealisticHostComponent, NoopAnimationsModule],
    }).compileComponents();

    fixture = TestBed.createComponent(RealisticHostComponent);
    host = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(host).toBeTruthy();
  });

  it('should apply directive without errors', () => {
    const matSelect = fixture.nativeElement.querySelector('mat-select');
    expect(matSelect).toBeTruthy();
    
    expect(() => {
      matSelect.click();
      fixture.detectChanges();
    }).not.toThrow();
  });

  it('should handle searchable mode', () => {
    host.searchable = true;
    fixture.detectChanges();

    const matSelect = fixture.nativeElement.querySelector('mat-select');
    expect(() => {
      matSelect.click();
      fixture.detectChanges();
    }).not.toThrow();
  });

  it('should handle multiple selector mode', () => {
    host.isMultiple = true;
    fixture.detectChanges();

    const matSelect = fixture.nativeElement.querySelector('mat-select');
    expect(matSelect).toBeTruthy();
  });

  it('should work with reactive forms', () => {
    host.testForm.patchValue({ selection: 'option1' });
    fixture.detectChanges();

    expect(host.testForm.get('selection')?.value).toBe('option1');
  });
});

不同類型 Directive 的測試實例

屬性型 Directive (Attribute Directives)

修改元素外觀或行為的 directive:

// 假設有一個工具提示 directive
@Component({
  standalone: true,
  imports: [TooltipDirective],
  template: `
    <button 
      appTooltip
      [tooltipText]="tooltipText"
      [tooltipPosition]="position"
      [tooltipDelay]="delay"
      [disabled]="disabled">
      Hover me
    </button>
  `,
})
class TooltipHostComponent {
  tooltipText = 'This is a tooltip';
  position = 'top';
  delay = 500;
  disabled = false;
}

describe('TooltipDirective', () => {
  let fixture: ComponentFixture<TooltipHostComponent>;
  let host: TooltipHostComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [TooltipHostComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(TooltipHostComponent);
    host = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(host).toBeTruthy();
  });

  it('should handle tooltip text changes', () => {
    host.tooltipText = 'Updated tooltip';
    fixture.detectChanges();

    const button = fixture.nativeElement.querySelector('button');
    expect(button).toBeTruthy();
  });

  it('should handle position changes', () => {
    host.position = 'bottom';
    fixture.detectChanges();

    const button = fixture.nativeElement.querySelector('button');
    expect(button).toBeTruthy();
  });

  it('should handle disabled state', () => {
    host.disabled = true;
    fixture.detectChanges();

    const button = fixture.nativeElement.querySelector('button');
    expect(button).toBeTruthy();
  });

  it('should handle mouse events', () => {
    const button = fixture.nativeElement.querySelector('button');
    
    expect(() => {
      // 模擬滑鼠移入
      button.dispatchEvent(new MouseEvent('mouseenter'));
      fixture.detectChanges();
      
      // 模擬滑鼠移出
      button.dispatchEvent(new MouseEvent('mouseleave'));
      fixture.detectChanges();
    }).not.toThrow();
  });
});

結構型 Directive (Structural Directives)

修改 DOM 結構的 directive:

// 假設有一個權限控制 directive
@Component({
  standalone: true,
  imports: [PermissionDirective],
  template: `
    <div>
      <div *appPermission="currentPermission">
        Protected Content
      </div>
      <div *appPermission="'admin'; else: elseTemplate">
        Admin Only Content
      </div>
      <ng-template #elseTemplate>
        <div>No Permission</div>
      </ng-template>
    </div>
  `,
})
class PermissionHostComponent {
  currentPermission = 'user';
}

describe('PermissionDirective', () => {
  let fixture: ComponentFixture<PermissionHostComponent>;
  let host: PermissionHostComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [PermissionHostComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(PermissionHostComponent);
    host = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(host).toBeTruthy();
  });

  it('should handle permission changes', () => {
    host.currentPermission = 'admin';
    fixture.detectChanges();

    const container = fixture.nativeElement;
    expect(container).toBeTruthy();
  });

  it('should handle null permission', () => {
    host.currentPermission = null as any;
    
    expect(() => {
      fixture.detectChanges();
    }).not.toThrow();
  });

  it('should render else template when needed', () => {
    // 這個測試可以檢查 DOM 結構,但保持簡單
    const container = fixture.nativeElement;
    expect(container.children.length).toBeGreaterThan(0);
  });
});

表單相關 Directive

與表單驗證和處理相關的 directive:

@Component({
  standalone: true,
  imports: [CustomValidatorDirective, ReactiveFormsModule],
  template: `
    <form [formGroup]="testForm">
      <input 
        type="email"
        formControlName="email"
        appCustomValidator
        [validationRules]="emailRules"
        [customMessages]="customMessages"
        [validateOnBlur]="validateOnBlur">
      
      <input 
        type="password"
        formControlName="password"
        appCustomValidator
        [validationRules]="passwordRules">
    </form>
  `,
})
class FormValidatorHostComponent {
  emailRules = ['required', 'email', 'minLength:5'];
  passwordRules = ['required', 'minLength:8', 'hasNumber'];
  customMessages = {
    required: 'This field is required',
    email: 'Please enter a valid email'
  };
  validateOnBlur = true;

  testForm = this.fb.group({
    email: [''],
    password: ['']
  });

  constructor(private fb: FormBuilder) {}
}

describe('CustomValidatorDirective', () => {
  let fixture: ComponentFixture<FormValidatorHostComponent>;
  let host: FormValidatorHostComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [FormValidatorHostComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(FormValidatorHostComponent);
    host = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(host).toBeTruthy();
  });

  it('should apply to form controls', () => {
    const emailInput = fixture.nativeElement.querySelector('input[type="email"]');
    const passwordInput = fixture.nativeElement.querySelector('input[type="password"]');
    
    expect(emailInput).toBeTruthy();
    expect(passwordInput).toBeTruthy();
  });

  it('should handle validation rule changes', () => {
    host.emailRules = ['required', 'email'];
    fixture.detectChanges();

    const emailInput = fixture.nativeElement.querySelector('input[type="email"]');
    expect(emailInput).toBeTruthy();
  });

  it('should handle user input', () => {
    const emailInput = fixture.nativeElement.querySelector('input[type="email"]');
    
    expect(() => {
      emailInput.value = 'test@example.com';
      emailInput.dispatchEvent(new Event('input'));
      fixture.detectChanges();
    }).not.toThrow();
  });

  it('should handle blur events', () => {
    const emailInput = fixture.nativeElement.querySelector('input[type="email"]');
    
    expect(() => {
      emailInput.dispatchEvent(new Event('blur'));
      fixture.detectChanges();
    }).not.toThrow();
  });

  it('should handle form value changes', () => {
    host.testForm.patchValue({
      email: 'test@example.com',
      password: 'password123'
    });
    
    expect(() => {
      fixture.detectChanges();
    }).not.toThrow();
  });
});

進階測試技巧

異步操作處理

describe('AsyncDirective', () => {
  it('should handle async operations', async () => {
    const element = fixture.nativeElement.querySelector('div');
    
    // 觸發異步操作
    element.click();
    fixture.detectChanges();
    
    // 等待異步操作完成
    await fixture.whenStable();
    
    expect(element).toBeTruthy();
  });

  it('should handle timed operations', fakeAsync(() => {
    const element = fixture.nativeElement.querySelector('div');
    
    // 觸發定時操作
    element.dispatchEvent(new MouseEvent('mouseenter'));
    fixture.detectChanges();
    
    // 快進時間
    tick(1000);
    
    expect(element).toBeTruthy();
    
    flush();
  }));
});

錯誤處理和邊界測試

describe('DirectiveErrorHandling', () => {
  it('should handle null input values', () => {
    host.inputValue = null;
    
    expect(() => {
      fixture.detectChanges();
    }).not.toThrow();
  });

  it('should handle undefined input values', () => {
    host.inputValue = undefined;
    
    expect(() => {
      fixture.detectChanges();
    }).not.toThrow();
  });

  it('should handle empty string values', () => {
    host.inputValue = '';
    
    expect(() => {
      fixture.detectChanges();
    }).not.toThrow();
  });

  it('should handle large data sets', () => {
    host.largeDataSet = new Array(1000).fill(0).map((_, i) => ({ id: i, name: `Item ${i}` }));
    
    expect(() => {
      fixture.detectChanges();
    }).not.toThrow();
  });

  it('should handle rapid property changes', () => {
    // 模擬快速變更
    for (let i = 0; i < 100; i++) {
      host.rapidChangeValue = `value-${i}`;
      fixture.detectChanges();
    }
    
    expect(host.rapidChangeValue).toBe('value-99');
  });
});

與其他元件的整合測試

@Component({
  standalone: true,
  imports: [
    YourDirective,
    ChildComponent,
    MatDialogModule,
    MatButtonModule
  ],
  template: `
    <div appYourDirective [config]="directiveConfig">
      <app-child-component 
        [data]="childData"
        (childEvent)="onChildEvent($event)">
      </app-child-component>
      
      <button mat-button (click)="openDialog()">
        Open Dialog
      </button>
    </div>
  `,
})
class IntegrationHostComponent {
  directiveConfig = { enabled: true, theme: 'dark' };
  childData = { items: ['item1', 'item2'] };

  onChildEvent(event: any) {
    console.log('Child event received:', event);
  }

  openDialog() {
    // 對話框邏輯
  }
}

describe('YourDirective - Integration', () => {
  let fixture: ComponentFixture<IntegrationHostComponent>;
  let host: IntegrationHostComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [IntegrationHostComponent, NoopAnimationsModule],
    }).compileComponents();

    fixture = TestBed.createComponent(IntegrationHostComponent);
    host = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should work with child components', () => {
    const childComponent = fixture.nativeElement.querySelector('app-child-component');
    expect(childComponent).toBeTruthy();
  });

  it('should work with Material Design components', () => {
    const button = fixture.nativeElement.querySelector('button[mat-button]');
    
    expect(() => {
      button.click();
      fixture.detectChanges();
    }).not.toThrow();
  });

  it('should handle complex config changes', () => {
    host.directiveConfig = { 
      enabled: false, 
      theme: 'light', 
      animation: 'fade',
      duration: 300 
    };
    host.childData = { items: ['new1', 'new2', 'new3'] };
    
    expect(() => {
      fixture.detectChanges();
    }).not.toThrow();
  });
});

測試配置的實際範例

基本配置

// 最簡單的配置
describe('SimpleDirective', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [HostComponent]
    }).compileComponents();
  });
});

進階配置

// 需要額外服務和設定的配置
describe('ComplexDirective', () => {
  let mockService: jasmine.SpyObj<DataService>;

  beforeEach(async () => {
    const spy = jasmine.createSpyObj('DataService', ['getData', 'updateData']);
    
    await TestBed.configureTestingModule({
      imports: [HostComponent, NoopAnimationsModule],
      providers: [
        { provide: DataService, useValue: spy },
        { provide: CONFIG_TOKEN, useValue: { apiUrl: 'test-url' } }
      ]
    }).compileComponents();

    mockService = TestBed.inject(DataService) as jasmine.SpyObj<DataService>;
    mockService.getData.and.returnValue(of([]));
  });
});

全域設定配置

// 處理全域設定的配置
describe('GlobalDirective', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [HostComponent],
      providers: [
        {
          provide: LOCALE_ID,
          useValue: 'zh-TW'
        },
        {
          provide: MatIconRegistry,
          useValue: {
            addSvgIcon: () => {},
            getNamedSvgIcon: () => of(document.createElementNS('http://www.w3.org/2000/svg', 'svg'))
          }
        }
      ]
    }).compileComponents();
  });
});

實用的測試輔助函數

測試輔助類別

class DirectiveTestHelper {
  constructor(private fixture: ComponentFixture<any>) {}

  clickElement(selector: string): void {
    const element = this.fixture.nativeElement.querySelector(selector);
    if (element) {
      element.click();
      this.fixture.detectChanges();
    }
  }

  setInputValue(selector: string, value: string): void {
    const input = this.fixture.nativeElement.querySelector(selector);
    if (input) {
      input.value = value;
      input.dispatchEvent(new Event('input'));
      this.fixture.detectChanges();
    }
  }

  triggerEvent(selector: string, eventType: string, eventData?: any): void {
    const element = this.fixture.nativeElement.querySelector(selector);
    if (element) {
      element.dispatchEvent(new Event(eventType, eventData));
      this.fixture.detectChanges();
    }
  }

  waitForAsync(): Promise<void> {
    return this.fixture.whenStable();
  }
}

// 使用範例
describe('DirectiveWithHelper', () => {
  let helper: DirectiveTestHelper;

  beforeEach(() => {
    helper = new DirectiveTestHelper(fixture);
  });

  it('should handle user interactions', async () => {
    helper.clickElement('button');
    helper.setInputValue('input', 'test value');
    await helper.waitForAsync();
    
    expect(/* 驗證結果 */).toBeTruthy();
  });
});

測試資料工廠

class DirectiveTestDataFactory {
  static createBasicConfig() {
    return {
      enabled: true,
      theme: 'default',
      animation: true
    };
  }

  static createComplexConfig() {
    return {
      enabled: true,
      theme: 'dark',
      animation: true,
      duration: 300,
      easing: 'ease-in-out',
      triggers: ['click', 'hover'],
      customStyles: {
        backgroundColor: '#333',
        color: '#fff'
      }
    };
  }

  static createLargeDataSet(size: number = 100) {
    return Array.from({ length: size }, (_, i) => ({
      id: i,
      name: `Item ${i}`,
      value: Math.random(),
      active: i % 2 === 0
    }));
  }
}

// 使用範例
describe('DirectiveWithTestData', () => {
  it('should handle basic config', () => {
    host.config = DirectiveTestDataFactory.createBasicConfig();
    fixture.detectChanges();
    
    expect(/* 驗證 */).toBeTruthy();
  });

  it('should handle complex config', () => {
    host.config = DirectiveTestDataFactory.createComplexConfig();
    fixture.detectChanges();
    
    expect(/* 驗證 */).toBeTruthy();
  });
});

總結

透過這些詳細的程式碼範例,Directive 測試的關鍵要點:

核心模式

  • Host Component - 提供真實的 Angular 環境

  • 三步驟測試法 - create、apply、property changes

  • 適度測試 - 專注於不出錯和基本功能

實用技巧

  • 輔助函數 - 簡化重複的測試操作

  • 測試資料工廠 - 統一管理測試資料

  • 分層配置 - 根據複雜度選擇適當的 TestBed 設定

最佳實踐

  • 真實環境優先 - 使用接近實際使用的 Host Component

  • 錯誤處理 - 測試邊界條件和異常情況

  • 保持簡潔 - 避免過度複雜的測試邏輯

Last updated