diff --git a/src/app/components/markdown-editor/markdown-editor.spec.ts b/src/app/components/markdown-editor/markdown-editor.spec.ts
new file mode 100644
index 0000000..e53072e
--- /dev/null
+++ b/src/app/components/markdown-editor/markdown-editor.spec.ts
@@ -0,0 +1,164 @@
+import { Component, signal } from '@angular/core';
+import { TestBed, type ComponentFixture } from '@angular/core/testing';
+import { MarkdownEditorComponent } from './markdown-editor';
+
+@Component({
+ standalone: true,
+ imports: [MarkdownEditorComponent],
+ template: ``,
+})
+class TestHostComponent {
+ content = signal('# Hello');
+ lastContentChange: string | null = null;
+
+ onContentChange(value: string): void {
+ this.lastContentChange = value;
+ }
+}
+
+function getEditor(fixture: ComponentFixture): MarkdownEditorComponent {
+ return fixture.debugElement.children[0].componentInstance as MarkdownEditorComponent;
+}
+
+describe('MarkdownEditorComponent', () => {
+ let host: TestHostComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TestHostComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TestHostComponent);
+ host = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ fixture.destroy();
+ });
+
+ // --- Creation ---
+
+ it('should create', () => {
+ expect(getEditor(fixture)).toBeTruthy();
+ });
+
+ it('should create an EditorView on initial render', () => {
+ const comp = getEditor(fixture);
+ expect(comp['view']).toBeTruthy();
+ });
+
+ it('should initialize with the provided content', () => {
+ const comp = getEditor(fixture);
+ const doc = comp['view']!.state.doc.toString();
+ expect(doc).toBe('# Hello');
+ });
+
+ it('should render the editor host element', () => {
+ const editorHost = fixture.nativeElement.querySelector('.editor-host');
+ expect(editorHost).toBeTruthy();
+ });
+
+ it('should have aria-label on the editor', () => {
+ const cmContent = fixture.nativeElement.querySelector('[aria-label="Markdown editor"]');
+ expect(cmContent).toBeTruthy();
+ });
+
+ // --- Content change emission ---
+
+ it('should emit contentChange when the document changes via dispatch', () => {
+ const comp = getEditor(fixture);
+ const view = comp['view']!;
+
+ // Simulate a user edit by dispatching a transaction
+ view.dispatch({
+ changes: { from: view.state.doc.length, insert: '\nWorld' },
+ });
+
+ expect(host.lastContentChange).toBe('# Hello\nWorld');
+ });
+
+ it('should set suppressUpdate when document changes', () => {
+ const comp = getEditor(fixture);
+ const view = comp['view']!;
+
+ view.dispatch({
+ changes: { from: view.state.doc.length, insert: '!' },
+ });
+
+ expect(comp['suppressUpdate']).toBe(true);
+ });
+
+ // --- External content sync ---
+
+ it('should sync editor when content input changes externally', async () => {
+ const comp = getEditor(fixture);
+
+ host.content.set('# World');
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ const doc = comp['view']!.state.doc.toString();
+ expect(doc).toBe('# World');
+ });
+
+ // --- Identical content no-op ---
+
+ it('should not dispatch when content is identical', async () => {
+ const comp = getEditor(fixture);
+ const view = comp['view']!;
+ const dispatchSpy = vi.spyOn(view, 'dispatch');
+
+ // Set content to the same value
+ host.content.set('# Hello');
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ // dispatch should not have been called since the content is the same
+ expect(dispatchSpy).not.toHaveBeenCalled();
+ });
+
+ // --- Suppress flag prevents feedback loop ---
+
+ it('should skip dispatch when suppressUpdate is true', async () => {
+ const comp = getEditor(fixture);
+ const view = comp['view']!;
+
+ // Simulate user typing (sets suppressUpdate = true)
+ view.dispatch({
+ changes: { from: view.state.doc.length, insert: '!' },
+ });
+ expect(comp['suppressUpdate']).toBe(true);
+
+ const dispatchSpy = vi.spyOn(view, 'dispatch');
+
+ // Parent reacts to contentChange by updating content input
+ host.content.set(view.state.doc.toString());
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ // Effect should have skipped dispatch and reset suppressUpdate
+ expect(dispatchSpy).not.toHaveBeenCalled();
+ expect(comp['suppressUpdate']).toBe(false);
+ });
+
+ // --- Destroy cleanup ---
+
+ it('should call view.destroy() on ngOnDestroy', () => {
+ const comp = getEditor(fixture);
+ const view = comp['view']!;
+ const destroySpy = vi.spyOn(view, 'destroy');
+
+ comp.ngOnDestroy();
+
+ expect(destroySpy).toHaveBeenCalledOnce();
+ });
+
+ it('should handle ngOnDestroy when view is null', () => {
+ const comp = getEditor(fixture);
+ comp['view'] = null;
+
+ expect(() => comp.ngOnDestroy()).not.toThrow();
+ });
+});