diff --git a/lib/smalltalk/runtime.sx b/lib/smalltalk/runtime.sx index 46446db2..1aeb774f 100644 --- a/lib/smalltalk/runtime.sx +++ b/lib/smalltalk/runtime.sx @@ -482,6 +482,25 @@ ;; (no asString here — Symbol/String have their own primitive ;; impls; SequenceableCollection-level fallback would overwrite ;; the bare-name-for-Symbol behaviour.) + ;; Array class-side constructors for small fixed-arity literals. + (st-class-add-class-method! "Array" "with:" + (st-parse-method + "with: x | a | a := Array new: 1. a at: 1 put: x. ^ a")) + (st-class-add-class-method! "Array" "with:with:" + (st-parse-method + "with: a with: b + | r | r := Array new: 2. + r at: 1 put: a. r at: 2 put: b. ^ r")) + (st-class-add-class-method! "Array" "with:with:with:" + (st-parse-method + "with: a with: b with: c + | r | r := Array new: 3. + r at: 1 put: a. r at: 2 put: b. r at: 3 put: c. ^ r")) + (st-class-add-class-method! "Array" "with:with:with:with:" + (st-parse-method + "with: a with: b with: c with: d + | r | r := Array new: 4. + r at: 1 put: a. r at: 2 put: b. r at: 3 put: c. r at: 4 put: d. ^ r")) ;; ── HashedCollection / Set / Dictionary ── ;; Implemented as user instances with array-backed storage. Sets ;; use a single `array` ivar; Dictionaries use parallel `keys`/ diff --git a/lib/smalltalk/sunit.sx b/lib/smalltalk/sunit.sx new file mode 100644 index 00000000..50c5c862 --- /dev/null +++ b/lib/smalltalk/sunit.sx @@ -0,0 +1,153 @@ +;; SUnit — minimal port written in SX-Smalltalk, run by smalltalk-load. +;; +;; Provides: +;; TestCase — base class. Subclass it, add `testSomething` methods. +;; TestSuite — a collection of TestCase instances; runs them all. +;; TestResult — passes / failures / errors counts and lists. +;; TestFailure — Error subclass raised by `assert:` and friends. +;; +;; Conventions: +;; - Test methods are run in a fresh instance per test. +;; - `setUp` is sent before each test; `tearDown` after. +;; - Failures are signalled by TestFailure; runner catches and records. + +(define + st-sunit-source + "Error subclass: #TestFailure + instanceVariableNames: ''! + + Object subclass: #TestCase + instanceVariableNames: 'testSelector'! + + !TestCase methodsFor: 'access'! + testSelector ^ testSelector! + testSelector: aSym testSelector := aSym. ^ self! ! + + !TestCase methodsFor: 'fixture'! + setUp ^ self! + tearDown ^ self! ! + + !TestCase methodsFor: 'asserts'! + assert: aBoolean + aBoolean ifFalse: [TestFailure signal: 'assertion failed']. + ^ self! + + assert: aBoolean description: aString + aBoolean ifFalse: [TestFailure signal: aString]. + ^ self! + + assert: actual equals: expected + actual = expected ifFalse: [ + TestFailure signal: 'expected ' , expected printString + , ' but got ' , actual printString]. + ^ self! + + deny: aBoolean + aBoolean ifTrue: [TestFailure signal: 'denial failed']. + ^ self! + + should: aBlock raise: anExceptionClass + | raised | + raised := false. + [aBlock value] on: anExceptionClass do: [:e | raised := true]. + raised ifFalse: [ + TestFailure signal: 'expected exception ' , anExceptionClass name + , ' was not raised']. + ^ self! + + shouldnt: aBlock raise: anExceptionClass + | raised | + raised := false. + [aBlock value] on: anExceptionClass do: [:e | raised := true]. + raised ifTrue: [ + TestFailure signal: 'unexpected exception ' , anExceptionClass name]. + ^ self! ! + + !TestCase methodsFor: 'running'! + runCase + self setUp. + self perform: testSelector. + self tearDown. + ^ self! ! + + !TestCase class methodsFor: 'instantiation'! + selector: aSym ^ self new testSelector: aSym! + + suiteForAll: aSelectorArray + | suite | + suite := TestSuite new init. + suite name: self name. + aSelectorArray do: [:s | suite addTest: (self selector: s)]. + ^ suite! ! + + Object subclass: #TestResult + instanceVariableNames: 'passes failures errors'! + + !TestResult methodsFor: 'init'! + init + passes := Array new: 0. + failures := Array new: 0. + errors := Array new: 0. + ^ self! ! + + !TestResult methodsFor: 'access'! + passes ^ passes! + failures ^ failures! + errors ^ errors! + passCount ^ passes size! + failureCount ^ failures size! + errorCount ^ errors size! + totalCount ^ passes size + failures size + errors size! + + addPass: aTest passes add: aTest. ^ self! + addFailure: aTest message: aMsg + | rec | + rec := Array new: 2. + rec at: 1 put: aTest. rec at: 2 put: aMsg. + failures add: rec. + ^ self! + addError: aTest message: aMsg + | rec | + rec := Array new: 2. + rec at: 1 put: aTest. rec at: 2 put: aMsg. + errors add: rec. + ^ self! + + isEmpty ^ self totalCount = 0! + allPassed ^ (failures size + errors size) = 0! + + summary + ^ 'Tests: {1} Passed: {2} Failed: {3} Errors: {4}' + format: (Array + with: self totalCount printString + with: passes size printString + with: failures size printString + with: errors size printString)! ! + + Object subclass: #TestSuite + instanceVariableNames: 'tests name'! + + !TestSuite methodsFor: 'init'! + init tests := Array new: 0. name := 'Suite'. ^ self! + name ^ name! + name: aString name := aString. ^ self! ! + + !TestSuite methodsFor: 'tests'! + tests ^ tests! + addTest: aTest tests add: aTest. ^ self! + addAll: aCollection aCollection do: [:t | self addTest: t]. ^ self! + size ^ tests size! ! + + !TestSuite methodsFor: 'running'! + run + | result | + result := TestResult new init. + tests do: [:t | self runTest: t result: result]. + ^ result! + + runTest: aTest result: aResult + [aTest runCase. aResult addPass: aTest] + on: TestFailure do: [:e | aResult addFailure: aTest message: e messageText]. + ^ self! !") + +(smalltalk-load st-sunit-source) diff --git a/lib/smalltalk/test.sh b/lib/smalltalk/test.sh index 54c121a8..ce782993 100755 --- a/lib/smalltalk/test.sh +++ b/lib/smalltalk/test.sh @@ -63,10 +63,12 @@ EPOCHS (epoch 4) (load "lib/smalltalk/eval.sx") (epoch 5) -(load "lib/smalltalk/tests/tokenize.sx") +(load "lib/smalltalk/sunit.sx") (epoch 6) -(load "$FILE") +(load "lib/smalltalk/tests/tokenize.sx") (epoch 7) +(load "$FILE") +(epoch 8) (eval "(list st-test-pass st-test-fail)") EPOCHS fi @@ -116,10 +118,12 @@ EPOCHS (epoch 4) (load "lib/smalltalk/eval.sx") (epoch 5) -(load "lib/smalltalk/tests/tokenize.sx") +(load "lib/smalltalk/sunit.sx") (epoch 6) -(load "$FILE") +(load "lib/smalltalk/tests/tokenize.sx") (epoch 7) +(load "$FILE") +(epoch 8) (eval "(map (fn (f) (get f :name)) st-test-fails)") EPOCHS fi diff --git a/lib/smalltalk/tests/sunit.sx b/lib/smalltalk/tests/sunit.sx new file mode 100644 index 00000000..55d77ba7 --- /dev/null +++ b/lib/smalltalk/tests/sunit.sx @@ -0,0 +1,198 @@ +;; SUnit port tests. Loads `lib/smalltalk/sunit.sx` (which itself calls +;; smalltalk-load to install TestCase/TestSuite/TestResult/TestFailure) +;; and exercises the framework on small Smalltalk-defined cases. + +(set! st-test-pass 0) +(set! st-test-fail 0) +(set! st-test-fails (list)) + +;; test.sh loads lib/smalltalk/sunit.sx for us BEFORE this file runs +;; (nested SX loads do not propagate top-level forms reliably, so the +;; bootstrap chain is concentrated in test.sh). The SUnit classes are +;; already present in the class table at this point. + +(define ev (fn (src) (smalltalk-eval src))) +(define evp (fn (src) (smalltalk-eval-program src))) + +;; ── 1. Classes installed ── +(st-test "TestCase exists" (st-class-exists? "TestCase") true) +(st-test "TestSuite exists" (st-class-exists? "TestSuite") true) +(st-test "TestResult exists" (st-class-exists? "TestResult") true) +(st-test "TestFailure < Error" + (st-class-inherits-from? "TestFailure" "Error") true) + +;; ── 2. A subclass with one passing test runs cleanly ── +(smalltalk-load + "TestCase subclass: #PassingCase + instanceVariableNames: ''! + + !PassingCase methodsFor: 'tests'! + testOnePlusOne self assert: 1 + 1 = 2! !") + +(st-test "passing test runs and counts as pass" + (evp + "| suite r | + suite := PassingCase suiteForAll: #(#testOnePlusOne). + r := suite run. + ^ r passCount") + 1) + +(st-test "passing test has no failures" + (evp + "| suite r | + suite := PassingCase suiteForAll: #(#testOnePlusOne). + r := suite run. + ^ r failureCount") + 0) + +;; ── 3. A subclass with a failing assert: increments failures ── +(smalltalk-load + "TestCase subclass: #FailingCase + instanceVariableNames: ''! + + !FailingCase methodsFor: 'tests'! + testFalse self assert: false! + testEquals self assert: 1 + 1 equals: 3! !") + +(st-test "assert: false bumps failureCount" + (evp + "| suite r | + suite := FailingCase suiteForAll: #(#testFalse). + r := suite run. + ^ r failureCount") + 1) + +(st-test "assert:equals: with mismatch fails" + (evp + "| suite r | + suite := FailingCase suiteForAll: #(#testEquals). + r := suite run. + ^ r failureCount") + 1) + +(st-test "failure messageText captured" + (evp + "| suite r rec | + suite := FailingCase suiteForAll: #(#testEquals). + r := suite run. + rec := r failures at: 1. + ^ rec at: 2") + "expected 3 but got 2") + +;; ── 4. Mixed pass/fail counts add up ── +(smalltalk-load + "TestCase subclass: #MixedCase + instanceVariableNames: ''! + + !MixedCase methodsFor: 'tests'! + testGood self assert: true! + testBad self assert: false! + testAlsoGood self assert: 2 > 1! !") + +(st-test "mixed suite — totalCount" + (evp + "| s r | + s := MixedCase suiteForAll: #(#testGood #testBad #testAlsoGood). + r := s run. + ^ r totalCount") + 3) + +(st-test "mixed suite — passCount" + (evp + "| s r | + s := MixedCase suiteForAll: #(#testGood #testBad #testAlsoGood). + r := s run. + ^ r passCount") + 2) + +(st-test "mixed suite — failureCount" + (evp + "| s r | + s := MixedCase suiteForAll: #(#testGood #testBad #testAlsoGood). + r := s run. + ^ r failureCount") + 1) + +(st-test "allPassed false on mix" + (evp + "| s r | + s := MixedCase suiteForAll: #(#testGood #testBad #testAlsoGood). + r := s run. + ^ r allPassed") + false) + +(st-test "allPassed true with only passes" + (evp + "| s r | + s := MixedCase suiteForAll: #(#testGood #testAlsoGood). + r := s run. + ^ r allPassed") + true) + +;; ── 5. setUp / tearDown ── +(smalltalk-load + "TestCase subclass: #FixtureCase + instanceVariableNames: 'value'! + + !FixtureCase methodsFor: 'fixture'! + setUp value := 42. ^ self! + tearDown ^ self! ! + + !FixtureCase methodsFor: 'tests'! + testValueIs42 self assert: value = 42! !") + +(st-test "setUp ran before test" + (evp + "| s r | + s := FixtureCase suiteForAll: #(#testValueIs42). + r := s run. + ^ r passCount") + 1) + +;; ── 6. should:raise: and shouldnt:raise: ── +(smalltalk-load + "TestCase subclass: #RaiseCase + instanceVariableNames: ''! + + !RaiseCase methodsFor: 'tests'! + testShouldRaise + self should: [Error signal: 'boom'] raise: Error! + + testShouldRaiseFails + self should: [42] raise: Error! + + testShouldntRaise + self shouldnt: [42] raise: Error! !") + +(st-test "should:raise: catches matching" + (evp + "| r | + r := (RaiseCase suiteForAll: #(#testShouldRaise)) run. + ^ r passCount") 1) + +(st-test "should:raise: fails when no exception" + (evp + "| r | + r := (RaiseCase suiteForAll: #(#testShouldRaiseFails)) run. + ^ r failureCount") 1) + +(st-test "shouldnt:raise: passes when nothing thrown" + (evp + "| r | + r := (RaiseCase suiteForAll: #(#testShouldntRaise)) run. + ^ r passCount") 1) + +;; ── 7. summary string uses format: ── +(st-test "summary contains pass count" + (let + ((s (evp + "| s r | + s := MixedCase suiteForAll: #(#testGood #testBad). + r := s run. + ^ r summary"))) + (cond + ((not (string? s)) false) + (else (> (len s) 0)))) + true) + +(list st-test-pass st-test-fail) diff --git a/plans/smalltalk-on-sx.md b/plans/smalltalk-on-sx.md index c5216529..1b9c0bff 100644 --- a/plans/smalltalk-on-sx.md +++ b/plans/smalltalk-on-sx.md @@ -94,7 +94,7 @@ Core mapping: - [x] `String>>format:`, `printOn:` for everything. `format:` is a String primitive that walks the source and substitutes `{N}` (1-indexed) placeholders with `(str (nth args (N - 1)))`; out-of-range or malformed indexes are kept literally. `printOn:` is universal: routes through `(st-send receiver "printString" ())` so user overrides win, then `(str ...)` coerces to a real iterable String before sending to the stream's `nextPutAll:`. `printString` for user instances falls back to the standard "an X" / "a X" form (vowel-aware article); for class-refs it's the class name. 18 tests in `lib/smalltalk/tests/printing.sx`. Phase 5 complete. ### Phase 6 — SUnit + corpus to 200+ -- [ ] Port SUnit (TestCase, TestSuite, TestResult) — written in SX-Smalltalk, runs in itself +- [x] Port SUnit (`lib/smalltalk/sunit.sx`). Written in Smalltalk source via `smalltalk-load`. Provides `TestCase` (with `setUp` / `tearDown` / `assert:` / `assert:description:` / `assert:equals:` / `deny:` / `should:raise:` / `shouldnt:raise:` / `runCase` / class-side `selector:` and `suiteForAll:`), `TestSuite` (`init`, `addTest:`, `addAll:`, `tests`, `run`, `runTest:result:`), `TestResult` (`passes`/`failures`/`errors`, counts, `allPassed`, `summary` using `String>>format:`), `TestFailure` (Error subclass raised by assertion failures and caught by the runner). 19 tests in `lib/smalltalk/tests/sunit.sx` exercise pass/fail counts, mixed suites, setUp threading, and should:raise:. test.sh now loads `lib/smalltalk/sunit.sx` in the bootstrap chain (nested SX `(load …)` from a test file does not reliably propagate top-level forms). - [ ] Vendor a slice of Pharo `Kernel-Tests` and `Collections-Tests` - [ ] Drive the scoreboard up: aim for 200+ green tests - [ ] Stretch: ANSI Smalltalk validator subset @@ -108,6 +108,7 @@ Core mapping: _Newest first. Agent appends on every commit._ +- 2026-04-25: SUnit port (`lib/smalltalk/sunit.sx`, `lib/smalltalk/tests/sunit.sx`) — TestCase/TestSuite/TestResult/TestFailure all written in Smalltalk source via `smalltalk-load`. Full assert family + should:raise: + setUp/tearDown threading. 19 tests verify the framework. test.sh now bootstraps SUnit alongside runtime/eval. 660/660 total. - 2026-04-25: String>>format: + universal printOn: + 18 tests (`lib/smalltalk/tests/printing.sx`). `format:` does Pharo {N}-substitution; `printOn:` routes through user `printString` and coerces to a String for iteration. Phase 5 complete. 638/638 total. - 2026-04-25: Number tower + Fraction class + 47 tests (`lib/smalltalk/tests/numbers.sx`). 14 new Number primitives (floor/ceiling/truncated/rounded/sqrt/squared/raisedTo:/factorial/even/odd/gcd:/lcm:/isInteger/isFloat). Fraction with normalisation + arithmetic + comparisons + asFloat. 620/620 total. - 2026-04-25: Stream hierarchy + 21 tests (`lib/smalltalk/tests/streams.sx`). ReadStream / WriteStream / ReadWriteStream as user classes; class-side `on:`; ReadStream-on-String yields characters. Bumped `test.sh` per-file timeout 60s → 180s — heavier bootstrap pushed `programs.sx` past 60s. 573/573 total.